* 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:
513
packages/rrdom/src/diff.ts
Normal file
513
packages/rrdom/src/diff.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(' '));
|
||||
};
|
||||
}
|
||||
|
||||
723
packages/rrdom/src/document.ts
Normal file
723
packages/rrdom/src/document.ts
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
450
packages/rrdom/src/virtual-dom.ts
Normal file
450
packages/rrdom/src/virtual-dom.ts
Normal 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';
|
||||
Reference in New Issue
Block a user