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:
@@ -430,6 +430,17 @@ function record<T = eventWithTime>(
|
||||
},
|
||||
}),
|
||||
),
|
||||
selectionCb: (p) => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Selection,
|
||||
...p,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
blockClass,
|
||||
ignoreClass,
|
||||
maskTextClass,
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
179
packages/rrweb/test/events/selection.ts
Normal file
179
packages/rrweb/test/events/selection.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user