1278 lines
39 KiB
TypeScript
1278 lines
39 KiB
TypeScript
import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot';
|
|
import * as mittProxy from 'mitt';
|
|
import { polyfill as smoothscrollPolyfill } from './smoothscroll';
|
|
import { Timer } from './timer';
|
|
import { createPlayerService, createSpeedService } from './machine';
|
|
import {
|
|
EventType,
|
|
IncrementalSource,
|
|
fullSnapshotEvent,
|
|
eventWithTime,
|
|
MouseInteractions,
|
|
playerConfig,
|
|
playerMetaData,
|
|
viewportResizeDimention,
|
|
missingNodeMap,
|
|
addedNodeMutation,
|
|
missingNode,
|
|
incrementalSnapshotEvent,
|
|
incrementalData,
|
|
ReplayerEvents,
|
|
Handler,
|
|
Emitter,
|
|
MediaInteractions,
|
|
metaEvent,
|
|
mutationData,
|
|
scrollData,
|
|
inputData,
|
|
canvasMutationData,
|
|
} from '../types';
|
|
import {
|
|
mirror,
|
|
polyfill,
|
|
TreeIndex,
|
|
queueToResolveTrees,
|
|
iterateResolveTree,
|
|
} from '../utils';
|
|
import getInjectStyleRules from './styles/inject-style';
|
|
import './styles/style.css';
|
|
|
|
const SKIP_TIME_THRESHOLD = 10 * 1000;
|
|
const SKIP_TIME_INTERVAL = 5 * 1000;
|
|
|
|
// https://github.com/rollup/rollup/issues/1267#issuecomment-296395734
|
|
// tslint:disable-next-line
|
|
const mitt = (mittProxy as any).default || mittProxy;
|
|
|
|
const REPLAY_CONSOLE_PREFIX = '[replayer]';
|
|
|
|
const defaultMouseTailConfig = {
|
|
duration: 500,
|
|
lineCap: 'round',
|
|
lineWidth: 3,
|
|
strokeStyle: 'red',
|
|
} as const;
|
|
|
|
export class Replayer {
|
|
public wrapper: HTMLDivElement;
|
|
public iframe: HTMLIFrameElement;
|
|
|
|
public service: ReturnType<typeof createPlayerService>;
|
|
public speedService: ReturnType<typeof createSpeedService>;
|
|
public get timer() {
|
|
return this.service.state.context.timer;
|
|
}
|
|
|
|
public config: playerConfig;
|
|
|
|
private mouse: HTMLDivElement;
|
|
private mouseTail: HTMLCanvasElement | null = null;
|
|
private tailPositions: Array<{ x: number; y: number }> = [];
|
|
|
|
private emitter: Emitter = mitt();
|
|
|
|
private nextUserInteractionEvent: eventWithTime | null;
|
|
|
|
// tslint:disable-next-line: variable-name
|
|
private legacy_missingNodeRetryMap: missingNodeMap = {};
|
|
|
|
private treeIndex!: TreeIndex;
|
|
private fragmentParentMap!: Map<INode, INode>;
|
|
|
|
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
|
|
|
|
constructor(
|
|
events: Array<eventWithTime | string>,
|
|
config?: Partial<playerConfig>,
|
|
) {
|
|
if (!config?.liveMode && events.length < 2) {
|
|
throw new Error('Replayer need at least 2 events.');
|
|
}
|
|
const defaultConfig: playerConfig = {
|
|
speed: 1,
|
|
root: document.body,
|
|
loadTimeout: 0,
|
|
skipInactive: false,
|
|
showWarning: true,
|
|
showDebug: false,
|
|
blockClass: 'rr-block',
|
|
liveMode: false,
|
|
insertStyleRules: [],
|
|
triggerFocus: true,
|
|
UNSAFE_replayCanvas: false,
|
|
mouseTail: defaultMouseTailConfig,
|
|
};
|
|
this.config = Object.assign({}, defaultConfig, config);
|
|
|
|
this.handleResize = this.handleResize.bind(this);
|
|
this.getCastFn = this.getCastFn.bind(this);
|
|
this.emitter.on(ReplayerEvents.Resize, this.handleResize as Handler);
|
|
|
|
this.setupDom();
|
|
|
|
this.treeIndex = new TreeIndex();
|
|
this.fragmentParentMap = new Map<INode, INode>();
|
|
this.emitter.on(ReplayerEvents.Flush, () => {
|
|
const { scrollMap, inputMap } = this.treeIndex.flush();
|
|
|
|
for (const [frag, parent] of this.fragmentParentMap.entries()) {
|
|
mirror.map[parent.__sn.id] = parent;
|
|
/**
|
|
* If we have already set value attribute on textarea,
|
|
* then we could not apply text content as default value any more.
|
|
*/
|
|
if (
|
|
parent.__sn.type === NodeType.Element &&
|
|
parent.__sn.tagName === 'textarea' &&
|
|
frag.textContent
|
|
) {
|
|
((parent as unknown) as HTMLTextAreaElement).value = frag.textContent;
|
|
}
|
|
parent.appendChild(frag);
|
|
}
|
|
this.fragmentParentMap.clear();
|
|
|
|
for (const d of scrollMap.values()) {
|
|
this.applyScroll(d);
|
|
}
|
|
for (const d of inputMap.values()) {
|
|
this.applyInput(d);
|
|
}
|
|
});
|
|
|
|
const timer = new Timer([], config?.speed || defaultConfig.speed);
|
|
this.service = createPlayerService(
|
|
{
|
|
events: events.map((e) => {
|
|
if (config && config.unpackFn) {
|
|
return config.unpackFn(e as string);
|
|
}
|
|
return e as eventWithTime;
|
|
}),
|
|
timer,
|
|
timeOffset: 0,
|
|
baselineTime: 0,
|
|
lastPlayedEvent: null,
|
|
},
|
|
{
|
|
getCastFn: this.getCastFn,
|
|
emitter: this.emitter,
|
|
},
|
|
);
|
|
this.service.start();
|
|
this.service.subscribe((state) => {
|
|
this.emitter.emit(ReplayerEvents.StateChange, {
|
|
player: state,
|
|
});
|
|
});
|
|
this.speedService = createSpeedService({
|
|
normalSpeed: -1,
|
|
timer,
|
|
});
|
|
this.speedService.start();
|
|
this.speedService.subscribe((state) => {
|
|
this.emitter.emit(ReplayerEvents.StateChange, {
|
|
speed: state,
|
|
});
|
|
});
|
|
|
|
// rebuild first full snapshot as the poster of the player
|
|
// maybe we can cache it for performance optimization
|
|
const firstMeta = this.service.state.context.events.find(
|
|
(e) => e.type === EventType.Meta,
|
|
);
|
|
const firstFullsnapshot = this.service.state.context.events.find(
|
|
(e) => e.type === EventType.FullSnapshot,
|
|
);
|
|
if (firstMeta) {
|
|
const { width, height } = firstMeta.data as metaEvent['data'];
|
|
setTimeout(() => {
|
|
this.emitter.emit(ReplayerEvents.Resize, {
|
|
width,
|
|
height,
|
|
});
|
|
}, 0);
|
|
}
|
|
if (firstFullsnapshot) {
|
|
setTimeout(() => {
|
|
this.rebuildFullSnapshot(
|
|
firstFullsnapshot as fullSnapshotEvent & { timestamp: number },
|
|
);
|
|
}, 1);
|
|
}
|
|
}
|
|
|
|
public on(event: string, handler: Handler) {
|
|
this.emitter.on(event, handler);
|
|
return this;
|
|
}
|
|
|
|
public setConfig(config: Partial<playerConfig>) {
|
|
Object.keys(config).forEach((key) => {
|
|
// @ts-ignore
|
|
this.config[key] = config[key];
|
|
});
|
|
if (!this.config.skipInactive) {
|
|
this.backToNormal();
|
|
}
|
|
if (typeof config.speed !== 'undefined') {
|
|
this.speedService.send({
|
|
type: 'SET_SPEED',
|
|
payload: {
|
|
speed: config.speed!,
|
|
},
|
|
});
|
|
}
|
|
if (typeof config.mouseTail !== 'undefined') {
|
|
if (config.mouseTail === false) {
|
|
this.mouseTail && (this.mouseTail.style.display = 'none');
|
|
} else {
|
|
if (!this.mouseTail) {
|
|
this.mouseTail = document.createElement('canvas');
|
|
this.mouseTail.width = Number.parseFloat(this.iframe.width);
|
|
this.mouseTail.height = Number.parseFloat(this.iframe.height);
|
|
this.mouseTail.classList.add('replayer-mouse-tail');
|
|
this.wrapper.insertBefore(this.mouseTail, this.iframe);
|
|
}
|
|
this.mouseTail.style.display = 'inherit';
|
|
}
|
|
}
|
|
}
|
|
|
|
public getMetaData(): playerMetaData {
|
|
const firstEvent = this.service.state.context.events[0];
|
|
const lastEvent = this.service.state.context.events[
|
|
this.service.state.context.events.length - 1
|
|
];
|
|
return {
|
|
startTime: firstEvent.timestamp,
|
|
endTime: lastEvent.timestamp,
|
|
totalTime: lastEvent.timestamp - firstEvent.timestamp,
|
|
};
|
|
}
|
|
|
|
public getCurrentTime(): number {
|
|
return this.timer.timeOffset + this.getTimeOffset();
|
|
}
|
|
|
|
public getTimeOffset(): number {
|
|
const { baselineTime, events } = this.service.state.context;
|
|
return baselineTime - events[0].timestamp;
|
|
}
|
|
|
|
/**
|
|
* This API was designed to be used as play at any time offset.
|
|
* Since we minimized the data collected from recorder, we do not
|
|
* have the ability of undo an event.
|
|
* So the implementation of play at any time offset will always iterate
|
|
* all of the events, cast event before the offset synchronously
|
|
* and cast event after the offset asynchronously with timer.
|
|
* @param timeOffset number
|
|
*/
|
|
public play(timeOffset = 0) {
|
|
if (this.service.state.matches('paused')) {
|
|
this.service.send({ type: 'PLAY', payload: { timeOffset } });
|
|
} else {
|
|
this.service.send({ type: 'PAUSE' });
|
|
this.service.send({ type: 'PLAY', payload: { timeOffset } });
|
|
}
|
|
this.iframe.contentDocument.getElementsByTagName('html')[0].classList.remove('rrweb-paused');
|
|
this.emitter.emit(ReplayerEvents.Start);
|
|
}
|
|
|
|
public pause(timeOffset?: number) {
|
|
if (timeOffset === undefined && this.service.state.matches('playing')) {
|
|
this.service.send({ type: 'PAUSE' });
|
|
}
|
|
if (typeof timeOffset === 'number') {
|
|
this.play(timeOffset);
|
|
this.service.send({ type: 'PAUSE' });
|
|
}
|
|
this.iframe.contentDocument.getElementsByTagName('html')[0].classList.add('rrweb-paused');
|
|
this.emitter.emit(ReplayerEvents.Pause);
|
|
}
|
|
|
|
public resume(timeOffset = 0) {
|
|
console.warn(
|
|
`The 'resume' will be departed in 1.0. Please use 'play' method which has the same interface.`,
|
|
);
|
|
this.play(timeOffset);
|
|
this.emitter.emit(ReplayerEvents.Resume);
|
|
}
|
|
|
|
public startLive(baselineTime?: number) {
|
|
this.service.send({ type: 'TO_LIVE', payload: { baselineTime } });
|
|
}
|
|
|
|
public addEvent(rawEvent: eventWithTime | string) {
|
|
const event = this.config.unpackFn
|
|
? this.config.unpackFn(rawEvent as string)
|
|
: (rawEvent as eventWithTime);
|
|
Promise.resolve().then(() =>
|
|
this.service.send({ type: 'ADD_EVENT', payload: { event } }),
|
|
);
|
|
}
|
|
|
|
public enableInteract() {
|
|
this.iframe.setAttribute('scrolling', 'auto');
|
|
this.iframe.style.pointerEvents = 'auto';
|
|
}
|
|
|
|
public disableInteract() {
|
|
this.iframe.setAttribute('scrolling', 'no');
|
|
this.iframe.style.pointerEvents = 'none';
|
|
}
|
|
|
|
private setupDom() {
|
|
this.wrapper = document.createElement('div');
|
|
this.wrapper.classList.add('replayer-wrapper');
|
|
this.config.root!.appendChild(this.wrapper);
|
|
|
|
this.mouse = document.createElement('div');
|
|
this.mouse.classList.add('replayer-mouse');
|
|
this.wrapper.appendChild(this.mouse);
|
|
|
|
if (this.config.mouseTail !== false) {
|
|
this.mouseTail = document.createElement('canvas');
|
|
this.mouseTail.classList.add('replayer-mouse-tail');
|
|
this.mouseTail.style.display = 'inherit';
|
|
this.wrapper.appendChild(this.mouseTail);
|
|
}
|
|
|
|
this.iframe = document.createElement('iframe');
|
|
const attributes = ['allow-same-origin'];
|
|
if (this.config.UNSAFE_replayCanvas) {
|
|
attributes.push('allow-scripts');
|
|
}
|
|
// hide iframe before first meta event
|
|
this.iframe.style.display = 'none';
|
|
this.iframe.setAttribute('sandbox', attributes.join(' '));
|
|
this.disableInteract();
|
|
this.wrapper.appendChild(this.iframe);
|
|
if (this.iframe.contentWindow && this.iframe.contentDocument) {
|
|
smoothscrollPolyfill(
|
|
this.iframe.contentWindow,
|
|
this.iframe.contentDocument,
|
|
);
|
|
|
|
polyfill(this.iframe.contentWindow as Window & typeof globalThis);
|
|
}
|
|
}
|
|
|
|
private handleResize(dimension: viewportResizeDimention) {
|
|
this.iframe.style.display = 'inherit';
|
|
for (const el of [this.mouseTail, this.iframe]) {
|
|
if (!el) {
|
|
continue;
|
|
}
|
|
el.setAttribute('width', String(dimension.width));
|
|
el.setAttribute('height', String(dimension.height));
|
|
}
|
|
}
|
|
|
|
private getCastFn(event: eventWithTime, isSync = false) {
|
|
let castFn: undefined | (() => void);
|
|
switch (event.type) {
|
|
case EventType.DomContentLoaded:
|
|
case EventType.Load:
|
|
break;
|
|
case EventType.Custom:
|
|
castFn = () => {
|
|
/**
|
|
* emit custom-event and pass the event object.
|
|
*
|
|
* This will add more value to the custom event and allows the client to react for custom-event.
|
|
*/
|
|
this.emitter.emit(ReplayerEvents.CustomEvent, event);
|
|
};
|
|
break;
|
|
case EventType.Meta:
|
|
castFn = () =>
|
|
this.emitter.emit(ReplayerEvents.Resize, {
|
|
width: event.data.width,
|
|
height: event.data.height,
|
|
});
|
|
break;
|
|
case EventType.FullSnapshot:
|
|
castFn = () => {
|
|
this.rebuildFullSnapshot(event, isSync);
|
|
this.iframe.contentWindow!.scrollTo(event.data.initialOffset);
|
|
};
|
|
break;
|
|
case EventType.IncrementalSnapshot:
|
|
castFn = () => {
|
|
this.applyIncremental(event, isSync);
|
|
if (isSync) {
|
|
// do not check skip in sync
|
|
return;
|
|
}
|
|
if (event === this.nextUserInteractionEvent) {
|
|
this.nextUserInteractionEvent = null;
|
|
this.backToNormal();
|
|
}
|
|
if (this.config.skipInactive && !this.nextUserInteractionEvent) {
|
|
for (const _event of this.service.state.context.events) {
|
|
if (_event.timestamp! <= event.timestamp!) {
|
|
continue;
|
|
}
|
|
if (this.isUserInteraction(_event)) {
|
|
if (
|
|
_event.delay! - event.delay! >
|
|
SKIP_TIME_THRESHOLD *
|
|
this.speedService.state.context.timer.speed
|
|
) {
|
|
this.nextUserInteractionEvent = _event;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (this.nextUserInteractionEvent) {
|
|
const skipTime =
|
|
this.nextUserInteractionEvent.delay! - event.delay!;
|
|
const payload = {
|
|
speed: Math.min(Math.round(skipTime / SKIP_TIME_INTERVAL), 360),
|
|
};
|
|
this.speedService.send({ type: 'FAST_FORWARD', payload });
|
|
this.emitter.emit(ReplayerEvents.SkipStart, payload);
|
|
}
|
|
}
|
|
};
|
|
break;
|
|
default:
|
|
}
|
|
const wrappedCastFn = () => {
|
|
if (castFn) {
|
|
castFn();
|
|
}
|
|
this.service.send({ type: 'CAST_EVENT', payload: { event } });
|
|
if (
|
|
event ===
|
|
this.service.state.context.events[
|
|
this.service.state.context.events.length - 1
|
|
]
|
|
) {
|
|
const finish = () => {
|
|
this.backToNormal();
|
|
this.service.send('END');
|
|
this.emitter.emit(ReplayerEvents.Finish);
|
|
};
|
|
if (
|
|
event.type === EventType.IncrementalSnapshot &&
|
|
event.data.source === IncrementalSource.MouseMove &&
|
|
event.data.positions.length
|
|
) {
|
|
// defer finish event if the last event is a mouse move
|
|
setTimeout(() => {
|
|
finish();
|
|
}, Math.max(0, -event.data.positions[0].timeOffset));
|
|
} else {
|
|
finish();
|
|
}
|
|
}
|
|
};
|
|
return wrappedCastFn;
|
|
}
|
|
|
|
private rebuildFullSnapshot(
|
|
event: fullSnapshotEvent & { timestamp: number },
|
|
isSync: boolean = false,
|
|
) {
|
|
if (!this.iframe.contentDocument) {
|
|
return console.warn('Looks like your replayer has been destroyed.');
|
|
}
|
|
if (Object.keys(this.legacy_missingNodeRetryMap).length) {
|
|
console.warn(
|
|
'Found unresolved missing node map',
|
|
this.legacy_missingNodeRetryMap,
|
|
);
|
|
}
|
|
this.legacy_missingNodeRetryMap = {};
|
|
mirror.map = rebuild(event.data.node, this.iframe.contentDocument)[1];
|
|
const styleEl = document.createElement('style');
|
|
const { documentElement, head } = this.iframe.contentDocument;
|
|
documentElement!.insertBefore(styleEl, head);
|
|
const injectStylesRules = getInjectStyleRules(
|
|
this.config.blockClass,
|
|
).concat(this.config.insertStyleRules);
|
|
injectStylesRules.push('html.rrweb-paused * { animation-play-state: paused !important; }');
|
|
if (!this.service.state.matches('playing')) {
|
|
this.iframe.contentDocument.getElementsByTagName('html')[0].classList.add('rrweb-paused');
|
|
}
|
|
for (let idx = 0; idx < injectStylesRules.length; idx++) {
|
|
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStylesRules[idx], idx);
|
|
}
|
|
this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event);
|
|
if (!isSync) {
|
|
this.waitForStylesheetLoad();
|
|
}
|
|
if (this.config.UNSAFE_replayCanvas) {
|
|
this.preloadAllImages();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pause when loading style sheet, resume when loaded all timeout exceed
|
|
*/
|
|
private waitForStylesheetLoad() {
|
|
const head = this.iframe.contentDocument?.head;
|
|
if (head) {
|
|
const unloadSheets: Set<HTMLLinkElement> = new Set();
|
|
let timer: number;
|
|
let beforeLoadState = this.service.state;
|
|
const stateHandler = () => {
|
|
beforeLoadState = this.service.state;
|
|
};
|
|
this.emitter.on(ReplayerEvents.Start, stateHandler);
|
|
this.emitter.on(ReplayerEvents.Pause, stateHandler);
|
|
const unsubscribe = () => {
|
|
this.emitter.off(ReplayerEvents.Start, stateHandler);
|
|
this.emitter.off(ReplayerEvents.Pause, stateHandler);
|
|
};
|
|
head
|
|
.querySelectorAll('link[rel="stylesheet"]')
|
|
.forEach((css: HTMLLinkElement) => {
|
|
if (!css.sheet) {
|
|
unloadSheets.add(css);
|
|
css.addEventListener('load', () => {
|
|
unloadSheets.delete(css);
|
|
// all loaded and timer not released yet
|
|
if (unloadSheets.size === 0 && timer !== -1) {
|
|
if (beforeLoadState.matches('playing')) {
|
|
this.play(this.getCurrentTime());
|
|
}
|
|
this.emitter.emit(ReplayerEvents.LoadStylesheetEnd);
|
|
if (timer) {
|
|
window.clearTimeout(timer);
|
|
}
|
|
unsubscribe();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (unloadSheets.size > 0) {
|
|
// find some unload sheets after iterate
|
|
this.service.send({ type: 'PAUSE' });
|
|
this.emitter.emit(ReplayerEvents.LoadStylesheetStart);
|
|
timer = window.setTimeout(() => {
|
|
if (beforeLoadState.matches('playing')) {
|
|
this.play(this.getCurrentTime());
|
|
}
|
|
// mark timer was called
|
|
timer = -1;
|
|
unsubscribe();
|
|
}, this.config.loadTimeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pause when there are some canvas drawImage args need to be loaded
|
|
*/
|
|
private preloadAllImages() {
|
|
let beforeLoadState = this.service.state;
|
|
const stateHandler = () => {
|
|
beforeLoadState = this.service.state;
|
|
};
|
|
this.emitter.on(ReplayerEvents.Start, stateHandler);
|
|
this.emitter.on(ReplayerEvents.Pause, stateHandler);
|
|
const unsubscribe = () => {
|
|
this.emitter.off(ReplayerEvents.Start, stateHandler);
|
|
this.emitter.off(ReplayerEvents.Pause, stateHandler);
|
|
};
|
|
let count = 0;
|
|
let resolved = 0;
|
|
for (const event of this.service.state.context.events) {
|
|
if (
|
|
event.type === EventType.IncrementalSnapshot &&
|
|
event.data.source === IncrementalSource.CanvasMutation &&
|
|
event.data.property === 'drawImage' &&
|
|
typeof event.data.args[0] === 'string' &&
|
|
!this.imageMap.has(event)
|
|
) {
|
|
count++;
|
|
const image = document.createElement('img');
|
|
image.src = event.data.args[0];
|
|
this.imageMap.set(event, image);
|
|
image.onload = () => {
|
|
resolved++;
|
|
if (resolved === count) {
|
|
if (beforeLoadState.matches('playing')) {
|
|
this.play(this.getCurrentTime());
|
|
}
|
|
unsubscribe();
|
|
}
|
|
};
|
|
}
|
|
}
|
|
if (count !== resolved) {
|
|
this.service.send({ type: 'PAUSE' });
|
|
}
|
|
}
|
|
|
|
private applyIncremental(
|
|
e: incrementalSnapshotEvent & { timestamp: number; delay?: number },
|
|
isSync: boolean,
|
|
) {
|
|
const { data: d } = e;
|
|
switch (d.source) {
|
|
case IncrementalSource.Mutation: {
|
|
if (isSync) {
|
|
d.adds.forEach((m) => this.treeIndex.add(m));
|
|
d.texts.forEach((m) => this.treeIndex.text(m));
|
|
d.attributes.forEach((m) => this.treeIndex.attribute(m));
|
|
d.removes.forEach((m) => this.treeIndex.remove(m));
|
|
}
|
|
this.applyMutation(d, isSync);
|
|
break;
|
|
}
|
|
case IncrementalSource.MouseMove:
|
|
if (isSync) {
|
|
const lastPosition = d.positions[d.positions.length - 1];
|
|
this.moveAndHover(d, lastPosition.x, lastPosition.y, lastPosition.id);
|
|
} else {
|
|
d.positions.forEach((p) => {
|
|
const action = {
|
|
doAction: () => {
|
|
this.moveAndHover(d, p.x, p.y, p.id);
|
|
},
|
|
delay:
|
|
p.timeOffset +
|
|
e.timestamp -
|
|
this.service.state.context.baselineTime,
|
|
};
|
|
this.timer.addAction(action);
|
|
});
|
|
// add a dummy action to keep timer alive
|
|
this.timer.addAction({
|
|
doAction() {},
|
|
delay: e.delay! - d.positions[0]?.timeOffset,
|
|
});
|
|
}
|
|
break;
|
|
case IncrementalSource.MouseInteraction: {
|
|
/**
|
|
* Same as the situation of missing input target.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
const event = new Event(MouseInteractions[d.type].toLowerCase());
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
this.emitter.emit(ReplayerEvents.MouseInteraction, {
|
|
type: d.type,
|
|
target,
|
|
});
|
|
const { triggerFocus } = this.config;
|
|
switch (d.type) {
|
|
case MouseInteractions.Blur:
|
|
if ('blur' in ((target as Node) as HTMLElement)) {
|
|
((target as Node) as HTMLElement).blur();
|
|
}
|
|
break;
|
|
case MouseInteractions.Focus:
|
|
if (triggerFocus && ((target as Node) as HTMLElement).focus) {
|
|
((target as Node) as HTMLElement).focus({
|
|
preventScroll: true,
|
|
});
|
|
}
|
|
break;
|
|
case MouseInteractions.Click:
|
|
case MouseInteractions.TouchStart:
|
|
case MouseInteractions.TouchEnd:
|
|
/**
|
|
* Click has no visual impact when replaying and may
|
|
* trigger navigation when apply to an <a> link.
|
|
* So we will not call click(), instead we add an
|
|
* animation to the mouse element which indicate user
|
|
* clicked at this moment.
|
|
*/
|
|
if (!isSync) {
|
|
this.moveAndHover(d, d.x, d.y, d.id);
|
|
this.mouse.classList.remove('active');
|
|
// tslint:disable-next-line
|
|
void this.mouse.offsetWidth;
|
|
this.mouse.classList.add('active');
|
|
}
|
|
break;
|
|
default:
|
|
target.dispatchEvent(event);
|
|
}
|
|
break;
|
|
}
|
|
case IncrementalSource.Scroll: {
|
|
/**
|
|
* Same as the situation of missing input target.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
if (isSync) {
|
|
this.treeIndex.scroll(d);
|
|
break;
|
|
}
|
|
this.applyScroll(d);
|
|
break;
|
|
}
|
|
case IncrementalSource.ViewportResize:
|
|
this.emitter.emit(ReplayerEvents.Resize, {
|
|
width: d.width,
|
|
height: d.height,
|
|
});
|
|
break;
|
|
case IncrementalSource.Input: {
|
|
/**
|
|
* Input event on an unserialized node usually means the event
|
|
* was synchrony triggered programmatically after the node was
|
|
* created. This means there was not an user observable interaction
|
|
* and we do not need to replay it.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
if (isSync) {
|
|
this.treeIndex.input(d);
|
|
break;
|
|
}
|
|
this.applyInput(d);
|
|
break;
|
|
}
|
|
case IncrementalSource.MediaInteraction: {
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
const mediaEl = (target as Node) as HTMLMediaElement;
|
|
try {
|
|
if (d.type === MediaInteractions.Pause) {
|
|
mediaEl.pause();
|
|
}
|
|
if (d.type === MediaInteractions.Play) {
|
|
if (mediaEl.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
mediaEl.play();
|
|
} else {
|
|
mediaEl.addEventListener('canplay', () => {
|
|
mediaEl.play();
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (this.config.showWarning) {
|
|
console.warn(
|
|
`Failed to replay media interactions: ${error.message || error}`,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case IncrementalSource.StyleSheetRule: {
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
|
|
const styleEl = (target as Node) as HTMLStyleElement;
|
|
const parent = (target.parentNode as unknown) as INode;
|
|
const usingVirtualParent = this.fragmentParentMap.has(parent);
|
|
let placeholderNode;
|
|
|
|
if (usingVirtualParent) {
|
|
/**
|
|
* styleEl.sheet is only accessible if the styleEl is part of the
|
|
* dom. This doesn't work on DocumentFragments so we have to re-add
|
|
* it to the dom temporarily.
|
|
*/
|
|
const domParent = this.fragmentParentMap.get(
|
|
(target.parentNode as unknown) as INode,
|
|
);
|
|
placeholderNode = document.createTextNode('');
|
|
parent.replaceChild(placeholderNode, target);
|
|
domParent!.appendChild(target);
|
|
}
|
|
|
|
const styleSheet = <CSSStyleSheet>styleEl.sheet;
|
|
|
|
if (d.adds) {
|
|
d.adds.forEach(({ rule, index }) => {
|
|
const _index =
|
|
index === undefined
|
|
? undefined
|
|
: Math.min(index, styleSheet.rules.length);
|
|
try {
|
|
styleSheet.insertRule(rule, _index);
|
|
} catch (e) {
|
|
/**
|
|
* sometimes we may capture rules with browser prefix
|
|
* insert rule with prefixs in other browsers may cause Error
|
|
*/
|
|
}
|
|
});
|
|
}
|
|
|
|
if (d.removes) {
|
|
d.removes.forEach(({ index }) => {
|
|
try {
|
|
styleSheet.deleteRule(index);
|
|
} catch (e) {
|
|
/**
|
|
* same as insertRule
|
|
*/
|
|
}
|
|
});
|
|
}
|
|
|
|
if (usingVirtualParent && placeholderNode) {
|
|
parent.replaceChild(target, placeholderNode);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case IncrementalSource.CanvasMutation: {
|
|
if (!this.config.UNSAFE_replayCanvas) {
|
|
return;
|
|
}
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
try {
|
|
const ctx = ((target as unknown) as HTMLCanvasElement).getContext(
|
|
'2d',
|
|
)!;
|
|
if (d.setter) {
|
|
// skip some read-only type checks
|
|
// tslint:disable-next-line:no-any
|
|
(ctx as any)[d.property] = d.args[0];
|
|
return;
|
|
}
|
|
const original = ctx[
|
|
d.property as keyof CanvasRenderingContext2D
|
|
] as Function;
|
|
/**
|
|
* We have serialized the image source into base64 string during recording,
|
|
* which has been preloaded before replay.
|
|
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
|
|
*/
|
|
if (d.property === 'drawImage' && typeof d.args[0] === 'string') {
|
|
const image = this.imageMap.get(e);
|
|
d.args[0] = image;
|
|
original.apply(ctx, d.args);
|
|
} else {
|
|
original.apply(ctx, d.args);
|
|
}
|
|
} catch (error) {
|
|
this.warnCanvasMutationFailed(d, d.id, error);
|
|
}
|
|
break;
|
|
}
|
|
case IncrementalSource.Font: {
|
|
try {
|
|
const fontFace = new FontFace(
|
|
d.family,
|
|
d.buffer ? new Uint8Array(JSON.parse(d.fontSource)) : d.fontSource,
|
|
d.descriptors,
|
|
);
|
|
this.iframe.contentDocument?.fonts.add(fontFace);
|
|
} catch (error) {
|
|
if (this.config.showWarning) {
|
|
console.warn(error);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
private applyMutation(d: mutationData, useVirtualParent: boolean) {
|
|
d.removes.forEach((mutation) => {
|
|
const target = mirror.getNode(mutation.id);
|
|
if (!target) {
|
|
return this.warnNodeNotFound(d, mutation.id);
|
|
}
|
|
const parent = mirror.getNode(mutation.parentId);
|
|
if (!parent) {
|
|
return this.warnNodeNotFound(d, mutation.parentId);
|
|
}
|
|
// target may be removed with its parents before
|
|
mirror.removeNodeFromMap(target);
|
|
if (parent) {
|
|
const realParent = this.fragmentParentMap.get(parent);
|
|
if (realParent && realParent.contains(target)) {
|
|
realParent.removeChild(target);
|
|
} else {
|
|
parent.removeChild(target);
|
|
}
|
|
}
|
|
});
|
|
|
|
const legacy_missingNodeMap: missingNodeMap = {
|
|
...this.legacy_missingNodeRetryMap,
|
|
};
|
|
const queue: addedNodeMutation[] = [];
|
|
|
|
// next not present at this moment
|
|
function nextNotInDOM(mutation: addedNodeMutation) {
|
|
let next: Node | null = null;
|
|
if (mutation.nextId) {
|
|
next = mirror.getNode(mutation.nextId) as Node;
|
|
}
|
|
// next not present at this moment
|
|
if (
|
|
mutation.nextId !== null &&
|
|
mutation.nextId !== undefined &&
|
|
mutation.nextId !== -1 &&
|
|
!next
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const appendNode = (mutation: addedNodeMutation) => {
|
|
if (!this.iframe.contentDocument) {
|
|
return console.warn('Looks like your replayer has been destroyed.');
|
|
}
|
|
let parent = mirror.getNode(mutation.parentId);
|
|
if (!parent) {
|
|
return queue.push(mutation);
|
|
}
|
|
|
|
let parentInDocument = null;
|
|
if (this.iframe.contentDocument.contains) {
|
|
parentInDocument = this.iframe.contentDocument.contains(parent);
|
|
} else if (this.iframe.contentDocument.body.contains) {
|
|
// fix for IE
|
|
// refer 'Internet Explorer notes' at https://developer.mozilla.org/zh-CN/docs/Web/API/Document
|
|
parentInDocument = this.iframe.contentDocument.body.contains(parent);
|
|
}
|
|
|
|
if (useVirtualParent && parentInDocument) {
|
|
const virtualParent = (document.createDocumentFragment() as unknown) as INode;
|
|
mirror.map[mutation.parentId] = virtualParent;
|
|
this.fragmentParentMap.set(virtualParent, parent);
|
|
while (parent.firstChild) {
|
|
virtualParent.appendChild(parent.firstChild);
|
|
}
|
|
parent = virtualParent;
|
|
}
|
|
|
|
let previous: Node | null = null;
|
|
let next: Node | null = null;
|
|
if (mutation.previousId) {
|
|
previous = mirror.getNode(mutation.previousId) as Node;
|
|
}
|
|
if (mutation.nextId) {
|
|
next = mirror.getNode(mutation.nextId) as Node;
|
|
}
|
|
if (nextNotInDOM(mutation)) {
|
|
return queue.push(mutation);
|
|
}
|
|
|
|
const target = buildNodeWithSN(
|
|
mutation.node,
|
|
this.iframe.contentDocument,
|
|
mirror.map,
|
|
true,
|
|
) as Node;
|
|
|
|
// legacy data, we should not have -1 siblings any more
|
|
if (mutation.previousId === -1 || mutation.nextId === -1) {
|
|
legacy_missingNodeMap[mutation.node.id] = {
|
|
node: target,
|
|
mutation,
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (previous && previous.nextSibling && previous.nextSibling.parentNode) {
|
|
parent.insertBefore(target, previous.nextSibling);
|
|
} else if (next && next.parentNode) {
|
|
// making sure the parent contains the reference nodes
|
|
// before we insert target before next.
|
|
parent.contains(next)
|
|
? parent.insertBefore(target, next)
|
|
: parent.insertBefore(target, null);
|
|
} else {
|
|
parent.appendChild(target);
|
|
}
|
|
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.legacy_resolveMissingNode(
|
|
legacy_missingNodeMap,
|
|
parent,
|
|
target,
|
|
mutation,
|
|
);
|
|
}
|
|
};
|
|
|
|
d.adds.forEach((mutation) => {
|
|
appendNode(mutation);
|
|
});
|
|
|
|
let startTime = Date.now();
|
|
while (queue.length) {
|
|
// transform queue to resolve tree
|
|
const resolveTrees = queueToResolveTrees(queue);
|
|
queue.length = 0;
|
|
if (Date.now() - startTime > 500) {
|
|
this.warn(
|
|
'Timeout in the loop, please check the resolve tree data:',
|
|
resolveTrees,
|
|
);
|
|
break;
|
|
}
|
|
for (const tree of resolveTrees) {
|
|
let parent = mirror.getNode(tree.value.parentId);
|
|
if (!parent) {
|
|
this.debug(
|
|
'Drop resolve tree since there is no parent for the root node.',
|
|
tree,
|
|
);
|
|
} else {
|
|
iterateResolveTree(tree, (mutation) => {
|
|
appendNode(mutation);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(legacy_missingNodeMap).length) {
|
|
Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap);
|
|
}
|
|
|
|
d.texts.forEach((mutation) => {
|
|
let target = mirror.getNode(mutation.id);
|
|
if (!target) {
|
|
return this.warnNodeNotFound(d, mutation.id);
|
|
}
|
|
/**
|
|
* apply text content to real parent directly
|
|
*/
|
|
if (this.fragmentParentMap.has(target)) {
|
|
target = this.fragmentParentMap.get(target)!;
|
|
}
|
|
target.textContent = mutation.value;
|
|
});
|
|
d.attributes.forEach((mutation) => {
|
|
let target = mirror.getNode(mutation.id);
|
|
if (!target) {
|
|
return this.warnNodeNotFound(d, mutation.id);
|
|
}
|
|
if (this.fragmentParentMap.has(target)) {
|
|
target = this.fragmentParentMap.get(target)!;
|
|
}
|
|
for (const attributeName in mutation.attributes) {
|
|
if (typeof attributeName === 'string') {
|
|
const value = mutation.attributes[attributeName];
|
|
try {
|
|
if (value !== null) {
|
|
((target as Node) as Element).setAttribute(attributeName, value);
|
|
} else {
|
|
((target as Node) as Element).removeAttribute(attributeName);
|
|
}
|
|
} catch (error) {
|
|
if (this.config.showWarning) {
|
|
console.warn(
|
|
'An error occurred may due to the checkout feature.',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private applyScroll(d: scrollData) {
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
if ((target as Node) === this.iframe.contentDocument) {
|
|
this.iframe.contentWindow!.scrollTo({
|
|
top: d.y,
|
|
left: d.x,
|
|
behavior: 'smooth',
|
|
});
|
|
} else {
|
|
try {
|
|
((target as Node) as Element).scrollTop = d.y;
|
|
((target as Node) as Element).scrollLeft = d.x;
|
|
} catch (error) {
|
|
/**
|
|
* Seldomly we may found scroll target was removed before
|
|
* its last scroll event.
|
|
*/
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyInput(d: inputData) {
|
|
const target = mirror.getNode(d.id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, d.id);
|
|
}
|
|
try {
|
|
((target as Node) as HTMLInputElement).checked = d.isChecked;
|
|
((target as Node) as HTMLInputElement).value = d.text;
|
|
} catch (error) {
|
|
// for safe
|
|
}
|
|
}
|
|
|
|
private legacy_resolveMissingNode(
|
|
map: missingNodeMap,
|
|
parent: Node,
|
|
target: Node,
|
|
targetMutation: addedNodeMutation,
|
|
) {
|
|
const { previousId, nextId } = targetMutation;
|
|
const previousInMap = previousId && map[previousId];
|
|
const nextInMap = nextId && map[nextId];
|
|
if (previousInMap) {
|
|
const { node, mutation } = previousInMap as missingNode;
|
|
parent.insertBefore(node, target);
|
|
delete map[mutation.node.id];
|
|
delete this.legacy_missingNodeRetryMap[mutation.node.id];
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.legacy_resolveMissingNode(map, parent, node as Node, mutation);
|
|
}
|
|
}
|
|
if (nextInMap) {
|
|
const { node, mutation } = nextInMap as missingNode;
|
|
parent.insertBefore(node, target.nextSibling);
|
|
delete map[mutation.node.id];
|
|
delete this.legacy_missingNodeRetryMap[mutation.node.id];
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.legacy_resolveMissingNode(map, parent, node as Node, mutation);
|
|
}
|
|
}
|
|
}
|
|
|
|
private moveAndHover(d: incrementalData, x: number, y: number, id: number) {
|
|
this.mouse.style.left = `${x}px`;
|
|
this.mouse.style.top = `${y}px`;
|
|
this.drawMouseTail({ x, y });
|
|
|
|
const target = mirror.getNode(id);
|
|
if (!target) {
|
|
return this.debugNodeNotFound(d, id);
|
|
}
|
|
this.hoverElements((target as Node) as Element);
|
|
}
|
|
|
|
private drawMouseTail(position: { x: number; y: number }) {
|
|
if (!this.mouseTail) {
|
|
return;
|
|
}
|
|
|
|
const { lineCap, lineWidth, strokeStyle, duration } =
|
|
this.config.mouseTail === true
|
|
? defaultMouseTailConfig
|
|
: Object.assign({}, defaultMouseTailConfig, this.config.mouseTail);
|
|
|
|
const draw = () => {
|
|
if (!this.mouseTail) {
|
|
return;
|
|
}
|
|
const ctx = this.mouseTail.getContext('2d');
|
|
if (!ctx || !this.tailPositions.length) {
|
|
return;
|
|
}
|
|
ctx.clearRect(0, 0, this.mouseTail.width, this.mouseTail.height);
|
|
ctx.beginPath();
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineCap = lineCap;
|
|
ctx.strokeStyle = strokeStyle;
|
|
ctx.moveTo(this.tailPositions[0].x, this.tailPositions[0].y);
|
|
this.tailPositions.forEach((p) => ctx.lineTo(p.x, p.y));
|
|
ctx.stroke();
|
|
};
|
|
|
|
this.tailPositions.push(position);
|
|
draw();
|
|
setTimeout(() => {
|
|
this.tailPositions = this.tailPositions.filter((p) => p !== position);
|
|
draw();
|
|
}, duration);
|
|
}
|
|
|
|
private hoverElements(el: Element) {
|
|
this.iframe.contentDocument
|
|
?.querySelectorAll('.\\:hover')
|
|
.forEach((hoveredEl) => {
|
|
hoveredEl.classList.remove(':hover');
|
|
});
|
|
let currentEl: Element | null = el;
|
|
while (currentEl) {
|
|
if (currentEl.classList) {
|
|
currentEl.classList.add(':hover');
|
|
}
|
|
currentEl = currentEl.parentElement;
|
|
}
|
|
}
|
|
|
|
private isUserInteraction(event: eventWithTime): boolean {
|
|
if (event.type !== EventType.IncrementalSnapshot) {
|
|
return false;
|
|
}
|
|
return (
|
|
event.data.source > IncrementalSource.Mutation &&
|
|
event.data.source <= IncrementalSource.Input
|
|
);
|
|
}
|
|
|
|
private backToNormal() {
|
|
this.nextUserInteractionEvent = null;
|
|
if (this.speedService.state.matches('normal')) {
|
|
return;
|
|
}
|
|
this.speedService.send({ type: 'BACK_TO_NORMAL' });
|
|
this.emitter.emit(ReplayerEvents.SkipEnd, {
|
|
speed: this.speedService.state.context.normalSpeed,
|
|
});
|
|
}
|
|
|
|
private warnNodeNotFound(d: incrementalData, id: number) {
|
|
this.warn(`Node with id '${id}' not found in`, d);
|
|
}
|
|
|
|
private warnCanvasMutationFailed(
|
|
d: canvasMutationData,
|
|
id: number,
|
|
error: unknown,
|
|
) {
|
|
this.warn(`Has error on update canvas '${id}'`, d, error);
|
|
}
|
|
|
|
private debugNodeNotFound(d: incrementalData, id: number) {
|
|
/**
|
|
* There maybe some valid scenes of node not being found.
|
|
* Because DOM events are macrotask and MutationObserver callback
|
|
* is microtask, so events fired on a removed DOM may emit
|
|
* snapshots in the reverse order.
|
|
*/
|
|
this.debug(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found in`, d);
|
|
}
|
|
|
|
private warn(...args: Parameters<typeof console.warn>) {
|
|
if (!this.config.showWarning) {
|
|
return;
|
|
}
|
|
console.warn(REPLAY_CONSOLE_PREFIX, ...args);
|
|
}
|
|
|
|
private debug(...args: Parameters<typeof console.log>) {
|
|
if (!this.config.showDebug) {
|
|
return;
|
|
}
|
|
// tslint:disable-next-line: no-console
|
|
console.log(REPLAY_CONSOLE_PREFIX, ...args);
|
|
}
|
|
}
|