#853 Second try: fast-forward implementation v2: virtual dom optimization (#895)

* rrdom: add a diff function for properties

* implement diffChildren function and unit tests

* finish basic functions of diff algorithm

* fix several bugs in the diff algorithm

* replace the virtual parent optimization in applyMutation()

* fix: moveAndHover after the diff algorithm is executed

* replace virtual style map with rrdom

cssom version has to be above 0.5.0 to pass virtual style tests

* fix: failed virtual style tests in replayer.test.ts

* fix: failed polyfill tests caused by nodejs compatibility of different versions

* fix: svg viewBox attribute doesn't work

Cause the attribute viewBox is case sensitive, set value for viewbox doesn't work

* feat: replace treeIndex optimization with rrdom

* fix bug of diffProps and disable smooth scrolling animation in fast-forward mode

* feat: add iframe support

* fix: @rollup/plugin-typescript build errors in rrweb-player

Error: @rollup/plugin-typescript TS1371: This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'

* fix: bug when fast-forward input events and add test for it

* add test for fast-forward scroll events

* fix: custom style rules don't get inserted into some iframe elements

* code style tweak

* fix: enable to diff iframe elements

* fix  the jest error "Unexpected token 'export'"

* try to fix build error of rrweb-player

* correct the attributes definition in rrdom

* fix: custom style rules are not inserted in some iframes

* add support for shadow dom

* add support for MediaInteraction

* add canvas support

* fix unit test error in rrdom

* add support for Text, Comment

* try to refactor RRDom

* refactor RRDom to reduce duplicate code

* rename document-browser to virtual-dom

* increase the test coverage for document.ts and add ownerDocument for it

* Merge branch 'master' into virtual-dom

* add more test for virtual-dom.ts

* use cssstyle in document-nodejs

* fix: bundle error

* improve document-nodejs

* enable to diff scroll positions of an element

* rename rrdom to virtualDom for more readability and make the tree public

* revert unknown change

* improve the css style parser for comments

* improve code style

* update typings

* add handling for the case where legacy_missingNodeRetryMap is not empty

* only import types from rrweb into rrdom

* Apply suggestions from code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* Apply suggestions from code review

* fix building error in rrweb

* add a method setDefaultSN to set a default value for a RRNode's __sn

* fix rrweb test error and bump up other packages

* add support for custom property of css styles

* add a switch for virtual-dom optimization

* Apply suggestions from code review

1. add an enum type for NodeType
2. rename nodeType from rrweb-snapshot to RRNodeType
3. rename notSerializedId to unserializedId
4. add comments for some confusing variables

* adapt changes of #865 to virtual-dom and improve the test case for more coverage

* apply review suggestions

https://github.com/rrweb-io/rrweb/pull/853#pullrequestreview-922474953

* tweak the diff algorithm

* add description of the flag useVirtualDom and remove outdated logConfig

* Remove console.log

* Contain changes to document

* Upgrade rollup to 2.70.2

* Revert "Upgrade rollup to 2.70.2"

This reverts commit b1be81a2a76565935c9dc391f31beb7f64d25956.

* Fix type checking rrdom

* Fix typing error while bundling

* Fix tslib error on build

Rollup would output the following error:
`semantic error TS2343: This syntax requires an imported helper named '__spreadArray' which does not exist in 'tslib'. Consider upgrading your version of 'tslib'.`

* Increase memory limit for rollup

* Use esbuild for bundling

Speeds up bundling significantly

* Avoid circular dependencies and import un-bundled rrdom

* Fix imports

* Revert back to pre-esbuild

This reverts the following commits:
b7b3c8dbaa551a0129da1477136b1baaad28e6e1
72e23b8e27f9030d911358d3a17fe5ad1b3b5d4f
85d600a20c56cfa764cf1f858932ba14e67b1d23
61e1a5d323212ca8fbe0569e0b3062ddd53fc612

* Set node to lts (12 is no longer supported)

* Speed up bundling and use less memory

This fixes the out of memory errors happening while bundling

* remove __sn from rrdom

* fix typo

* test: add a test case for StyleSheet mutation exceptions while fast-forwarding

* rename Array.prototype.slice.call() to Array.from()

* improve test cases

* fix: PR #887 in 'virtual-dom' branch

* apply justin's suggestion on 'Array.from' refactor

related commit 0f6729d27a323260b36fbe79485a86715c0bc98a

* improve import code structure

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 4f2f739d93
commit 2887c8c7e5
99 changed files with 7087 additions and 2821 deletions

513
packages/rrdom/src/diff.ts Normal file
View File

@@ -0,0 +1,513 @@
import { NodeType as RRNodeType, Mirror as NodeMirror } from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
} from 'rrweb/src/types';
import type {
IRRCDATASection,
IRRComment,
IRRDocument,
IRRDocumentType,
IRRElement,
IRRNode,
IRRText,
} from './document';
import type {
RRCanvasElement,
RRElement,
RRIFrameElement,
RRMediaElement,
RRStyleElement,
RRDocument,
Mirror,
} from './virtual-dom';
const NAMESPACES: Record<string, string> = {
svg: 'http://www.w3.org/2000/svg',
'xlink:href': 'http://www.w3.org/1999/xlink',
xmlns: 'http://www.w3.org/2000/xmlns/',
};
// camel case svg element tag names
const SVGTagMap: Record<string, string> = {
altglyph: 'altGlyph',
altglyphdef: 'altGlyphDef',
altglyphitem: 'altGlyphItem',
animatecolor: 'animateColor',
animatemotion: 'animateMotion',
animatetransform: 'animateTransform',
clippath: 'clipPath',
feblend: 'feBlend',
fecolormatrix: 'feColorMatrix',
fecomponenttransfer: 'feComponentTransfer',
fecomposite: 'feComposite',
feconvolvematrix: 'feConvolveMatrix',
fediffuselighting: 'feDiffuseLighting',
fedisplacementmap: 'feDisplacementMap',
fedistantlight: 'feDistantLight',
fedropshadow: 'feDropShadow',
feflood: 'feFlood',
fefunca: 'feFuncA',
fefuncb: 'feFuncB',
fefuncg: 'feFuncG',
fefuncr: 'feFuncR',
fegaussianblur: 'feGaussianBlur',
feimage: 'feImage',
femerge: 'feMerge',
femergenode: 'feMergeNode',
femorphology: 'feMorphology',
feoffset: 'feOffset',
fepointlight: 'fePointLight',
fespecularlighting: 'feSpecularLighting',
fespotlight: 'feSpotLight',
fetile: 'feTile',
feturbulence: 'feTurbulence',
foreignobject: 'foreignObject',
glyphref: 'glyphRef',
lineargradient: 'linearGradient',
radialgradient: 'radialGradient',
};
export type ReplayerHandler = {
mirror: NodeMirror;
applyCanvas: (
canvasEvent: canvasEventWithTime,
canvasMutationData: canvasMutationData,
target: HTMLCanvasElement,
) => void;
applyInput: (data: inputData) => void;
applyScroll: (data: scrollData, isSync: boolean) => void;
};
export function diff(
oldTree: Node,
newTree: IRRNode,
replayer: ReplayerHandler,
rrnodeMirror?: Mirror,
) {
const oldChildren = oldTree.childNodes;
const newChildren = newTree.childNodes;
rrnodeMirror =
rrnodeMirror ||
(newTree as RRDocument).mirror ||
(newTree.ownerDocument as RRDocument).mirror;
if (oldChildren.length > 0 || newChildren.length > 0) {
diffChildren(
Array.from(oldChildren),
newChildren,
oldTree,
replayer,
rrnodeMirror,
);
}
let inputDataToApply = null,
scrollDataToApply = null;
switch (newTree.RRNodeType) {
case RRNodeType.Document:
const newRRDocument = newTree as IRRDocument;
scrollDataToApply = (newRRDocument as RRDocument).scrollData;
break;
case RRNodeType.Element:
const oldElement = (oldTree as Node) as HTMLElement;
const newRRElement = newTree as IRRElement;
diffProps(oldElement, newRRElement, rrnodeMirror);
scrollDataToApply = (newRRElement as RRElement).scrollData;
inputDataToApply = (newRRElement as RRElement).inputData;
switch (newRRElement.tagName) {
case 'AUDIO':
case 'VIDEO':
const oldMediaElement = (oldTree as Node) as HTMLMediaElement;
const newMediaRRElement = newRRElement as RRMediaElement;
if (newMediaRRElement.paused !== undefined)
newMediaRRElement.paused
? oldMediaElement.pause()
: oldMediaElement.play();
if (newMediaRRElement.muted !== undefined)
oldMediaElement.muted = newMediaRRElement.muted;
if (newMediaRRElement.volume !== undefined)
oldMediaElement.volume = newMediaRRElement.volume;
if (newMediaRRElement.currentTime !== undefined)
oldMediaElement.currentTime = newMediaRRElement.currentTime;
break;
case 'CANVAS':
(newTree as RRCanvasElement).canvasMutations.forEach(
(canvasMutation) =>
replayer.applyCanvas(
canvasMutation.event,
canvasMutation.mutation,
(oldTree as Node) as HTMLCanvasElement,
),
);
break;
case 'STYLE':
applyVirtualStyleRulesToNode(
oldElement as HTMLStyleElement,
(newTree as RRStyleElement).rules,
);
break;
}
if (newRRElement.shadowRoot) {
if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' });
const oldChildren = oldElement.shadowRoot!.childNodes;
const newChildren = newRRElement.shadowRoot.childNodes;
if (oldChildren.length > 0 || newChildren.length > 0)
diffChildren(
Array.from(oldChildren),
newChildren,
oldElement.shadowRoot!,
replayer,
rrnodeMirror,
);
}
break;
case RRNodeType.Text:
case RRNodeType.Comment:
case RRNodeType.CDATA:
if (
oldTree.textContent !==
(newTree as IRRText | IRRComment | IRRCDATASection).data
)
oldTree.textContent = (newTree as
| IRRText
| IRRComment
| IRRCDATASection).data;
break;
default:
}
scrollDataToApply && replayer.applyScroll(scrollDataToApply, true);
/**
* Input data need to get applied after all children of this node are updated.
* Otherwise when we set a value for a select element whose options are empty, the value won't actually update.
*/
inputDataToApply && replayer.applyInput(inputDataToApply);
// IFrame element doesn't have child nodes.
if (newTree.nodeName === 'IFRAME') {
const oldContentDocument = ((oldTree as Node) as HTMLIFrameElement)
.contentDocument;
const newIFrameElement = newTree as RRIFrameElement;
// If the iframe is cross-origin, the contentDocument will be null.
if (oldContentDocument) {
const sn = rrnodeMirror.getMeta(newIFrameElement.contentDocument);
if (sn) {
replayer.mirror.add(oldContentDocument, { ...sn });
}
diff(
oldContentDocument,
newIFrameElement.contentDocument,
replayer,
rrnodeMirror,
);
}
}
}
function diffProps(
oldTree: HTMLElement,
newTree: IRRElement,
rrnodeMirror: Mirror,
) {
const oldAttributes = oldTree.attributes;
const newAttributes = newTree.attributes;
for (const name in newAttributes) {
const newValue = newAttributes[name];
const sn = rrnodeMirror.getMeta(newTree);
if (sn && 'isSVG' in sn && sn.isSVG && NAMESPACES[name])
oldTree.setAttributeNS(NAMESPACES[name], name, newValue);
else if (newTree.tagName === 'CANVAS' && name === 'rr_dataURL') {
const image = document.createElement('img');
image.src = newValue;
image.onload = () => {
const ctx = (oldTree as HTMLCanvasElement).getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0, image.width, image.height);
}
};
} else oldTree.setAttribute(name, newValue);
}
for (const { name } of Array.from(oldAttributes))
if (!(name in newAttributes)) oldTree.removeAttribute(name);
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
}
function diffChildren(
oldChildren: (Node | undefined)[],
newChildren: IRRNode[],
parentNode: Node,
replayer: ReplayerHandler,
rrnodeMirror: Mirror,
) {
let oldStartIndex = 0,
oldEndIndex = oldChildren.length - 1,
newStartIndex = 0,
newEndIndex = newChildren.length - 1;
let oldStartNode = oldChildren[oldStartIndex],
oldEndNode = oldChildren[oldEndIndex],
newStartNode = newChildren[newStartIndex],
newEndNode = newChildren[newEndIndex];
let oldIdToIndex: Record<number, number> | undefined = undefined,
indexInOld;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (oldStartNode === undefined) {
oldStartNode = oldChildren[++oldStartIndex];
} else if (oldEndNode === undefined) {
oldEndNode = oldChildren[--oldEndIndex];
} else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newStartNode)
) {
diff(oldStartNode, newStartNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex];
newStartNode = newChildren[++newStartIndex];
} else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newEndNode)
) {
diff(oldEndNode, newEndNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex];
newEndNode = newChildren[--newEndIndex];
} else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newEndNode)
) {
parentNode.insertBefore(oldStartNode, oldEndNode.nextSibling);
diff(oldStartNode, newEndNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex];
newEndNode = newChildren[--newEndIndex];
} else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newStartNode)
) {
parentNode.insertBefore(oldEndNode, oldStartNode);
diff(oldEndNode, newStartNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex];
newStartNode = newChildren[++newStartIndex];
} else {
if (!oldIdToIndex) {
oldIdToIndex = {};
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
const oldChild = oldChildren[i];
if (oldChild && replayer.mirror.hasNode(oldChild))
oldIdToIndex[replayer.mirror.getId(oldChild)] = i;
}
}
indexInOld = oldIdToIndex[rrnodeMirror.getId(newStartNode)];
if (indexInOld) {
const nodeToMove = oldChildren[indexInOld]!;
parentNode.insertBefore(nodeToMove, oldStartNode);
diff(nodeToMove, newStartNode, replayer, rrnodeMirror);
oldChildren[indexInOld] = undefined;
} else {
const newNode = createOrGetNode(
newStartNode,
replayer.mirror,
rrnodeMirror,
);
/**
* A mounted iframe element has an automatically created HTML element.
* We should delete it before insert a serialized one. Otherwise, an error 'Only one element on document allowed' will be thrown.
*/
if (
replayer.mirror.getMeta(parentNode)?.type === RRNodeType.Document &&
replayer.mirror.getMeta(newNode)?.type === RRNodeType.Element &&
((parentNode as Node) as Document).documentElement
) {
parentNode.removeChild(
((parentNode as Node) as Document).documentElement,
);
oldChildren[oldStartIndex] = undefined;
oldStartNode = undefined;
}
parentNode.insertBefore(newNode, oldStartNode || null);
diff(newNode, newStartNode, replayer, rrnodeMirror);
}
newStartNode = newChildren[++newStartIndex];
}
}
if (oldStartIndex > oldEndIndex) {
const referenceRRNode = newChildren[newEndIndex + 1];
let referenceNode = null;
if (referenceRRNode)
parentNode.childNodes.forEach((child) => {
if (
replayer.mirror.getId(child) === rrnodeMirror.getId(referenceRRNode)
)
referenceNode = child;
});
for (; newStartIndex <= newEndIndex; ++newStartIndex) {
const newNode = createOrGetNode(
newChildren[newStartIndex],
replayer.mirror,
rrnodeMirror,
);
parentNode.insertBefore(newNode, referenceNode);
diff(newNode, newChildren[newStartIndex], replayer, rrnodeMirror);
}
} else if (newStartIndex > newEndIndex) {
for (; oldStartIndex <= oldEndIndex; oldStartIndex++) {
const node = oldChildren[oldStartIndex];
if (node) {
parentNode.removeChild(node);
replayer.mirror.removeNodeFromMap(node);
}
}
}
}
export function createOrGetNode(
rrNode: IRRNode,
domMirror: NodeMirror,
rrnodeMirror: Mirror,
): Node {
let node = domMirror.getNode(rrnodeMirror.getId(rrNode));
const sn = rrnodeMirror.getMeta(rrNode);
if (node !== null) return node;
switch (rrNode.RRNodeType) {
case RRNodeType.Document:
node = new Document();
break;
case RRNodeType.DocumentType:
node = document.implementation.createDocumentType(
(rrNode as IRRDocumentType).name,
(rrNode as IRRDocumentType).publicId,
(rrNode as IRRDocumentType).systemId,
);
break;
case RRNodeType.Element:
let tagName = (rrNode as IRRElement).tagName.toLowerCase();
tagName = SVGTagMap[tagName] || tagName;
if (sn && 'isSVG' in sn && sn?.isSVG) {
node = document.createElementNS(
NAMESPACES['svg'],
(rrNode as IRRElement).tagName.toLowerCase(),
);
} else node = document.createElement((rrNode as IRRElement).tagName);
break;
case RRNodeType.Text:
node = document.createTextNode((rrNode as IRRText).data);
break;
case RRNodeType.Comment:
node = document.createComment((rrNode as IRRComment).data);
break;
case RRNodeType.CDATA:
node = document.createCDATASection((rrNode as IRRCDATASection).data);
break;
}
if (sn) domMirror.add(node, { ...sn });
return node;
}
export function getNestedRule(
rules: CSSRuleList,
position: number[],
): CSSGroupingRule {
const rule = rules[position[0]] as CSSGroupingRule;
if (position.length === 1) {
return rule;
} else {
return getNestedRule(
((rule as CSSGroupingRule).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
position.slice(2),
);
}
}
export enum StyleRuleType {
Insert,
Remove,
Snapshot,
SetProperty,
RemoveProperty,
}
type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number | number[];
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number | number[];
};
type SetPropertyRule = {
type: StyleRuleType.SetProperty;
index: number[];
property: string;
value: string | null;
priority: string | undefined;
};
type RemovePropertyRule = {
type: StyleRuleType.RemoveProperty;
index: number[];
property: string;
};
export type VirtualStyleRules = Array<
InsertRule | RemoveRule | SetPropertyRule | RemovePropertyRule
>;
export function getPositionsAndIndex(nestedIndex: number[]) {
const positions = [...nestedIndex];
const index = positions.pop();
return { positions, index };
}
export function applyVirtualStyleRulesToNode(
styleNode: HTMLStyleElement,
virtualStyleRules: VirtualStyleRules,
) {
const sheet = styleNode.sheet!;
virtualStyleRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.insertRule(rule.cssText, index);
} else {
sheet.insertRule(rule.cssText, rule.index);
}
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
} else if (rule.type === StyleRuleType.Remove) {
try {
if (Array.isArray(rule.index)) {
const { positions, index } = getPositionsAndIndex(rule.index);
const nestedRule = getNestedRule(sheet.cssRules, positions);
nestedRule.deleteRule(index || 0);
} else {
sheet.deleteRule(rule.index);
}
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
} else if (rule.type === StyleRuleType.SetProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.setProperty(rule.property, rule.value, rule.priority);
} else if (rule.type === StyleRuleType.RemoveProperty) {
const nativeRule = (getNestedRule(
sheet.cssRules,
rule.index,
) as unknown) as CSSStyleRule;
nativeRule.style.removeProperty(rule.property);
}
});
}

View File

@@ -1,68 +1,24 @@
import { INode, NodeType, serializedNodeWithId } from 'rrweb-snapshot';
import { NWSAPI } from 'nwsapi';
import { parseCSSText, camelize, toCSSText } from './style';
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import type { NWSAPI } from 'nwsapi';
import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle';
import {
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRNode,
BaseRRTextImpl,
ClassList,
IRRDocument,
CSSStyleDeclaration,
} from './document';
const nwsapi = require('nwsapi');
const cssom = require('cssom');
const cssstyle = require('cssstyle');
export abstract class RRNode {
__sn: serializedNodeWithId | undefined;
children: Array<RRNode> = [];
parentElement: RRElement | null = null;
parentNode: RRNode | null = null;
ownerDocument: RRDocument | null = null;
ELEMENT_NODE = 1;
TEXT_NODE = 3;
get firstChild() {
return this.children[0];
}
get nodeType() {
if (this instanceof RRDocument) return NodeType.Document;
if (this instanceof RRDocumentType) return NodeType.DocumentType;
if (this instanceof RRElement) return NodeType.Element;
if (this instanceof RRText) return NodeType.Text;
if (this instanceof RRCDATASection) return NodeType.CDATA;
if (this instanceof RRComment) return NodeType.Comment;
}
get childNodes() {
return this.children;
}
appendChild(newChild: RRNode): RRNode {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method.`,
);
}
contains(node: RRNode) {
if (node === this) return true;
for (const child of this.children) {
if (child.contains(node)) return true;
}
return false;
}
removeChild(node: RRNode) {
const indexOfChild = this.children.indexOf(node);
if (indexOfChild !== -1) {
this.children.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
}
}
toString(nodeName?: string) {
return `${JSON.stringify(this.__sn?.id) || ''} ${nodeName}`;
}
}
export class RRNode extends BaseRRNode {}
export class RRWindow {
scrollLeft = 0;
@@ -74,8 +30,10 @@ export class RRWindow {
}
}
export class RRDocument extends RRNode {
private mirror: Map<number, RRNode> = new Map();
export class RRDocument
extends BaseRRDocumentImpl(RRNode)
implements IRRDocument {
readonly nodeName: '#document' = '#document';
private _nwsapi: NWSAPI;
get nwsapi() {
if (!this._nwsapi) {
@@ -95,66 +53,32 @@ export class RRDocument extends RRNode {
return this._nwsapi;
}
get documentElement(): RRElement {
return this.children.find(
(node) => node instanceof RRElement && node.tagName === 'HTML',
) as RRElement;
get documentElement(): RRElement | null {
return super.documentElement as RRElement | null;
}
get body() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'BODY',
) || null
);
get body(): RRElement | null {
return super.body as RRElement | null;
}
get head() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'HEAD',
) || null
);
return super.head as RRElement | null;
}
get implementation() {
get implementation(): RRDocument {
return this;
}
get firstElementChild() {
return this.documentElement;
get firstElementChild(): RRElement | null {
return this.documentElement as RRElement | null;
}
appendChild(childNode: RRNode) {
const nodeType = childNode.nodeType;
if (nodeType === NodeType.Element || nodeType === NodeType.DocumentType) {
if (this.children.some((s) => s.nodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one ${
nodeType === NodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
childNode.ownerDocument = this;
this.children.push(childNode);
return childNode;
return super.appendChild(childNode);
}
insertBefore(newChild: RRNode, refChild: RRNode | null) {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this;
return newChild;
return super.insertBefore(newChild, refChild);
}
querySelectorAll(selectors: string): RRNode[] {
@@ -216,16 +140,16 @@ export class RRDocument extends RRNode {
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIframeElement(upperTagName);
element = new RRIFrameElement(upperTagName);
break;
case 'IMG':
element = new RRImageElement('IMG');
element = new RRImageElement(upperTagName);
break;
case 'CANVAS':
element = new RRCanvasElement('CANVAS');
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement('STYLE');
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
@@ -235,10 +159,7 @@ export class RRDocument extends RRNode {
return element;
}
createElementNS(
_namespaceURI: 'http://www.w3.org/2000/svg',
qualifiedName: string,
) {
createElementNS(_namespaceURI: string, qualifiedName: string) {
return this.createElement(qualifiedName as keyof HTMLElementTagNameMap);
}
@@ -259,266 +180,40 @@ export class RRDocument extends RRNode {
textNode.ownerDocument = this;
return textNode;
}
/**
* This does come with some side effects. For example:
* 1. All event listeners currently registered on the document, nodes inside the document, or the document's window are removed.
* 2. All existing nodes are removed from the document.
*/
open() {
this.children = [];
}
close() {}
buildFromDom(dom: Document) {
let notSerializedId = -1;
const NodeTypeMap: Record<number, number> = {};
NodeTypeMap[document.DOCUMENT_NODE] = NodeType.Document;
NodeTypeMap[document.DOCUMENT_TYPE_NODE] = NodeType.DocumentType;
NodeTypeMap[document.ELEMENT_NODE] = NodeType.Element;
NodeTypeMap[document.TEXT_NODE] = NodeType.Text;
NodeTypeMap[document.CDATA_SECTION_NODE] = NodeType.CDATA;
NodeTypeMap[document.COMMENT_NODE] = NodeType.Comment;
function getValidTagName(element: HTMLElement): string {
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase().trim();
}
const walk = function (node: INode) {
let serializedNodeWithId = node.__sn;
let rrNode: RRNode;
if (!serializedNodeWithId) {
serializedNodeWithId = {
type: NodeTypeMap[node.nodeType],
textContent: '',
id: notSerializedId,
};
notSerializedId -= 1;
node.__sn = serializedNodeWithId;
}
if (!this.mirror.has(serializedNodeWithId.id)) {
switch (node.nodeType) {
case node.DOCUMENT_NODE:
if (
serializedNodeWithId.rootId &&
serializedNodeWithId.rootId !== serializedNodeWithId.id
)
rrNode = this.createDocument();
else rrNode = this;
break;
case node.DOCUMENT_TYPE_NODE:
const documentType = (node as unknown) as DocumentType;
rrNode = this.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case node.ELEMENT_NODE:
const elementNode = (node as unknown) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = this.createElement(tagName);
const rrElement = rrNode as RRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
// form fields
if (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT'
) {
const value = (elementNode as
| HTMLInputElement
| HTMLTextAreaElement).value;
if (
['RADIO', 'CHECKBOX', 'SUBMIT', 'BUTTON'].includes(
rrElement.attributes.type as string,
) &&
value
) {
rrElement.attributes.value = value;
} else if ((elementNode as HTMLInputElement).checked) {
rrElement.attributes.checked = (elementNode as HTMLInputElement).checked;
}
}
if (tagName === 'OPTION') {
const selectValue = (elementNode as HTMLOptionElement)
.parentElement;
if (
rrElement.attributes.value ===
(selectValue as HTMLSelectElement).value
) {
rrElement.attributes.selected = (elementNode as HTMLOptionElement).selected;
}
}
// canvas image data
if (tagName === 'CANVAS') {
rrElement.attributes.rr_dataURL = (elementNode as HTMLCanvasElement).toDataURL();
}
// media elements
if (tagName === 'AUDIO' || tagName === 'VIDEO') {
const rrMediaElement = rrElement as RRMediaElement;
rrMediaElement.paused = (elementNode as HTMLMediaElement).paused;
rrMediaElement.currentTime = (elementNode as HTMLMediaElement).currentTime;
}
// scroll
if (elementNode.scrollLeft) {
rrElement.scrollLeft = elementNode.scrollLeft;
}
if (elementNode.scrollTop) {
rrElement.scrollTop = elementNode.scrollTop;
}
break;
case node.TEXT_NODE:
rrNode = this.createTextNode(
((node as unknown) as Text).textContent,
);
break;
case node.CDATA_SECTION_NODE:
rrNode = this.createCDATASection();
break;
case node.COMMENT_NODE:
rrNode = this.createComment(
((node as unknown) as Comment).textContent || '',
);
break;
default:
return;
}
rrNode.__sn = serializedNodeWithId;
this.mirror.set(serializedNodeWithId.id, rrNode);
} else {
rrNode = this.mirror.get(serializedNodeWithId.id);
rrNode.parentElement = null;
rrNode.parentNode = null;
rrNode.children = [];
}
const parentNode = node.parentElement || node.parentNode;
if (parentNode) {
const parentSN = ((parentNode as unknown) as INode).__sn;
const parentRRNode = this.mirror.get(parentSN.id);
parentRRNode.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement =
parentRRNode instanceof RRElement ? parentRRNode : null;
}
if (
serializedNodeWithId.type === NodeType.Document ||
serializedNodeWithId.type === NodeType.Element
) {
node.childNodes.forEach((node) => walk((node as unknown) as INode));
}
}.bind(this);
if (dom) {
this.destroyTree();
walk((dom as unknown) as INode);
}
}
destroyTree() {
this.children = [];
this.mirror.clear();
}
toString() {
return super.toString('RRDocument');
}
}
export class RRDocumentType extends RRNode {
readonly name: string;
readonly publicId: string;
readonly systemId: string;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
this.name = qualifiedName;
this.publicId = publicId;
this.systemId = systemId;
}
toString() {
return super.toString('RRDocumentType');
}
}
export class RRElement extends RRNode {
tagName: string;
attributes: Record<string, string | number | boolean> = {};
scrollLeft: number = 0;
scrollTop: number = 0;
shadowRoot: RRElement | null = null;
export class RRDocumentType extends BaseRRDocumentTypeImpl(RRNode) {}
export class RRElement extends BaseRRElementImpl(RRNode) {
private _style: CSSStyleDeclarationType;
constructor(tagName: string) {
super();
this.tagName = tagName;
}
get classList() {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
super(tagName);
this._style = new cssstyle.CSSStyleDeclaration();
const style = this._style;
Object.defineProperty(this.attributes, 'style', {
get() {
return style.cssText;
},
);
set(cssText: string) {
style.cssText = cssText;
},
});
}
get id() {
return this.attributes.id;
}
get className() {
return this.attributes.class || '';
}
get textContent() {
return '';
}
set textContent(newText: string) {}
get style() {
const style = (this.attributes.style
? parseCSSText(this.attributes.style as string)
: {}) as Record<string, string> & {
setProperty: (
name: string,
value: string | null,
priority?: string | null,
) => void;
};
style.setProperty = (name: string, value: string | null) => {
const normalizedName = camelize(name);
if (!value) delete style[normalizedName];
else style[normalizedName] = value;
this.attributes.style = toCSSText(style);
};
// This is used to bypass the smoothscroll polyfill in rrweb player.
style.scrollBehavior = '';
return style;
return (this._style as unknown) as CSSStyleDeclaration;
}
get firstElementChild(): RRElement | null {
for (let child of this.children)
if (child instanceof RRElement) return child;
return null;
attachShadow(_init: ShadowRootInit): RRElement {
return super.attachShadow(_init) as RRElement;
}
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.children;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
appendChild(newChild: RRNode): RRNode {
return super.appendChild(newChild) as RRNode;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
return super.insertBefore(newChild, refChild) as RRNode;
}
getAttribute(name: string) {
@@ -531,57 +226,44 @@ export class RRElement extends RRNode {
this.attributes[name.toLowerCase()] = attribute;
}
hasAttribute(name: string) {
return (name && name.toLowerCase()) in this.attributes;
}
setAttributeNS(
_namespace: string | null,
qualifiedName: string,
value: string,
): void {
this.setAttribute(qualifiedName, value);
}
removeAttribute(name: string) {
delete this.attributes[name];
delete this.attributes[name.toLowerCase()];
}
appendChild(newChild: RRNode): RRNode {
this.children.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get firstElementChild(): RRElement | null {
for (let child of this.childNodes)
if (child.RRNodeType === RRNodeType.Element) return child as RRElement;
return null;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
}
querySelectorAll(selectors: string): RRNode[] {
const result: RRElement[] = [];
if (this.ownerDocument !== null) {
return (this.ownerDocument.nwsapi.select(
((this.ownerDocument as RRDocument).nwsapi.select(
selectors,
(this as unknown) as Element,
(element) => {
if (((element as unknown) as RRElement) !== this)
result.push((element as unknown) as RRElement);
},
) as unknown) as RRNode[];
}
return [];
return result;
}
getElementById(elementId: string): RRElement | null {
if (this instanceof RRElement && this.id === elementId) return this;
for (const child of this.children) {
if (this.id === elementId) return this;
for (const child of this.childNodes) {
if (child instanceof RRElement) {
const result = child.getElementById(elementId);
if (result !== null) return result;
@@ -596,12 +278,12 @@ export class RRElement extends RRNode {
// Make sure this element has all queried class names.
if (
this instanceof RRElement &&
queryClassList.filter((queriedClassName) =>
this.classList.some((name) => name === queriedClassName),
).length == queryClassList.length
queryClassList.classes.filter((queriedClassName) =>
this.classList.classes.some((name) => name === queriedClassName),
).length == queryClassList.classes.length
)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByClassName(className));
}
@@ -613,32 +295,12 @@ export class RRElement extends RRNode {
const normalizedTagName = tagName.toUpperCase();
if (this instanceof RRElement && this.tagName === normalizedTagName)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByTagName(tagName));
}
return elements;
}
dispatchEvent(_event: Event) {
return true;
}
/**
* Creates a shadow root for element and returns it.
*/
attachShadow(init: ShadowRootInit): RRElement {
this.shadowRoot = init.mode === 'open' ? this : null;
return this;
}
toString() {
let attributeString = '';
for (let attribute in this.attributes) {
attributeString += `${attribute}="${this.attributes[attribute]}" `;
}
return `${super.toString(this.tagName)} ${attributeString}`;
}
}
export class RRImageElement extends RRElement {
@@ -648,16 +310,7 @@ export class RRImageElement extends RRElement {
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null;
}
export class RRMediaElement extends RRElement {
currentTime: number = 0;
paused: boolean = true;
async play() {
this.paused = false;
}
async pause() {
this.paused = true;
}
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement {
/**
@@ -675,7 +328,7 @@ export class RRStyleElement extends RRElement {
if (!this._sheet) {
let result = '';
for (let child of this.childNodes)
if (child.nodeType === NodeType.Text)
if (child.RRNodeType === RRNodeType.Text)
result += (child as RRText).textContent;
this._sheet = cssom.parse(result);
}
@@ -683,7 +336,7 @@ export class RRStyleElement extends RRElement {
}
}
export class RRIframeElement extends RRElement {
export class RRIFrameElement extends RRElement {
width: string = '';
height: string = '';
src: string = '';
@@ -699,89 +352,27 @@ export class RRIframeElement extends RRElement {
}
}
export class RRText extends RRNode {
textContent: string;
constructor(data: string) {
super();
this.textContent = data;
}
toString() {
return `${super.toString('RRText')} text=${JSON.stringify(
this.textContent,
)}`;
}
export class RRText extends BaseRRTextImpl(RRNode) {
readonly nodeName: '#text' = '#text';
}
export class RRComment extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRComment')} data=${JSON.stringify(this.data)}`;
}
export class RRComment extends BaseRRCommentImpl(RRNode) {
readonly nodeName: '#comment' = '#comment';
}
export class RRCDATASection extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRCDATASection')} data=${JSON.stringify(
this.data,
)}`;
}
export class RRCDATASection extends BaseRRCDATASectionImpl(RRNode) {
readonly nodeName: '#cdata-section' = '#cdata-section';
}
interface RRElementTagNameMap {
img: RRImageElement;
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
img: RRImageElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
class ClassList extends Array {
private onChange: ((newClassText: string) => void) | undefined;
constructor(
classText?: string,
onChange?: ((newClassText: string) => void) | undefined,
) {
super();
if (classText) {
const classes = classText.trim().split(/\s+/);
super.push(...classes);
}
this.onChange = onChange;
}
add = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
if (super.indexOf(className) >= 0) continue;
super.push(className);
}
this.onChange && this.onChange(super.join(' '));
};
remove = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
const index = super.indexOf(className);
if (index < 0) continue;
super.splice(index, 1);
}
this.onChange && this.onChange(super.join(' '));
};
}

View File

@@ -0,0 +1,723 @@
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import { parseCSSText, camelize, toCSSText } from './style';
export interface IRRNode {
parentElement: IRRNode | null;
parentNode: IRRNode | null;
childNodes: IRRNode[];
ownerDocument: IRRDocument;
readonly ELEMENT_NODE: number;
readonly TEXT_NODE: number;
// corresponding nodeType value of standard HTML Node
readonly nodeType: number;
readonly nodeName: string; // https://dom.spec.whatwg.org/#dom-node-nodename
readonly RRNodeType: RRNodeType;
firstChild: IRRNode | null;
lastChild: IRRNode | null;
nextSibling: IRRNode | null;
textContent: string | null;
contains(node: IRRNode): boolean;
appendChild(newChild: IRRNode): IRRNode;
insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode;
removeChild(node: IRRNode): IRRNode;
toString(): string;
}
export interface IRRDocument extends IRRNode {
documentElement: IRRElement | null;
body: IRRElement | null;
head: IRRElement | null;
implementation: IRRDocument;
firstElementChild: IRRElement | null;
readonly nodeName: '#document';
compatMode: 'BackCompat' | 'CSS1Compat';
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
): IRRDocument;
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
): IRRDocumentType;
createElement(tagName: string): IRRElement;
createElementNS(_namespaceURI: string, qualifiedName: string): IRRElement;
createTextNode(data: string): IRRText;
createComment(data: string): IRRComment;
createCDATASection(data: string): IRRCDATASection;
open(): void;
close(): void;
write(content: string): void;
}
export interface IRRElement extends IRRNode {
tagName: string;
attributes: Record<string, string>;
shadowRoot: IRRElement | null;
scrollLeft?: number;
scrollTop?: number;
id: string;
className: string;
classList: ClassList;
style: CSSStyleDeclaration;
attachShadow(init: ShadowRootInit): IRRElement;
getAttribute(name: string): string | null;
setAttribute(name: string, attribute: string): void;
setAttributeNS(
namespace: string | null,
qualifiedName: string,
value: string,
): void;
removeAttribute(name: string): void;
dispatchEvent(event: Event): boolean;
}
export interface IRRDocumentType extends IRRNode {
readonly name: string;
readonly publicId: string;
readonly systemId: string;
}
export interface IRRText extends IRRNode {
readonly nodeName: '#text';
data: string;
}
export interface IRRComment extends IRRNode {
readonly nodeName: '#comment';
data: string;
}
export interface IRRCDATASection extends IRRNode {
readonly nodeName: '#cdata-section';
data: string;
}
type ConstrainedConstructor<T = {}> = new (...args: any[]) => T;
/**
* This is designed as an abstract class so it should never be instantiated.
*/
export class BaseRRNode implements IRRNode {
public childNodes: IRRNode[] = [];
public parentElement: IRRNode | null = null;
public parentNode: IRRNode | null = null;
public textContent: string | null;
public ownerDocument: IRRDocument;
public readonly ELEMENT_NODE: number = NodeType.ELEMENT_NODE;
public readonly TEXT_NODE: number = NodeType.TEXT_NODE;
// corresponding nodeType value of standard HTML Node
public readonly nodeType: number;
public readonly nodeName: string;
public readonly RRNodeType: RRNodeType;
constructor(...args: any[]) {}
public get firstChild(): IRRNode | null {
return this.childNodes[0] || null;
}
public get lastChild(): IRRNode | null {
return this.childNodes[this.childNodes.length - 1] || null;
}
public get nextSibling(): IRRNode | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
let index = siblings.indexOf(this);
return siblings[index + 1] || null;
}
public contains(node: IRRNode) {
if (node === this) return true;
for (const child of this.childNodes) {
if (child.contains(node)) return true;
}
return false;
}
public appendChild(_newChild: IRRNode): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
public insertBefore(_newChild: IRRNode, _refChild: IRRNode | null): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method.`,
);
}
public removeChild(node: IRRNode): IRRNode {
throw new Error(
`RRDomException: Failed to execute 'removeChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
public toString(): string {
return 'RRNode';
}
}
export function BaseRRDocumentImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
return class BaseRRDocument extends RRNodeClass implements IRRDocument {
public readonly nodeType: number = NodeType.DOCUMENT_NODE;
public readonly nodeName: '#document' = '#document';
public readonly compatMode: 'BackCompat' | 'CSS1Compat' = 'CSS1Compat';
public readonly RRNodeType = RRNodeType.Document;
public textContent: string | null = null;
public get documentElement(): IRRElement | null {
return (
(this.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'HTML',
) as IRRElement) || null
);
}
public get body(): IRRElement | null {
return (
(this.documentElement?.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'BODY',
) as IRRElement) || null
);
}
public get head(): IRRElement | null {
return (
(this.documentElement?.childNodes.find(
(node) =>
node.RRNodeType === RRNodeType.Element &&
(node as IRRElement).tagName === 'HEAD',
) as IRRElement) || null
);
}
public get implementation(): IRRDocument {
return this;
}
public get firstElementChild(): IRRElement | null {
return this.documentElement;
}
public appendChild(childNode: IRRNode): IRRNode {
const nodeType = childNode.RRNodeType;
if (
nodeType === RRNodeType.Element ||
nodeType === RRNodeType.DocumentType
) {
if (this.childNodes.some((s) => s.RRNodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one ${
nodeType === RRNodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
this.childNodes.push(childNode);
return childNode;
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
const nodeType = newChild.RRNodeType;
if (
nodeType === RRNodeType.Element ||
nodeType === RRNodeType.DocumentType
) {
if (this.childNodes.some((s) => s.RRNodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': Only one ${
nodeType === RRNodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
return newChild;
}
public removeChild(node: IRRNode) {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRDocument': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
}
public open() {
this.childNodes = [];
}
public close() {}
/**
* Adhoc implementation for setting xhtml namespace in rebuilt.ts (rrweb-snapshot).
* There are two lines used this function:
* 1. doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">')
* 2. doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">')
*/
public write(content: string) {
let publicId;
if (
content ===
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">'
)
publicId = '-//W3C//DTD XHTML 1.0 Transitional//EN';
else if (
content ===
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">'
)
publicId = '-//W3C//DTD HTML 4.0 Transitional//EN';
if (publicId) {
const doctype = this.createDocumentType('html', publicId, '');
this.open();
this.appendChild(doctype);
}
}
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
): IRRDocument {
return new BaseRRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
): IRRDocumentType {
const doctype = new (BaseRRDocumentTypeImpl(BaseRRNode))(
qualifiedName,
publicId,
systemId,
);
doctype.ownerDocument = this;
return doctype;
}
createElement(tagName: string): IRRElement {
const element = new (BaseRRElementImpl(BaseRRNode))(tagName);
element.ownerDocument = this;
return element;
}
createElementNS(_namespaceURI: string, qualifiedName: string): IRRElement {
return this.createElement(qualifiedName);
}
createTextNode(data: string): IRRText {
const text = new (BaseRRTextImpl(BaseRRNode))(data);
text.ownerDocument = this;
return text;
}
createComment(data: string): IRRComment {
const comment = new (BaseRRCommentImpl(BaseRRNode))(data);
comment.ownerDocument = this;
return comment;
}
createCDATASection(data: string): IRRCDATASection {
const CDATASection = new (BaseRRCDATASectionImpl(BaseRRNode))(data);
CDATASection.ownerDocument = this;
return CDATASection;
}
toString() {
return 'RRDocument';
}
};
}
export function BaseRRDocumentTypeImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRDocumentType
extends RRNodeClass
implements IRRDocumentType {
public readonly nodeType: number = NodeType.DOCUMENT_TYPE_NODE;
public readonly RRNodeType = RRNodeType.DocumentType;
public readonly nodeName: string;
public readonly name: string;
public readonly publicId: string;
public readonly systemId: string;
public textContent: string | null = null;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
this.name = qualifiedName;
this.publicId = publicId;
this.systemId = systemId;
this.nodeName = qualifiedName;
}
toString() {
return 'RRDocumentType';
}
};
}
export function BaseRRElementImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRElement extends RRNodeClass implements IRRElement {
public readonly nodeType: number = NodeType.ELEMENT_NODE;
public readonly RRNodeType = RRNodeType.Element;
public readonly nodeName: string;
public tagName: string;
public attributes: Record<string, string> = {};
public shadowRoot: IRRElement | null = null;
public scrollLeft?: number;
public scrollTop?: number;
constructor(tagName: string) {
super();
this.tagName = tagName.toUpperCase();
this.nodeName = tagName.toUpperCase();
}
public get textContent(): string {
let result = '';
this.childNodes.forEach((node) => (result += node.textContent));
return result;
}
public set textContent(textContent: string) {
this.childNodes = [this.ownerDocument.createTextNode(textContent)];
}
public get classList(): ClassList {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
},
);
}
public get id() {
return this.attributes.id || '';
}
public get className() {
return this.attributes.class || '';
}
public get style() {
const style = (this.attributes.style
? parseCSSText(this.attributes.style as string)
: {}) as CSSStyleDeclaration;
const hyphenateRE = /\B([A-Z])/g;
style.setProperty = (
name: string,
value: string | null,
priority?: string,
) => {
if (hyphenateRE.test(name)) return;
const normalizedName = camelize(name);
if (!value) delete style[normalizedName];
else style[normalizedName] = value;
if (priority === 'important') style[normalizedName] += ' !important';
this.attributes.style = toCSSText(style);
};
style.removeProperty = (name: string) => {
if (hyphenateRE.test(name)) return '';
const normalizedName = camelize(name);
const value = style[normalizedName] || '';
delete style[normalizedName];
this.attributes.style = toCSSText(style);
return value;
};
return style;
}
public getAttribute(name: string) {
return this.attributes[name] || null;
}
public setAttribute(name: string, attribute: string) {
this.attributes[name] = attribute;
}
public setAttributeNS(
_namespace: string | null,
qualifiedName: string,
value: string,
): void {
this.setAttribute(qualifiedName, value);
}
public removeAttribute(name: string) {
delete this.attributes[name];
}
public appendChild(newChild: IRRNode): IRRNode {
this.childNodes.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
return newChild;
}
public insertBefore(newChild: IRRNode, refChild: IRRNode | null): IRRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.childNodes.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.childNodes.splice(childIndex, 0, newChild);
newChild.parentElement = this;
newChild.parentNode = this;
return newChild;
}
public removeChild(node: IRRNode): IRRNode {
const indexOfChild = this.childNodes.indexOf(node);
if (indexOfChild === -1)
throw new Error(
"Failed to execute 'removeChild' on 'RRElement': The RRNode to be removed is not a child of this RRNode.",
);
this.childNodes.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
return node;
}
public attachShadow(_init: ShadowRootInit): IRRElement {
const shadowRoot = this.ownerDocument.createElement('SHADOWROOT');
this.shadowRoot = shadowRoot;
return shadowRoot;
}
public dispatchEvent(_event: Event) {
return true;
}
toString() {
let attributeString = '';
for (let attribute in this.attributes) {
attributeString += `${attribute}="${this.attributes[attribute]}" `;
}
return `${this.tagName} ${attributeString}`;
}
};
}
export function BaseRRMediaElementImpl<
RRElement extends ConstrainedConstructor<IRRElement>
>(RRElementClass: RRElement) {
return class BaseRRMediaElement extends RRElementClass {
public currentTime?: number;
public volume?: number;
public paused?: boolean;
public muted?: boolean;
attachShadow(_init: ShadowRootInit): IRRElement {
throw new Error(
`RRDomException: Failed to execute 'attachShadow' on 'RRElement': This RRElement does not support attachShadow`,
);
}
public play() {
this.paused = false;
}
public pause() {
this.paused = true;
}
};
}
export function BaseRRTextImpl<RRNode extends ConstrainedConstructor<IRRNode>>(
RRNodeClass: RRNode,
) {
// @ts-ignore
return class BaseRRText extends RRNodeClass implements IRRText {
public readonly nodeType: number = NodeType.TEXT_NODE;
public readonly nodeName: '#text' = '#text';
public readonly RRNodeType = RRNodeType.Text;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRText text=${JSON.stringify(this.data)}`;
}
};
}
export function BaseRRCommentImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRComment extends RRNodeClass implements IRRComment {
public readonly nodeType: number = NodeType.COMMENT_NODE;
public readonly nodeName: '#comment' = '#comment';
public readonly RRNodeType = RRNodeType.Comment;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRComment text=${JSON.stringify(this.data)}`;
}
};
}
export function BaseRRCDATASectionImpl<
RRNode extends ConstrainedConstructor<IRRNode>
>(RRNodeClass: RRNode) {
// @ts-ignore
return class BaseRRCDATASection
extends RRNodeClass
implements IRRCDATASection {
public readonly nodeName: '#cdata-section' = '#cdata-section';
public readonly nodeType: number = NodeType.CDATA_SECTION_NODE;
public readonly RRNodeType = RRNodeType.CDATA;
public data: string;
constructor(data: string) {
super();
this.data = data;
}
public get textContent(): string {
return this.data;
}
public set textContent(textContent: string) {
this.data = textContent;
}
toString() {
return `RRCDATASection data=${JSON.stringify(this.data)}`;
}
};
}
export class ClassList {
private onChange: ((newClassText: string) => void) | undefined;
classes: string[] = [];
constructor(
classText?: string,
onChange?: ((newClassText: string) => void) | undefined,
) {
if (classText) {
const classes = classText.trim().split(/\s+/);
this.classes.push(...classes);
}
this.onChange = onChange;
}
add = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
if (this.classes.indexOf(className) >= 0) continue;
this.classes.push(className);
}
this.onChange && this.onChange(this.classes.join(' '));
};
remove = (...classNames: string[]) => {
this.classes = this.classes.filter(
(item) => classNames.indexOf(item) === -1,
);
this.onChange && this.onChange(this.classes.join(' '));
};
}
export type CSSStyleDeclaration = Record<string, string> & {
setProperty: (
name: string,
value: string | null,
priority?: string | null,
) => void;
removeProperty: (name: string) => string;
};
// Enumerate nodeType value of standard HTML Node.
export enum NodeType {
PLACEHOLDER, // This isn't a node type. Enum type value starts from zero but NodeType value starts from 1.
ELEMENT_NODE,
ATTRIBUTE_NODE,
TEXT_NODE,
CDATA_SECTION_NODE,
ENTITY_REFERENCE_NODE,
ENTITY_NODE,
PROCESSING_INSTRUCTION_NODE,
COMMENT_NODE,
DOCUMENT_NODE,
DOCUMENT_TYPE_NODE,
DOCUMENT_FRAGMENT_NODE,
}

View File

@@ -2,6 +2,8 @@ import { RRDocument, RRNode } from './document-nodejs';
/**
* Polyfill the performance for nodejs.
* Note: The performance api is available through the global object from nodejs v16.0.0.
* https://github.com/nodejs/node/pull/37970
*/
export function polyfillPerformance() {
if (typeof window !== 'undefined' || 'performance' in global) return;
@@ -80,8 +82,8 @@ export function polyfillDocument() {
const rrdom = new RRDocument();
(() => {
rrdom.appendChild(rrdom.createElement('html'));
rrdom.documentElement.appendChild(rrdom.createElement('head'));
rrdom.documentElement.appendChild(rrdom.createElement('body'));
rrdom.documentElement!.appendChild(rrdom.createElement('head'));
rrdom.documentElement!.appendChild(rrdom.createElement('body'));
})();
global.document = (rrdom as unknown) as Document;
}

View File

@@ -1,40 +1,45 @@
export function parseCSSText(cssText: string): Record<string, string> {
const res: Record<string, string> = {};
const listDelimiter = /;(?![^(]*\))/g;
const propertyDelimiter = /:(.+)/;
cssText.split(listDelimiter).forEach(function (item) {
if (item) {
const tmp = item.split(propertyDelimiter);
tmp.length > 1 && (res[camelize(tmp[0].trim())] = tmp[1].trim());
}
});
return res;
const res: Record<string, string> = {};
const listDelimiter = /;(?![^(]*\))/g;
const propertyDelimiter = /:(.+)/;
const comment = /\/\*.*?\*\//g;
cssText
.replace(comment, '')
.split(listDelimiter)
.forEach(function (item) {
if (item) {
const tmp = item.split(propertyDelimiter);
tmp.length > 1 && (res[camelize(tmp[0].trim())] = tmp[1].trim());
}
});
return res;
}
export function toCSSText(style: Record<string, string>): string {
const properties = [];
for (let name in style) {
const value = style[name];
if (typeof value !== 'string') continue;
const normalizedName = hyphenate(name);
properties.push(`${normalizedName}: ${value};`);
}
export function toCSSText(style: Record<string, string>): string {
const properties = [];
for (let name in style) {
const value = style[name];
if (typeof value !== 'string') continue;
const normalizedName = hyphenate(name);
properties.push(`${normalizedName}:${value};`);
}
return properties.join(' ');
}
/**
* Camelize a hyphen-delimited string.
*/
const camelizeRE = /-(\w)/g;
export const camelize = (str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''));
};
/**
* Hyphenate a camelCase string.
*/
const hyphenateRE = /\B([A-Z])/g;
export const hyphenate = (str: string): string => {
return str.replace(hyphenateRE, '-$1').toLowerCase();
};
return properties.join(' ');
}
/**
* Camelize a hyphen-delimited string.
*/
const camelizeRE = /-([a-z])/g;
const CUSTOM_PROPERTY_REGEX = /^--[a-zA-Z0-9-]+$/;
export const camelize = (str: string): string => {
if (CUSTOM_PROPERTY_REGEX.test(str)) return str;
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''));
};
/**
* Hyphenate a camelCase string.
*/
const hyphenateRE = /\B([A-Z])/g;
export const hyphenate = (str: string): string => {
return str.replace(hyphenateRE, '-$1').toLowerCase();
};

View File

@@ -0,0 +1,450 @@
import {
NodeType as RRNodeType,
createMirror as createNodeMirror,
} from 'rrweb-snapshot';
import type {
Mirror as NodeMirror,
IMirror,
serializedNodeWithId,
} from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
} from 'rrweb/src/types';
import {
BaseRRNode as RRNode,
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRTextImpl,
IRRDocument,
IRRElement,
IRRNode,
NodeType,
IRRDocumentType,
IRRText,
IRRComment,
} from './document';
import type { VirtualStyleRules } from './diff';
export class RRDocument extends BaseRRDocumentImpl(RRNode) {
// In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules.
// These unserialized nodes may interfere the execution of the diff algorithm.
// The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes.
private _unserializedId = -1;
/**
* Every time the id is used, it will minus 1 automatically to avoid collisions.
*/
public get unserializedId(): number {
return this._unserializedId--;
}
public mirror: Mirror = createMirror();
public scrollData: scrollData | null = null;
constructor(mirror?: Mirror) {
super();
if (mirror) {
this.mirror = mirror;
}
}
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
) {
return new RRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
) {
const documentTypeNode = new RRDocumentType(
qualifiedName,
publicId,
systemId,
);
documentTypeNode.ownerDocument = this;
return documentTypeNode;
}
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
): RRElementType<K>;
createElement(tagName: string): RRElement;
createElement(tagName: string) {
const upperTagName = tagName.toUpperCase();
let element;
switch (upperTagName) {
case 'AUDIO':
case 'VIDEO':
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIFrameElement(upperTagName, this.mirror);
break;
case 'CANVAS':
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
break;
}
element.ownerDocument = this;
return element;
}
createComment(data: string) {
const commentNode = new RRComment(data);
commentNode.ownerDocument = this;
return commentNode;
}
createCDATASection(data: string) {
const sectionNode = new RRCDATASection(data);
sectionNode.ownerDocument = this;
return sectionNode;
}
createTextNode(data: string) {
const textNode = new RRText(data);
textNode.ownerDocument = this;
return textNode;
}
destroyTree() {
this.childNodes = [];
this.mirror.reset();
}
open() {
super.open();
this._unserializedId = -1;
}
}
export const RRDocumentType = BaseRRDocumentTypeImpl(RRNode);
export class RRElement extends BaseRRElementImpl(RRNode) {
inputData: inputData | null = null;
scrollData: scrollData | null = null;
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement implements IRRElement {
public canvasMutations: {
event: canvasEventWithTime;
mutation: canvasMutationData;
}[] = [];
/**
* This is a dummy implementation to distinguish RRCanvasElement from real HTMLCanvasElement.
*/
getContext(): RenderingContext | null {
return null;
}
}
export class RRStyleElement extends RRElement {
public rules: VirtualStyleRules = [];
}
export class RRIFrameElement extends RRElement {
contentDocument: RRDocument = new RRDocument();
constructor(upperTagName: string, mirror: Mirror) {
super(upperTagName);
this.contentDocument.mirror = mirror;
}
}
export const RRText = BaseRRTextImpl(RRNode);
export type RRText = typeof RRText;
export const RRComment = BaseRRCommentImpl(RRNode);
export type RRComment = typeof RRComment;
export const RRCDATASection = BaseRRCDATASectionImpl(RRNode);
export type RRCDATASection = typeof RRCDATASection;
interface RRElementTagNameMap {
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
function getValidTagName(element: HTMLElement): string {
// https://github.com/rrweb-io/rrweb-snapshot/issues/56
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase();
}
/**
* Build a RRNode from a real Node.
* @param node the real Node
* @param rrdom the RRDocument
* @param domMirror the NodeMirror that records the real document tree
* @returns the built RRNode
*/
export function buildFromNode(
node: Node,
rrdom: IRRDocument,
domMirror: NodeMirror,
parentRRNode?: IRRNode | null,
): IRRNode | null {
let rrNode: IRRNode;
switch (node.nodeType) {
case NodeType.DOCUMENT_NODE:
if (parentRRNode && parentRRNode.nodeName === 'IFRAME')
rrNode = (parentRRNode as RRIFrameElement).contentDocument;
else {
rrNode = rrdom;
(rrNode as IRRDocument).compatMode = (node as Document).compatMode as
| 'BackCompat'
| 'CSS1Compat';
}
break;
case NodeType.DOCUMENT_TYPE_NODE:
const documentType = (node as Node) as DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case NodeType.ELEMENT_NODE:
const elementNode = (node as Node) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = rrdom.createElement(tagName);
const rrElement = rrNode as IRRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
elementNode.scrollLeft && (rrElement.scrollLeft = elementNode.scrollLeft);
elementNode.scrollTop && (rrElement.scrollTop = elementNode.scrollTop);
/**
* We don't have to record special values of input elements at the beginning.
* Because if these values are changed later, the mutation will be applied through the batched input events on its RRElement after the diff algorithm is executed.
*/
break;
case NodeType.TEXT_NODE:
rrNode = rrdom.createTextNode(((node as Node) as Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection(((node as Node) as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment(
((node as Node) as Comment).textContent || '',
);
break;
// if node is a shadow root
case NodeType.DOCUMENT_FRAGMENT_NODE:
rrNode = (parentRRNode as IRRElement).attachShadow({ mode: 'open' });
break;
default:
return null;
}
let sn: serializedNodeWithId | null = domMirror.getMeta(node);
if (rrdom instanceof RRDocument) {
if (!sn) {
sn = getDefaultSN(rrNode, rrdom.unserializedId);
domMirror.add(node, sn);
}
rrdom.mirror.add(rrNode, { ...sn });
}
return rrNode;
}
/**
* Build a RRDocument from a real document tree.
* @param dom the real document tree
* @param domMirror the NodeMirror that records the real document tree
* @param rrdom the rrdom object to be constructed
* @returns the build rrdom
*/
export function buildFromDom(
dom: Document,
domMirror: NodeMirror = createNodeMirror(),
rrdom: IRRDocument = new RRDocument(),
) {
function walk(node: Node, parentRRNode: IRRNode | null) {
const rrNode = buildFromNode(node, rrdom, domMirror, parentRRNode);
if (rrNode === null) return;
if (
// if the parentRRNode isn't a RRIFrameElement
parentRRNode?.nodeName !== 'IFRAME' &&
// if node isn't a shadow root
node.nodeType !== NodeType.DOCUMENT_FRAGMENT_NODE
) {
parentRRNode?.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement = parentRRNode as RRElement;
}
if (node.nodeName === 'IFRAME') {
walk((node as HTMLIFrameElement).contentDocument!, rrNode);
} else if (
node.nodeType === NodeType.DOCUMENT_NODE ||
node.nodeType === NodeType.ELEMENT_NODE ||
node.nodeType === NodeType.DOCUMENT_FRAGMENT_NODE
) {
// if the node is a shadow dom
if (
node.nodeType === NodeType.ELEMENT_NODE &&
((node as Node) as HTMLElement).shadowRoot
)
walk(((node as Node) as HTMLElement).shadowRoot!, rrNode);
node.childNodes.forEach((childNode) => walk(childNode, rrNode));
}
}
walk(dom, null);
return rrdom;
}
export function createMirror(): Mirror {
return new Mirror();
}
// based on Mirror from rrweb-snapshots
export class Mirror implements IMirror<RRNode> {
private idNodeMap: Map<number, RRNode> = new Map();
private nodeMetaMap: WeakMap<RRNode, serializedNodeWithId> = new WeakMap();
getId(n: RRNode | undefined | null): number {
if (!n) return -1;
const id = this.getMeta(n)?.id;
// if n is not a serialized Node, use -1 as its id.
return id ?? -1;
}
getNode(id: number): RRNode | null {
return this.idNodeMap.get(id) || null;
}
getIds(): number[] {
return Array.from(this.idNodeMap.keys());
}
getMeta(n: RRNode): serializedNodeWithId | null {
return this.nodeMetaMap.get(n) || null;
}
// removes the node from idNodeMap
// doesn't remove the node from nodeMetaMap
removeNodeFromMap(n: RRNode) {
const id = this.getId(n);
this.idNodeMap.delete(id);
if (n.childNodes) {
n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode));
}
}
has(id: number): boolean {
return this.idNodeMap.has(id);
}
hasNode(node: RRNode): boolean {
return this.nodeMetaMap.has(node);
}
add(n: RRNode, meta: serializedNodeWithId) {
const id = meta.id;
this.idNodeMap.set(id, n);
this.nodeMetaMap.set(n, meta);
}
replace(id: number, n: RRNode) {
this.idNodeMap.set(id, n);
}
reset() {
this.idNodeMap = new Map();
this.nodeMetaMap = new WeakMap();
}
}
/**
* Get a default serializedNodeWithId value for a RRNode.
* @param id the serialized id to assign
*/
export function getDefaultSN(node: IRRNode, id: number): serializedNodeWithId {
switch (node.RRNodeType) {
case RRNodeType.Document:
return {
id,
type: node.RRNodeType,
childNodes: [],
};
case RRNodeType.DocumentType:
const doctype = node as IRRDocumentType;
return {
id,
type: node.RRNodeType,
name: doctype.name,
publicId: doctype.publicId,
systemId: doctype.systemId,
};
case RRNodeType.Element:
return {
id,
type: node.RRNodeType,
tagName: (node as IRRElement).tagName.toLowerCase(), // In rrweb data, all tagNames are lowercase.
attributes: {},
childNodes: [],
};
case RRNodeType.Text:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRText).textContent || '',
};
case RRNodeType.Comment:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRComment).textContent || '',
};
case RRNodeType.CDATA:
return {
id,
type: node.RRNodeType,
textContent: '',
};
}
}
export { RRNode };
export {
diff,
createOrGetNode,
StyleRuleType,
VirtualStyleRules,
ReplayerHandler,
} from './diff';