rrweb: add selection observer (#936)

* rrweb: add selection observer

* Update packages/rrweb/src/record/observer.ts

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>

* Update packages/rrweb/src/record/observer.ts

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>

* Update packages/rrweb/src/replay/index.ts

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>

* remove: repeat updateSelection

* Update packages/rrweb/src/record/observer.ts

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>

* remove: utils sample events

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>
This commit is contained in:
Jinxing Lin
2026-04-01 12:00:00 +08:00
committed by GitHub
parent b129f290a8
commit c405e31e01
7 changed files with 351 additions and 0 deletions

View File

@@ -430,6 +430,17 @@ function record<T = eventWithTime>(
},
}),
),
selectionCb: (p) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Selection,
...p,
},
}),
);
},
blockClass,
ignoreClass,
maskTextClass,

View File

@@ -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<mutationCallBack>) => {
if (hooks.mutation) {
@@ -832,6 +876,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
}
fontCb(...p);
};
o.selectionCb = (...p: Arguments<selectionCallback>) => {
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());
};
}

View File

@@ -1320,6 +1320,35 @@ export class Replayer {
}
break;
}
case IncrementalSource.Selection: {
const selectionSet = new Set<Selection>();
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:
}
}

View File

@@ -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<SelectionRange>;
};
export type selectionCallback = (p: selectionParam) => void;
export type DeprecatedMirror = {
map: {
[key: number]: INode;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)}`);