diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index fffb4ce8b8..a7b513c2a0 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -430,6 +430,17 @@ function record( }, }), ), + selectionCb: (p) => { + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Selection, + ...p, + }, + }), + ); + }, blockClass, ignoreClass, maskTextClass, diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index f745a413b2..9350906cec 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -35,6 +35,8 @@ import { styleDeclarationCallback, IWindow, MutationBufferParam, + SelectionRange, + selectionCallback, } from '../types'; import MutationBuffer from './mutation'; @@ -752,6 +754,47 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { }; } +function initSelectionObserver(param: observerParam): listenerHandler { + const { doc, mirror, blockClass, selectionCb } = param; + let collapsed = true; + + const updateSelection = () => { + const selection = doc.getSelection(); + + if (!selection || (collapsed && selection?.isCollapsed)) return; + + collapsed = selection.isCollapsed || false; + + const ranges: SelectionRange[] = []; + const count = selection.rangeCount || 0; + + for (let i = 0; i < count; i++) { + const range = selection.getRangeAt(i); + + const { startContainer, startOffset, endContainer, endOffset } = range; + + const blocked = + isBlocked(startContainer, blockClass, true) || + isBlocked(endContainer, blockClass, true); + + if (blocked) continue; + + ranges.push({ + start: mirror.getId(startContainer), + startOffset, + end: mirror.getId(endContainer), + endOffset, + }); + } + + selectionCb({ ranges }); + }; + + updateSelection(); + + return on('selectionchange', updateSelection); +} + function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -765,6 +808,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { styleDeclarationCb, canvasMutationCb, fontCb, + selectionCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -832,6 +876,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } fontCb(...p); }; + o.selectionCb = (...p: Arguments) => { + if (hooks.selection) { + hooks.selection(...p); + } + selectionCb(...p); + }; } export function initObservers( @@ -863,6 +913,8 @@ export function initObservers( : () => { // }; + const selectionObserver = initSelectionObserver(o); + // plugins const pluginHandlers: listenerHandler[] = []; for (const plugin of o.plugins) { @@ -883,6 +935,7 @@ export function initObservers( styleSheetObserver(); styleDeclarationObserver(); fontObserver(); + selectionObserver(); pluginHandlers.forEach((h) => h()); }; } diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 70efba2b9a..df76ca9eeb 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1320,6 +1320,35 @@ export class Replayer { } break; } + case IncrementalSource.Selection: { + const selectionSet = new Set(); + const ranges = d.ranges.map( + ({ start, startOffset, end, endOffset }) => { + const startContainer = this.mirror.getNode(start); + const endContainer = this.mirror.getNode(end); + + if (!startContainer || !endContainer) return; + + const result = new Range(); + + result.setStart(startContainer, startOffset); + result.setEnd(endContainer, endOffset); + const doc = startContainer.ownerDocument; + const selection = doc?.getSelection(); + selection && selectionSet.add(selection); + + return { + range: result, + selection, + }; + }, + ); + + selectionSet.forEach((s) => s.removeAllRanges()); + + ranges.forEach((r) => r && r.selection?.addRange(r.range)); + break; + } default: } } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 2442fb2d35..79ae529651 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -91,6 +91,7 @@ export enum IncrementalSource { Log, Drag, StyleDeclaration, + Selection, } export type mutationData = { @@ -142,6 +143,10 @@ export type fontData = { source: IncrementalSource.Font; } & fontParam; +export type selectionData = { + source: IncrementalSource.Selection; +} & selectionParam; + export type incrementalData = | mutationData | mousemoveData @@ -153,6 +158,7 @@ export type incrementalData = | styleSheetRuleData | canvasMutationData | fontData + | selectionData | styleDeclarationData; export type event = @@ -261,6 +267,7 @@ export type observerParam = { viewportResizeCb: viewportResizeCallback; inputCb: inputCallback; mediaInteractionCb: mediaInteractionCallback; + selectionCb: selectionCallback; blockClass: blockClass; blockSelector: string | null; ignoreClass: string; @@ -331,6 +338,7 @@ export type hooksParam = { styleDeclaration?: styleDeclarationCallback; canvasMutation?: canvasMutationCallback; font?: fontCallback; + selection?: selectionCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -619,6 +627,19 @@ export type DocumentDimension = { absoluteScale: number; }; +export type SelectionRange = { + start: number; + startOffset: number; + end: number; + endOffset: number; +}; + +export type selectionParam = { + ranges: Array; +}; + +export type selectionCallback = (p: selectionParam) => void; + export type DeprecatedMirror = { map: { [key: number]: INode; diff --git a/packages/rrweb/test/events/selection.ts b/packages/rrweb/test/events/selection.ts new file mode 100644 index 0000000000..152350b0d4 --- /dev/null +++ b/packages/rrweb/test/events/selection.ts @@ -0,0 +1,179 @@ +import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; + +const now = Date.now(); + +const events: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 100, + }, + { + type: EventType.Meta, + data: { + href: 'about:blank', + width: 1920, + height: 1080, + }, + timestamp: now + 200, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\\\\n ', + id: 5, + }, + { + type: 2, + tagName: 'meta', + attributes: { + charset: 'UTF-8', + }, + childNodes: [], + id: 6, + }, + { + type: 3, + textContent: '\\\\n ', + id: 7, + }, + ], + id: 4, + }, + { + type: 3, + textContent: '\\\\n ', + id: 8, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: '\\\\n Lorem, ipsum\\\\n ', + id: 10, + }, + { + type: 2, + tagName: 'span', + attributes: { + id: 'startNode', + }, + childNodes: [ + { + type: 3, + textContent: + '\\\\n Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolores culpa\\\\n corporis voluptas odit nobis recusandae inventore, magni praesentium\\\\n maiores perferendis quaerat excepturi officia minus velit voluptate\\\\n placeat minima? Nesciunt, eum!\\\\n ', + id: 12, + }, + ], + id: 11, + }, + { + type: 3, + textContent: + '\\\\n dolor sit amet consectetur adipisicing elit. Ad repellendus quas hic\\\\n deleniti, delectus consequatur voluptas aliquam dolore voluptates repellat\\\\n perferendis aperiam saepe maxime officia rem corporis beatae, assumenda\\\\n doloribus.\\\\n ', + id: 13, + }, + { + type: 2, + tagName: 'span', + attributes: { + id: 'endNode', + }, + childNodes: [ + { + type: 3, + textContent: + '\\\\n Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae\\\\n explicabo omnis dolores magni, ea doloribus possimus debitis reiciendis\\\\n distinctio perferendis nihil ipsum officiis pariatur laboriosam quas,\\\\n corrupti vero vitae minus.\\\\n ', + id: 15, + }, + ], + id: 14, + }, + { + type: 3, + textContent: '\\\\n \\\\n ', + id: 16, + }, + { + type: 2, + tagName: 'script', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'SCRIPT_PLACEHOLDER', + id: 18, + }, + ], + id: 17, + }, + { + type: 3, + textContent: '\\\\n \\\\n \\\\n\\\\n', + id: 19, + }, + ], + id: 9, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: now + 300, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Selection, + ranges: [ + { + start: 12, + startOffset: 11, + end: 15, + endOffset: 6, + }, + ], + }, + timestamp: now + 400, + }, +]; + +export default events; diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 7fd98d92f5..f9b46aea02 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -8,6 +8,7 @@ import { EventType, IncrementalSource, styleSheetRuleData, + selectionData, } from '../src/types'; import { assertSnapshot, launchPuppeteer, waitForRAF } from './utils'; @@ -211,6 +212,47 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + it('should record selection event', async () => { + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + record({ + emit: ((window as unknown) as IWindow).emit, + }); + const startNode = document.createElement('p'); + + startNode.innerText = + 'Lorem ipsum dolor sit amet consectetur adipisicing elit.'; + + const endNode = document.createElement('span'); + endNode.innerText = + 'nihil ipsum officiis pariatur laboriosam quas,corrupti vero vitae minus.'; + + document.body.appendChild(startNode); + document.body.appendChild(endNode); + + const selection = window.getSelection(); + const range = new Range(); + + range.setStart(startNode!.firstChild!, 10); + range.setEnd(endNode!.firstChild!, 2); + + selection?.addRange(range); + }); + await waitForRAF(ctx.page); + const selectionData = ctx.events + .filter(({ type, data }) => { + return ( + type === EventType.IncrementalSnapshot && + data.source === IncrementalSource.Selection + ); + }) + .map((ev) => ev.data as selectionData); + + expect(selectionData.length).toEqual(1); + expect(selectionData[0].ranges[0].startOffset).toEqual(10); + expect(selectionData[0].ranges[0].endOffset).toEqual(2); + }); + it('can add custom event', async () => { await ctx.page.evaluate(() => { const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 01927e4dd1..c0c8a07827 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -13,6 +13,7 @@ import orderingEvents from './events/ordering'; import scrollEvents from './events/scroll'; import inputEvents from './events/input'; import iframeEvents from './events/iframe'; +import selectionEvents from './events/selection'; import shadowDomEvents from './events/shadow-dom'; import StyleSheetTextMutation from './events/style-sheet-text-mutation'; import canvasInIframe from './events/canvas-in-iframe'; @@ -202,6 +203,21 @@ describe('replayer', function () { ); }); + it('can restore selection', async () => { + await page.evaluate(`events = ${JSON.stringify(selectionEvents)}`); + const [startOffset, endOffset] = (await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(1500); + const range = replayer.iframe.contentDocument.getSelection().getRangeAt(0); + + [range.startOffset, range.endOffset]; + `)) as [startOffset: number, endOffset: number]; + + expect(startOffset).toEqual(11); + expect(endOffset).toEqual(6); + }); + it('can fast forward past StyleSheetRule deletion on virtual elements', async () => { await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);