* inline stylesheets when loaded * set empty link elements to loaded by default * Clean up stylesheet manager * Remove attribute mutation code * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/scripts/repl.js * Update packages/rrweb/test/record.test.ts * Update packages/rrweb/src/record/index.ts * Add todo * Move require out of time sensitive assert * Add waitForRAF, its more reliable than waitForTimeout * Remove flaky tests * Add recording stylesheets in iframes * Remove variability from flaky test * Make test more robust * Fix naming * Add test cases for inlineImages * Add test cases for inlineImages * Record iframe mutations cross page * Test: should record images inside iframe with blob url after iframe was reloaded * Handle negative ids in rrdom correctly When iframes get inserted they create untracked elements, both on the dom and rrdom side. Because they are untracked they generate negative numbers when fetching the id from mirror. This creates a problem when comparing and fetching ids across mirrors. This commit tries to get away from using negative ids as much as possible in rrdom's comparisons * Update packages/rrdom/src/diff.ts Co-authored-by: Yun Feng <yun.feng@anu.edu.au> * Start unserialized nodes at -2 This way we don't accidentally think of them as mirror misses * Set unserialized id starting number at -2 * Remove duplication Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
1344 lines
36 KiB
TypeScript
1344 lines
36 KiB
TypeScript
import {
|
|
serializedNode,
|
|
serializedNodeWithId,
|
|
NodeType,
|
|
attributes,
|
|
MaskInputOptions,
|
|
SlimDOMOptions,
|
|
DataURLOptions,
|
|
MaskTextFn,
|
|
MaskInputFn,
|
|
KeepIframeSrcFn,
|
|
ICanvas,
|
|
serializedElementNodeWithId,
|
|
} from './types';
|
|
import {
|
|
Mirror,
|
|
is2DCanvasBlank,
|
|
isElement,
|
|
isShadowRoot,
|
|
maskInputValue,
|
|
isNativeShadowDom,
|
|
} from './utils';
|
|
|
|
let _id = 1;
|
|
const tagNameRegex = new RegExp('[^a-z0-9-_:]');
|
|
|
|
export const IGNORED_NODE = -2;
|
|
|
|
function genId(): number {
|
|
return _id++;
|
|
}
|
|
|
|
function getValidTagName(element: HTMLElement): string {
|
|
if (element instanceof HTMLFormElement) {
|
|
return 'form';
|
|
}
|
|
|
|
const processedTagName = element.tagName.toLowerCase().trim();
|
|
|
|
if (tagNameRegex.test(processedTagName)) {
|
|
// if the tag name is odd and we cannot extract
|
|
// anything from the string, then we return a
|
|
// generic div
|
|
return 'div';
|
|
}
|
|
|
|
return processedTagName;
|
|
}
|
|
|
|
function getCssRulesString(s: CSSStyleSheet): string | null {
|
|
try {
|
|
const rules = s.rules || s.cssRules;
|
|
return rules ? Array.from(rules).map(getCssRuleString).join('') : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getCssRuleString(rule: CSSRule): string {
|
|
let cssStringified = rule.cssText;
|
|
if (isCSSImportRule(rule)) {
|
|
try {
|
|
cssStringified = getCssRulesString(rule.styleSheet) || cssStringified;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return cssStringified;
|
|
}
|
|
|
|
function isCSSImportRule(rule: CSSRule): rule is CSSImportRule {
|
|
return 'styleSheet' in rule;
|
|
}
|
|
|
|
function stringifyStyleSheet(sheet: CSSStyleSheet): string {
|
|
return sheet.cssRules
|
|
? Array.from(sheet.cssRules)
|
|
.map((rule) => rule.cssText || '')
|
|
.join('')
|
|
: '';
|
|
}
|
|
|
|
function extractOrigin(url: string): string {
|
|
let origin = '';
|
|
if (url.indexOf('//') > -1) {
|
|
origin = url.split('/').slice(0, 3).join('/');
|
|
} else {
|
|
origin = url.split('/')[0];
|
|
}
|
|
origin = origin.split('?')[0];
|
|
return origin;
|
|
}
|
|
|
|
let canvasService: HTMLCanvasElement | null;
|
|
let canvasCtx: CanvasRenderingContext2D | null;
|
|
|
|
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
|
|
const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/;
|
|
const DATA_URI = /^(data:)([^,]*),(.*)/i;
|
|
export function absoluteToStylesheet(
|
|
cssText: string | null,
|
|
href: string,
|
|
): string {
|
|
return (cssText || '').replace(
|
|
URL_IN_CSS_REF,
|
|
(
|
|
origin: string,
|
|
quote1: string,
|
|
path1: string,
|
|
quote2: string,
|
|
path2: string,
|
|
path3: string,
|
|
) => {
|
|
const filePath = path1 || path2 || path3;
|
|
const maybeQuote = quote1 || quote2 || '';
|
|
if (!filePath) {
|
|
return origin;
|
|
}
|
|
if (!RELATIVE_PATH.test(filePath)) {
|
|
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
|
}
|
|
if (DATA_URI.test(filePath)) {
|
|
return `url(${maybeQuote}${filePath}${maybeQuote})`;
|
|
}
|
|
if (filePath[0] === '/') {
|
|
return `url(${maybeQuote}${
|
|
extractOrigin(href) + filePath
|
|
}${maybeQuote})`;
|
|
}
|
|
const stack = href.split('/');
|
|
const parts = filePath.split('/');
|
|
stack.pop();
|
|
for (const part of parts) {
|
|
if (part === '.') {
|
|
continue;
|
|
} else if (part === '..') {
|
|
stack.pop();
|
|
} else {
|
|
stack.push(part);
|
|
}
|
|
}
|
|
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
|
|
},
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space
|
|
// eslint-disable-next-line no-control-regex
|
|
const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/;
|
|
function getAbsoluteSrcsetString(doc: Document, attributeValue: string) {
|
|
/*
|
|
run absoluteToDoc over every url in the srcset
|
|
|
|
this is adapted from https://github.com/albell/parse-srcset/
|
|
without the parsing of the descriptors (we return these as-is)
|
|
parce-srcset is in turn based on
|
|
https://html.spec.whatwg.org/multipage/embedded-content.html#parse-a-srcset-attribute
|
|
*/
|
|
if (attributeValue.trim() === '') {
|
|
return attributeValue;
|
|
}
|
|
|
|
let pos = 0;
|
|
|
|
function collectCharacters(regEx: RegExp) {
|
|
let chars: string;
|
|
const match = regEx.exec(attributeValue.substring(pos));
|
|
if (match) {
|
|
chars = match[0];
|
|
pos += chars.length;
|
|
return chars;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const output = [];
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
collectCharacters(SRCSET_COMMAS_OR_SPACES);
|
|
if (pos >= attributeValue.length) {
|
|
break;
|
|
}
|
|
// don't split on commas within urls
|
|
let url = collectCharacters(SRCSET_NOT_SPACES);
|
|
if (url.slice(-1) === ',') {
|
|
// aside: according to spec more than one comma at the end is a parse error, but we ignore that
|
|
url = absoluteToDoc(doc, url.substring(0, url.length - 1));
|
|
// the trailing comma splits the srcset, so the interpretion is that
|
|
// another url will follow, and the descriptor is empty
|
|
output.push(url);
|
|
} else {
|
|
let descriptorsStr = '';
|
|
url = absoluteToDoc(doc, url);
|
|
let inParens = false;
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const c = attributeValue.charAt(pos);
|
|
if (c === '') {
|
|
output.push((url + descriptorsStr).trim());
|
|
break;
|
|
} else if (!inParens) {
|
|
if (c === ',') {
|
|
pos += 1;
|
|
output.push((url + descriptorsStr).trim());
|
|
break; // parse the next url
|
|
} else if (c === '(') {
|
|
inParens = true;
|
|
}
|
|
} else {
|
|
// in parenthesis; ignore commas
|
|
// (parenthesis may be supported by future additions to spec)
|
|
if (c === ')') {
|
|
inParens = false;
|
|
}
|
|
}
|
|
descriptorsStr += c;
|
|
pos += 1;
|
|
}
|
|
}
|
|
}
|
|
return output.join(', ');
|
|
}
|
|
|
|
export function absoluteToDoc(doc: Document, attributeValue: string): string {
|
|
if (!attributeValue || attributeValue.trim() === '') {
|
|
return attributeValue;
|
|
}
|
|
const a: HTMLAnchorElement = doc.createElement('a');
|
|
a.href = attributeValue;
|
|
return a.href;
|
|
}
|
|
|
|
function isSVGElement(el: Element): boolean {
|
|
return Boolean(el.tagName === 'svg' || (el as SVGElement).ownerSVGElement);
|
|
}
|
|
|
|
function getHref() {
|
|
// return a href without hash
|
|
const a = document.createElement('a');
|
|
a.href = '';
|
|
return a.href;
|
|
}
|
|
|
|
export function transformAttribute(
|
|
doc: Document,
|
|
tagName: string,
|
|
name: string,
|
|
value: string,
|
|
): string {
|
|
// relative path in attribute
|
|
if (
|
|
name === 'src' ||
|
|
(name === 'href' && value && !(tagName === 'use' && value[0] === '#'))
|
|
) {
|
|
// href starts with a # is an id pointer for svg
|
|
return absoluteToDoc(doc, value);
|
|
} else if (name === 'xlink:href' && value && value[0] !== '#') {
|
|
// xlink:href starts with # is an id pointer
|
|
return absoluteToDoc(doc, value);
|
|
} else if (
|
|
name === 'background' &&
|
|
value &&
|
|
(tagName === 'table' || tagName === 'td' || tagName === 'th')
|
|
) {
|
|
return absoluteToDoc(doc, value);
|
|
} else if (name === 'srcset' && value) {
|
|
return getAbsoluteSrcsetString(doc, value);
|
|
} else if (name === 'style' && value) {
|
|
return absoluteToStylesheet(value, getHref());
|
|
} else if (tagName === 'object' && name === 'data' && value) {
|
|
return absoluteToDoc(doc, value);
|
|
} else {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export function _isBlockedElement(
|
|
element: HTMLElement,
|
|
blockClass: string | RegExp,
|
|
blockSelector: string | null,
|
|
): boolean {
|
|
if (typeof blockClass === 'string') {
|
|
if (element.classList.contains(blockClass)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
for (let eIndex = element.classList.length; eIndex--; ) {
|
|
const className = element.classList[eIndex];
|
|
if (blockClass.test(className)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if (blockSelector) {
|
|
return element.matches(blockSelector);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function classMatchesRegex(
|
|
node: Node | null,
|
|
regex: RegExp,
|
|
checkAncestors: boolean,
|
|
): boolean {
|
|
if (!node) return false;
|
|
if (node.nodeType !== node.ELEMENT_NODE) {
|
|
if (!checkAncestors) return false;
|
|
return classMatchesRegex(node.parentNode, regex, checkAncestors);
|
|
}
|
|
|
|
for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) {
|
|
const className = (node as HTMLElement).classList[eIndex];
|
|
if (regex.test(className)) {
|
|
return true;
|
|
}
|
|
}
|
|
if (!checkAncestors) return false;
|
|
return classMatchesRegex(node.parentNode, regex, checkAncestors);
|
|
}
|
|
|
|
export function needMaskingText(
|
|
node: Node,
|
|
maskTextClass: string | RegExp,
|
|
maskTextSelector: string | null,
|
|
): boolean {
|
|
const el: HTMLElement | null =
|
|
node.nodeType === node.ELEMENT_NODE
|
|
? (node as HTMLElement)
|
|
: node.parentElement;
|
|
if (el === null) return false;
|
|
|
|
if (typeof maskTextClass === 'string') {
|
|
if (el.classList.contains(maskTextClass)) return true;
|
|
if (el.closest(`.${maskTextClass}`)) return true;
|
|
} else {
|
|
if (classMatchesRegex(el, maskTextClass, true)) return true;
|
|
}
|
|
|
|
if (maskTextSelector) {
|
|
if (el.matches(maskTextSelector)) return true;
|
|
if (el.closest(maskTextSelector)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// https://stackoverflow.com/a/36155560
|
|
function onceIframeLoaded(
|
|
iframeEl: HTMLIFrameElement,
|
|
listener: () => unknown,
|
|
iframeLoadTimeout: number,
|
|
) {
|
|
const win = iframeEl.contentWindow;
|
|
if (!win) {
|
|
return;
|
|
}
|
|
// document is loading
|
|
let fired = false;
|
|
|
|
let readyState: DocumentReadyState;
|
|
try {
|
|
readyState = win.document.readyState;
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
if (readyState !== 'complete') {
|
|
const timer = setTimeout(() => {
|
|
if (!fired) {
|
|
listener();
|
|
fired = true;
|
|
}
|
|
}, iframeLoadTimeout);
|
|
iframeEl.addEventListener('load', () => {
|
|
clearTimeout(timer);
|
|
fired = true;
|
|
listener();
|
|
});
|
|
return;
|
|
}
|
|
// check blank frame for Chrome
|
|
const blankUrl = 'about:blank';
|
|
if (
|
|
win.location.href !== blankUrl ||
|
|
iframeEl.src === blankUrl ||
|
|
iframeEl.src === ''
|
|
) {
|
|
// iframe was already loaded, make sure we wait to trigger the listener
|
|
// till _after_ the mutation that found this iframe has had time to process
|
|
setTimeout(listener, 0);
|
|
|
|
return iframeEl.addEventListener('load', listener); // keep listing for future loads
|
|
}
|
|
// use default listener
|
|
iframeEl.addEventListener('load', listener);
|
|
}
|
|
|
|
function isStylesheetLoaded(link: HTMLLinkElement) {
|
|
if (!link.getAttribute('href')) return true; // nothing to load
|
|
return link.sheet !== null;
|
|
}
|
|
|
|
function onceStylesheetLoaded(
|
|
link: HTMLLinkElement,
|
|
listener: () => unknown,
|
|
styleSheetLoadTimeout: number,
|
|
) {
|
|
let fired = false;
|
|
let styleSheetLoaded: StyleSheet | null;
|
|
try {
|
|
styleSheetLoaded = link.sheet;
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
|
|
if (styleSheetLoaded) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (!fired) {
|
|
listener();
|
|
fired = true;
|
|
}
|
|
}, styleSheetLoadTimeout);
|
|
|
|
link.addEventListener('load', () => {
|
|
clearTimeout(timer);
|
|
fired = true;
|
|
listener();
|
|
});
|
|
}
|
|
|
|
function serializeNode(
|
|
n: Node,
|
|
options: {
|
|
doc: Document;
|
|
mirror: Mirror;
|
|
blockClass: string | RegExp;
|
|
blockSelector: string | null;
|
|
maskTextClass: string | RegExp;
|
|
maskTextSelector: string | null;
|
|
inlineStylesheet: boolean;
|
|
maskInputOptions: MaskInputOptions;
|
|
maskTextFn: MaskTextFn | undefined;
|
|
maskInputFn: MaskInputFn | undefined;
|
|
dataURLOptions?: DataURLOptions;
|
|
inlineImages: boolean;
|
|
recordCanvas: boolean;
|
|
keepIframeSrcFn: KeepIframeSrcFn;
|
|
/**
|
|
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
|
*/
|
|
newlyAddedElement?: boolean;
|
|
},
|
|
): serializedNode | false {
|
|
const {
|
|
doc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
inlineStylesheet,
|
|
maskInputOptions = {},
|
|
maskTextFn,
|
|
maskInputFn,
|
|
dataURLOptions = {},
|
|
inlineImages,
|
|
recordCanvas,
|
|
keepIframeSrcFn,
|
|
newlyAddedElement = false,
|
|
} = options;
|
|
// Only record root id when document object is not the base document
|
|
const rootId = getRootId(doc, mirror);
|
|
switch (n.nodeType) {
|
|
case n.DOCUMENT_NODE:
|
|
if ((n as Document).compatMode !== 'CSS1Compat') {
|
|
return {
|
|
type: NodeType.Document,
|
|
childNodes: [],
|
|
compatMode: (n as Document).compatMode, // probably "BackCompat"
|
|
rootId,
|
|
};
|
|
} else {
|
|
return {
|
|
type: NodeType.Document,
|
|
childNodes: [],
|
|
rootId,
|
|
};
|
|
}
|
|
case n.DOCUMENT_TYPE_NODE:
|
|
return {
|
|
type: NodeType.DocumentType,
|
|
name: (n as DocumentType).name,
|
|
publicId: (n as DocumentType).publicId,
|
|
systemId: (n as DocumentType).systemId,
|
|
rootId,
|
|
};
|
|
case n.ELEMENT_NODE:
|
|
return serializeElementNode(n as HTMLElement, {
|
|
doc,
|
|
blockClass,
|
|
blockSelector,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskInputFn,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
keepIframeSrcFn,
|
|
newlyAddedElement,
|
|
rootId,
|
|
});
|
|
case n.TEXT_NODE:
|
|
return serializeTextNode(n as Text, {
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
maskTextFn,
|
|
rootId,
|
|
});
|
|
case n.CDATA_SECTION_NODE:
|
|
return {
|
|
type: NodeType.CDATA,
|
|
textContent: '',
|
|
rootId,
|
|
};
|
|
case n.COMMENT_NODE:
|
|
return {
|
|
type: NodeType.Comment,
|
|
textContent: (n as Comment).textContent || '',
|
|
rootId,
|
|
};
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getRootId(doc: Document, mirror: Mirror): number | undefined {
|
|
if (!mirror.hasNode(doc)) return undefined;
|
|
const docId = mirror.getId(doc);
|
|
return docId === 1 ? undefined : docId;
|
|
}
|
|
|
|
function serializeTextNode(
|
|
n: Text,
|
|
options: {
|
|
maskTextClass: string | RegExp;
|
|
maskTextSelector: string | null;
|
|
maskTextFn: MaskTextFn | undefined;
|
|
rootId: number | undefined;
|
|
},
|
|
): serializedNode {
|
|
const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
|
|
// The parent node may not be a html element which has a tagName attribute.
|
|
// So just let it be undefined which is ok in this use case.
|
|
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
|
|
let textContent = n.textContent;
|
|
const isStyle = parentTagName === 'STYLE' ? true : undefined;
|
|
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
|
|
if (isStyle && textContent) {
|
|
try {
|
|
// try to read style sheet
|
|
if (n.nextSibling || n.previousSibling) {
|
|
// This is not the only child of the stylesheet.
|
|
// We can't read all of the sheet's .cssRules and expect them
|
|
// to _only_ include the current rule(s) added by the text node.
|
|
// So we'll be conservative and keep textContent as-is.
|
|
} else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) {
|
|
textContent = stringifyStyleSheet(
|
|
(n.parentNode as HTMLStyleElement).sheet!,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`Cannot get CSS styles from text's parentNode. Error: ${err as string}`,
|
|
n,
|
|
);
|
|
}
|
|
textContent = absoluteToStylesheet(textContent, getHref());
|
|
}
|
|
if (isScript) {
|
|
textContent = 'SCRIPT_PLACEHOLDER';
|
|
}
|
|
if (
|
|
!isStyle &&
|
|
!isScript &&
|
|
textContent &&
|
|
needMaskingText(n, maskTextClass, maskTextSelector)
|
|
) {
|
|
textContent = maskTextFn
|
|
? maskTextFn(textContent)
|
|
: textContent.replace(/[\S]/g, '*');
|
|
}
|
|
|
|
return {
|
|
type: NodeType.Text,
|
|
textContent: textContent || '',
|
|
isStyle,
|
|
rootId,
|
|
};
|
|
}
|
|
|
|
function serializeElementNode(
|
|
n: HTMLElement,
|
|
options: {
|
|
doc: Document;
|
|
blockClass: string | RegExp;
|
|
blockSelector: string | null;
|
|
inlineStylesheet: boolean;
|
|
maskInputOptions: MaskInputOptions;
|
|
maskInputFn: MaskInputFn | undefined;
|
|
dataURLOptions?: DataURLOptions;
|
|
inlineImages: boolean;
|
|
recordCanvas: boolean;
|
|
keepIframeSrcFn: KeepIframeSrcFn;
|
|
/**
|
|
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
|
*/
|
|
newlyAddedElement?: boolean;
|
|
rootId: number | undefined;
|
|
},
|
|
): serializedNode | false {
|
|
const {
|
|
doc,
|
|
blockClass,
|
|
blockSelector,
|
|
inlineStylesheet,
|
|
maskInputOptions = {},
|
|
maskInputFn,
|
|
dataURLOptions = {},
|
|
inlineImages,
|
|
recordCanvas,
|
|
keepIframeSrcFn,
|
|
newlyAddedElement = false,
|
|
rootId,
|
|
} = options;
|
|
const needBlock = _isBlockedElement(n, blockClass, blockSelector);
|
|
const tagName = getValidTagName(n);
|
|
let attributes: attributes = {};
|
|
const len = n.attributes.length;
|
|
for (let i = 0; i < len; i++) {
|
|
const attr = n.attributes[i];
|
|
attributes[attr.name] = transformAttribute(
|
|
doc,
|
|
tagName,
|
|
attr.name,
|
|
attr.value,
|
|
);
|
|
}
|
|
// remote css
|
|
if (tagName === 'link' && inlineStylesheet) {
|
|
const stylesheet = Array.from(doc.styleSheets).find((s) => {
|
|
return s.href === (n as HTMLLinkElement).href;
|
|
});
|
|
let cssText: string | null = null;
|
|
if (stylesheet) {
|
|
cssText = getCssRulesString(stylesheet);
|
|
}
|
|
if (cssText) {
|
|
delete attributes.rel;
|
|
delete attributes.href;
|
|
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
|
|
}
|
|
}
|
|
// dynamic stylesheet
|
|
if (
|
|
tagName === 'style' &&
|
|
(n as HTMLStyleElement).sheet &&
|
|
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
|
|
!(n.innerText || n.textContent || '').trim().length
|
|
) {
|
|
const cssText = getCssRulesString(
|
|
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
|
);
|
|
if (cssText) {
|
|
attributes._cssText = absoluteToStylesheet(cssText, getHref());
|
|
}
|
|
}
|
|
// form fields
|
|
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
|
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
|
|
if (
|
|
attributes.type !== 'radio' &&
|
|
attributes.type !== 'checkbox' &&
|
|
attributes.type !== 'submit' &&
|
|
attributes.type !== 'button' &&
|
|
value
|
|
) {
|
|
attributes.value = maskInputValue({
|
|
type: attributes.type,
|
|
tagName,
|
|
value,
|
|
maskInputOptions,
|
|
maskInputFn,
|
|
});
|
|
} else if ((n as HTMLInputElement).checked) {
|
|
attributes.checked = (n as HTMLInputElement).checked;
|
|
}
|
|
}
|
|
if (tagName === 'option') {
|
|
if ((n as HTMLOptionElement).selected && !maskInputOptions['select']) {
|
|
attributes.selected = true;
|
|
} else {
|
|
// ignore the html attribute (which corresponds to DOM (n as HTMLOptionElement).defaultSelected)
|
|
// if it's already been changed
|
|
delete attributes.selected;
|
|
}
|
|
}
|
|
// canvas image data
|
|
if (tagName === 'canvas' && recordCanvas) {
|
|
if ((n as ICanvas).__context === '2d') {
|
|
// only record this on 2d canvas
|
|
if (!is2DCanvasBlank(n as HTMLCanvasElement)) {
|
|
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(
|
|
dataURLOptions.type,
|
|
dataURLOptions.quality,
|
|
);
|
|
}
|
|
} else if (!('__context' in n)) {
|
|
// context is unknown, better not call getContext to trigger it
|
|
const canvasDataURL = (n as HTMLCanvasElement).toDataURL(
|
|
dataURLOptions.type,
|
|
dataURLOptions.quality,
|
|
);
|
|
|
|
// create blank canvas of same dimensions
|
|
const blankCanvas = document.createElement('canvas');
|
|
blankCanvas.width = (n as HTMLCanvasElement).width;
|
|
blankCanvas.height = (n as HTMLCanvasElement).height;
|
|
const blankCanvasDataURL = blankCanvas.toDataURL(
|
|
dataURLOptions.type,
|
|
dataURLOptions.quality,
|
|
);
|
|
|
|
// no need to save dataURL if it's the same as blank canvas
|
|
if (canvasDataURL !== blankCanvasDataURL) {
|
|
attributes.rr_dataURL = canvasDataURL;
|
|
}
|
|
}
|
|
}
|
|
// save image offline
|
|
if (tagName === 'img' && inlineImages) {
|
|
if (!canvasService) {
|
|
canvasService = doc.createElement('canvas');
|
|
canvasCtx = canvasService.getContext('2d');
|
|
}
|
|
const image = n as HTMLImageElement;
|
|
const oldValue = image.crossOrigin;
|
|
image.crossOrigin = 'anonymous';
|
|
const recordInlineImage = () => {
|
|
try {
|
|
canvasService!.width = image.naturalWidth;
|
|
canvasService!.height = image.naturalHeight;
|
|
canvasCtx!.drawImage(image, 0, 0);
|
|
attributes.rr_dataURL = canvasService!.toDataURL(
|
|
dataURLOptions.type,
|
|
dataURLOptions.quality,
|
|
);
|
|
} catch (err) {
|
|
console.warn(
|
|
`Cannot inline img src=${image.currentSrc}! Error: ${err as string}`,
|
|
);
|
|
}
|
|
oldValue
|
|
? (attributes.crossOrigin = oldValue)
|
|
: image.removeAttribute('crossorigin');
|
|
};
|
|
// The image content may not have finished loading yet.
|
|
if (image.complete && image.naturalWidth !== 0) recordInlineImage();
|
|
else image.onload = recordInlineImage;
|
|
}
|
|
// media elements
|
|
if (tagName === 'audio' || tagName === 'video') {
|
|
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
|
? 'paused'
|
|
: 'played';
|
|
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
|
|
}
|
|
// Scroll
|
|
if (!newlyAddedElement) {
|
|
// `scrollTop` and `scrollLeft` are expensive calls because they trigger reflow.
|
|
// Since `scrollTop` & `scrollLeft` are always 0 when an element is added to the DOM.
|
|
// And scrolls also get picked up by rrweb's ScrollObserver
|
|
// So we can safely skip the `scrollTop/Left` calls for newly added elements
|
|
if (n.scrollLeft) {
|
|
attributes.rr_scrollLeft = n.scrollLeft;
|
|
}
|
|
if (n.scrollTop) {
|
|
attributes.rr_scrollTop = n.scrollTop;
|
|
}
|
|
}
|
|
// block element
|
|
if (needBlock) {
|
|
const { width, height } = n.getBoundingClientRect();
|
|
attributes = {
|
|
class: attributes.class,
|
|
rr_width: `${width}px`,
|
|
rr_height: `${height}px`,
|
|
};
|
|
}
|
|
// iframe
|
|
if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) {
|
|
if (!(n as HTMLIFrameElement).contentDocument) {
|
|
// we can't record it directly as we can't see into it
|
|
// preserve the src attribute so a decision can be taken at replay time
|
|
attributes.rr_src = attributes.src;
|
|
}
|
|
delete attributes.src; // prevent auto loading
|
|
}
|
|
|
|
return {
|
|
type: NodeType.Element,
|
|
tagName,
|
|
attributes,
|
|
childNodes: [],
|
|
isSVG: isSVGElement(n as Element) || undefined,
|
|
needBlock,
|
|
rootId,
|
|
};
|
|
}
|
|
|
|
function lowerIfExists(maybeAttr: string | number | boolean): string {
|
|
if (maybeAttr === undefined) {
|
|
return '';
|
|
} else {
|
|
return (maybeAttr as string).toLowerCase();
|
|
}
|
|
}
|
|
|
|
function slimDOMExcluded(
|
|
sn: serializedNode,
|
|
slimDOMOptions: SlimDOMOptions,
|
|
): boolean {
|
|
if (slimDOMOptions.comment && sn.type === NodeType.Comment) {
|
|
// TODO: convert IE conditional comments to real nodes
|
|
return true;
|
|
} else if (sn.type === NodeType.Element) {
|
|
if (
|
|
slimDOMOptions.script &&
|
|
// script tag
|
|
(sn.tagName === 'script' ||
|
|
// preload link
|
|
(sn.tagName === 'link' &&
|
|
sn.attributes.rel === 'preload' &&
|
|
sn.attributes.as === 'script') ||
|
|
// prefetch link
|
|
(sn.tagName === 'link' &&
|
|
sn.attributes.rel === 'prefetch' &&
|
|
typeof sn.attributes.href === 'string' &&
|
|
sn.attributes.href.endsWith('.js')))
|
|
) {
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headFavicon &&
|
|
((sn.tagName === 'link' && sn.attributes.rel === 'shortcut icon') ||
|
|
(sn.tagName === 'meta' &&
|
|
(lowerIfExists(sn.attributes.name).match(
|
|
/^msapplication-tile(image|color)$/,
|
|
) ||
|
|
lowerIfExists(sn.attributes.name) === 'application-name' ||
|
|
lowerIfExists(sn.attributes.rel) === 'icon' ||
|
|
lowerIfExists(sn.attributes.rel) === 'apple-touch-icon' ||
|
|
lowerIfExists(sn.attributes.rel) === 'shortcut icon')))
|
|
) {
|
|
return true;
|
|
} else if (sn.tagName === 'meta') {
|
|
if (
|
|
slimDOMOptions.headMetaDescKeywords &&
|
|
lowerIfExists(sn.attributes.name).match(/^description|keywords$/)
|
|
) {
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headMetaSocial &&
|
|
(lowerIfExists(sn.attributes.property).match(/^(og|twitter|fb):/) || // og = opengraph (facebook)
|
|
lowerIfExists(sn.attributes.name).match(/^(og|twitter):/) ||
|
|
lowerIfExists(sn.attributes.name) === 'pinterest')
|
|
) {
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headMetaRobots &&
|
|
(lowerIfExists(sn.attributes.name) === 'robots' ||
|
|
lowerIfExists(sn.attributes.name) === 'googlebot' ||
|
|
lowerIfExists(sn.attributes.name) === 'bingbot')
|
|
) {
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headMetaHttpEquiv &&
|
|
sn.attributes['http-equiv'] !== undefined
|
|
) {
|
|
// e.g. X-UA-Compatible, Content-Type, Content-Language,
|
|
// cache-control, X-Translated-By
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headMetaAuthorship &&
|
|
(lowerIfExists(sn.attributes.name) === 'author' ||
|
|
lowerIfExists(sn.attributes.name) === 'generator' ||
|
|
lowerIfExists(sn.attributes.name) === 'framework' ||
|
|
lowerIfExists(sn.attributes.name) === 'publisher' ||
|
|
lowerIfExists(sn.attributes.name) === 'progid' ||
|
|
lowerIfExists(sn.attributes.property).match(/^article:/) ||
|
|
lowerIfExists(sn.attributes.property).match(/^product:/))
|
|
) {
|
|
return true;
|
|
} else if (
|
|
slimDOMOptions.headMetaVerification &&
|
|
(lowerIfExists(sn.attributes.name) === 'google-site-verification' ||
|
|
lowerIfExists(sn.attributes.name) === 'yandex-verification' ||
|
|
lowerIfExists(sn.attributes.name) === 'csrf-token' ||
|
|
lowerIfExists(sn.attributes.name) === 'p:domain_verify' ||
|
|
lowerIfExists(sn.attributes.name) === 'verify-v1' ||
|
|
lowerIfExists(sn.attributes.name) === 'verification' ||
|
|
lowerIfExists(sn.attributes.name) === 'shopify-checkout-api-token')
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function serializeNodeWithId(
|
|
n: Node,
|
|
options: {
|
|
doc: Document;
|
|
mirror: Mirror;
|
|
blockClass: string | RegExp;
|
|
blockSelector: string | null;
|
|
maskTextClass: string | RegExp;
|
|
maskTextSelector: string | null;
|
|
skipChild: boolean;
|
|
inlineStylesheet: boolean;
|
|
newlyAddedElement?: boolean;
|
|
maskInputOptions?: MaskInputOptions;
|
|
maskTextFn: MaskTextFn | undefined;
|
|
maskInputFn: MaskInputFn | undefined;
|
|
slimDOMOptions: SlimDOMOptions;
|
|
dataURLOptions?: DataURLOptions;
|
|
keepIframeSrcFn?: KeepIframeSrcFn;
|
|
inlineImages?: boolean;
|
|
recordCanvas?: boolean;
|
|
preserveWhiteSpace?: boolean;
|
|
onSerialize?: (n: Node) => unknown;
|
|
onIframeLoad?: (
|
|
iframeNode: HTMLIFrameElement,
|
|
node: serializedElementNodeWithId,
|
|
) => unknown;
|
|
iframeLoadTimeout?: number;
|
|
onStylesheetLoad?: (
|
|
linkNode: HTMLLinkElement,
|
|
node: serializedElementNodeWithId,
|
|
) => unknown;
|
|
stylesheetLoadTimeout?: number;
|
|
},
|
|
): serializedNodeWithId | null {
|
|
const {
|
|
doc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
skipChild = false,
|
|
inlineStylesheet = true,
|
|
maskInputOptions = {},
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions = {},
|
|
inlineImages = false,
|
|
recordCanvas = false,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout = 5000,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout = 5000,
|
|
keepIframeSrcFn = () => false,
|
|
newlyAddedElement = false,
|
|
} = options;
|
|
let { preserveWhiteSpace = true } = options;
|
|
const _serializedNode = serializeNode(n, {
|
|
doc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
keepIframeSrcFn,
|
|
newlyAddedElement,
|
|
});
|
|
if (!_serializedNode) {
|
|
// TODO: dev only
|
|
console.warn(n, 'not serialized');
|
|
return null;
|
|
}
|
|
|
|
let id: number | undefined;
|
|
if (mirror.hasNode(n)) {
|
|
// Reuse the previous id
|
|
id = mirror.getId(n);
|
|
} else if (
|
|
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
|
(!preserveWhiteSpace &&
|
|
_serializedNode.type === NodeType.Text &&
|
|
!_serializedNode.isStyle &&
|
|
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
|
|
) {
|
|
id = IGNORED_NODE;
|
|
} else {
|
|
id = genId();
|
|
}
|
|
if (id === IGNORED_NODE) {
|
|
return null; // slimDOM
|
|
}
|
|
|
|
const serializedNode = Object.assign(_serializedNode, { id });
|
|
|
|
mirror.add(n, serializedNode);
|
|
|
|
if (onSerialize) {
|
|
onSerialize(n);
|
|
}
|
|
let recordChild = !skipChild;
|
|
if (serializedNode.type === NodeType.Element) {
|
|
recordChild = recordChild && !serializedNode.needBlock;
|
|
// this property was not needed in replay side
|
|
delete serializedNode.needBlock;
|
|
const shadowRoot = (n as HTMLElement).shadowRoot;
|
|
if (shadowRoot && isNativeShadowDom(shadowRoot))
|
|
serializedNode.isShadowHost = true;
|
|
}
|
|
if (
|
|
(serializedNode.type === NodeType.Document ||
|
|
serializedNode.type === NodeType.Element) &&
|
|
recordChild
|
|
) {
|
|
if (
|
|
slimDOMOptions.headWhitespace &&
|
|
serializedNode.type === NodeType.Element &&
|
|
serializedNode.tagName === 'head'
|
|
// would impede performance: || getComputedStyle(n)['white-space'] === 'normal'
|
|
) {
|
|
preserveWhiteSpace = false;
|
|
}
|
|
const bypassOptions = {
|
|
doc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
skipChild,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout,
|
|
keepIframeSrcFn,
|
|
};
|
|
for (const childN of Array.from(n.childNodes)) {
|
|
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
|
if (serializedChildNode) {
|
|
serializedNode.childNodes.push(serializedChildNode);
|
|
}
|
|
}
|
|
|
|
if (isElement(n) && n.shadowRoot) {
|
|
for (const childN of Array.from(n.shadowRoot.childNodes)) {
|
|
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
|
if (serializedChildNode) {
|
|
isNativeShadowDom(n.shadowRoot) &&
|
|
(serializedChildNode.isShadow = true);
|
|
serializedNode.childNodes.push(serializedChildNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
n.parentNode &&
|
|
isShadowRoot(n.parentNode) &&
|
|
isNativeShadowDom(n.parentNode)
|
|
) {
|
|
serializedNode.isShadow = true;
|
|
}
|
|
|
|
if (
|
|
serializedNode.type === NodeType.Element &&
|
|
serializedNode.tagName === 'iframe'
|
|
) {
|
|
onceIframeLoaded(
|
|
n as HTMLIFrameElement,
|
|
() => {
|
|
const iframeDoc = (n as HTMLIFrameElement).contentDocument;
|
|
if (iframeDoc && onIframeLoad) {
|
|
const serializedIframeNode = serializeNodeWithId(iframeDoc, {
|
|
doc: iframeDoc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
skipChild: false,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout,
|
|
keepIframeSrcFn,
|
|
});
|
|
|
|
if (serializedIframeNode) {
|
|
onIframeLoad(
|
|
n as HTMLIFrameElement,
|
|
serializedIframeNode as serializedElementNodeWithId,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
iframeLoadTimeout,
|
|
);
|
|
}
|
|
|
|
// <link rel=stylesheet href=...>
|
|
if (
|
|
serializedNode.type === NodeType.Element &&
|
|
serializedNode.tagName === 'link' &&
|
|
serializedNode.attributes.rel === 'stylesheet'
|
|
) {
|
|
onceStylesheetLoaded(
|
|
n as HTMLLinkElement,
|
|
() => {
|
|
if (onStylesheetLoad) {
|
|
const serializedLinkNode = serializeNodeWithId(n, {
|
|
doc,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
skipChild: false,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout,
|
|
keepIframeSrcFn,
|
|
});
|
|
|
|
if (serializedLinkNode) {
|
|
onStylesheetLoad(
|
|
n as HTMLLinkElement,
|
|
serializedLinkNode as serializedElementNodeWithId,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
stylesheetLoadTimeout,
|
|
);
|
|
if (isStylesheetLoaded(n as HTMLLinkElement) === false) return null; // add stylesheet in later mutation
|
|
}
|
|
|
|
return serializedNode;
|
|
}
|
|
|
|
function snapshot(
|
|
n: Document,
|
|
options?: {
|
|
mirror?: Mirror;
|
|
blockClass?: string | RegExp;
|
|
blockSelector?: string | null;
|
|
maskTextClass?: string | RegExp;
|
|
maskTextSelector?: string | null;
|
|
inlineStylesheet?: boolean;
|
|
maskAllInputs?: boolean | MaskInputOptions;
|
|
maskTextFn?: MaskTextFn;
|
|
maskInputFn?: MaskTextFn;
|
|
slimDOM?: boolean | SlimDOMOptions;
|
|
dataURLOptions?: DataURLOptions;
|
|
inlineImages?: boolean;
|
|
recordCanvas?: boolean;
|
|
preserveWhiteSpace?: boolean;
|
|
onSerialize?: (n: Node) => unknown;
|
|
onIframeLoad?: (
|
|
iframeNode: HTMLIFrameElement,
|
|
node: serializedElementNodeWithId,
|
|
) => unknown;
|
|
iframeLoadTimeout?: number;
|
|
onStylesheetLoad?: (
|
|
linkNode: HTMLLinkElement,
|
|
node: serializedElementNodeWithId,
|
|
) => unknown;
|
|
stylesheetLoadTimeout?: number;
|
|
keepIframeSrcFn?: KeepIframeSrcFn;
|
|
},
|
|
): serializedNodeWithId | null {
|
|
const {
|
|
mirror = new Mirror(),
|
|
blockClass = 'rr-block',
|
|
blockSelector = null,
|
|
maskTextClass = 'rr-mask',
|
|
maskTextSelector = null,
|
|
inlineStylesheet = true,
|
|
inlineImages = false,
|
|
recordCanvas = false,
|
|
maskAllInputs = false,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOM = false,
|
|
dataURLOptions,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout,
|
|
keepIframeSrcFn = () => false,
|
|
} = options || {};
|
|
const maskInputOptions: MaskInputOptions =
|
|
maskAllInputs === true
|
|
? {
|
|
color: true,
|
|
date: true,
|
|
'datetime-local': true,
|
|
email: true,
|
|
month: true,
|
|
number: true,
|
|
range: true,
|
|
search: true,
|
|
tel: true,
|
|
text: true,
|
|
time: true,
|
|
url: true,
|
|
week: true,
|
|
textarea: true,
|
|
select: true,
|
|
password: true,
|
|
}
|
|
: maskAllInputs === false
|
|
? {
|
|
password: true,
|
|
}
|
|
: maskAllInputs;
|
|
const slimDOMOptions: SlimDOMOptions =
|
|
slimDOM === true || slimDOM === 'all'
|
|
? // if true: set of sensible options that should not throw away any information
|
|
{
|
|
script: true,
|
|
comment: true,
|
|
headFavicon: true,
|
|
headWhitespace: true,
|
|
headMetaDescKeywords: slimDOM === 'all', // destructive
|
|
headMetaSocial: true,
|
|
headMetaRobots: true,
|
|
headMetaHttpEquiv: true,
|
|
headMetaAuthorship: true,
|
|
headMetaVerification: true,
|
|
}
|
|
: slimDOM === false
|
|
? {}
|
|
: slimDOM;
|
|
return serializeNodeWithId(n, {
|
|
doc: n,
|
|
mirror,
|
|
blockClass,
|
|
blockSelector,
|
|
maskTextClass,
|
|
maskTextSelector,
|
|
skipChild: false,
|
|
inlineStylesheet,
|
|
maskInputOptions,
|
|
maskTextFn,
|
|
maskInputFn,
|
|
slimDOMOptions,
|
|
dataURLOptions,
|
|
inlineImages,
|
|
recordCanvas,
|
|
preserveWhiteSpace,
|
|
onSerialize,
|
|
onIframeLoad,
|
|
iframeLoadTimeout,
|
|
onStylesheetLoad,
|
|
stylesheetLoadTimeout,
|
|
keepIframeSrcFn,
|
|
newlyAddedElement: false,
|
|
});
|
|
}
|
|
|
|
export function visitSnapshot(
|
|
node: serializedNodeWithId,
|
|
onVisit: (node: serializedNodeWithId) => unknown,
|
|
) {
|
|
function walk(current: serializedNodeWithId) {
|
|
onVisit(current);
|
|
if (
|
|
current.type === NodeType.Document ||
|
|
current.type === NodeType.Element
|
|
) {
|
|
current.childNodes.forEach(walk);
|
|
}
|
|
}
|
|
|
|
walk(node);
|
|
}
|
|
|
|
export function cleanupSnapshot() {
|
|
// allow a new recording to start numbering nodes from scratch
|
|
_id = 1;
|
|
}
|
|
|
|
export default snapshot;
|