Skip to content

Commit 7a4aed4

Browse files
AHBcodex
andauthored
feat: record clipboard actions (#445)
Co-authored-by: Codex <noreply@openai.com>
1 parent a16dec2 commit 7a4aed4

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

csr/csr-recorder/src/engine/rrweb-engine.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,39 @@ describe('RrwebEngine', () => {
6969
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
7070
});
7171

72+
it('records copy, cut, and paste actions without reading clipboard contents', () => {
73+
new RrwebEngine().start({}, () => {});
74+
const plugin = recordSpy.mock.calls[0][0].plugins.find(({ name }: { name: string }) => name === 'csr/clipboard@1');
75+
const getId = vi.fn().mockReturnValue(42);
76+
plugin.getMirror({ nodeMirror: { getId } });
77+
const callback = vi.fn();
78+
const removeObserver = plugin.observer(callback, window);
79+
const input = document.createElement('input');
80+
document.body.appendChild(input);
81+
82+
for (const action of ['copy', 'cut', 'paste']) {
83+
const event = new Event(action, { bubbles: true });
84+
Object.defineProperty(event, 'clipboardData', {
85+
get: () => {
86+
throw new Error('clipboard contents must not be read');
87+
},
88+
});
89+
expect(() => input.dispatchEvent(event)).not.toThrow();
90+
}
91+
92+
expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
93+
{ action: 'copy', targetId: 42 },
94+
{ action: 'cut', targetId: 42 },
95+
{ action: 'paste', targetId: 42 },
96+
]);
97+
expect(getId).toHaveBeenCalledTimes(3);
98+
99+
removeObserver();
100+
input.dispatchEvent(new Event('paste', { bubbles: true }));
101+
expect(callback).toHaveBeenCalledTimes(3);
102+
input.remove();
103+
});
104+
72105
it('keeps native click modifiers through a browser microtask checkpoint', async () => {
73106
new RrwebEngine().start({}, () => {});
74107
const plugin = recordSpy.mock.calls[0][0].plugins.find(

csr/csr-recorder/src/engine/rrweb-engine.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@ type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number]
1010

1111
type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
1212

13+
type ClipboardAction = 'copy' | 'cut' | 'paste';
14+
15+
/**
16+
* Record clipboard actions and their DOM target without reading clipboard
17+
* contents. The resulting rrweb Plugin events can explain otherwise
18+
* surprising input changes during analysis.
19+
*/
20+
function clipboardActionsPlugin(): RrwebPlugin {
21+
let getId: ((node: Node) => number) | undefined;
22+
23+
return {
24+
name: 'csr/clipboard@1',
25+
options: {},
26+
getMirror: ({ nodeMirror }) => {
27+
getId = node => nodeMirror.getId(node);
28+
},
29+
observer: (callback, win) => {
30+
const actions: ClipboardAction[] = ['copy', 'cut', 'paste'];
31+
const handlers = actions.map(action => {
32+
const handler = (event: Event) => {
33+
const targetId = event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1;
34+
callback({ action, targetId });
35+
};
36+
37+
win.document.addEventListener(action, handler, true);
38+
return () => win.document.removeEventListener(action, handler, true);
39+
});
40+
41+
return () => handlers.forEach(remove => remove());
42+
},
43+
};
44+
}
45+
1346
/**
1447
* rrweb does not include modifier keys in mouse-interaction events. Capture
1548
* the native click first, then add its safe, non-text metadata to the rrweb
@@ -69,7 +102,7 @@ export class RrwebEngine implements RecordingEngine {
69102
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
70103
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
71104

72-
const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
105+
const plugins: RrwebPlugin[] = [clickModifiersPlugin(), clipboardActionsPlugin()];
73106
const { captureConsoleLogs } = config;
74107
if (captureConsoleLogs) {
75108
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;

0 commit comments

Comments
 (0)