diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 02619296c8..0b591170c0 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -440,6 +440,12 @@ function serializeNode( * `newlyAddedElement: true` skips scrollTop and scrollLeft check */ newlyAddedElement?: boolean; + onNodeMutation?: (args: { + id: number; + attributes: { + [key: string]: string; + }; + }) => unknown; }, ): serializedNode | false { const { @@ -458,6 +464,7 @@ function serializeNode( recordCanvas, keepIframeSrcFn, newlyAddedElement = false, + onNodeMutation, } = options; // Only record root id when document object is not the base document const rootId = getRootId(doc, mirror); @@ -486,6 +493,7 @@ function serializeNode( case n.ELEMENT_NODE: return serializeElementNode(n as HTMLElement, { doc, + mirror, blockClass, blockSelector, inlineStylesheet, @@ -497,6 +505,7 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + onNodeMutation, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -587,10 +596,32 @@ function serializeTextNode( }; } +const mapSrcToDataUrl = new Map(); + +const loadCrossOriginImage = ( + doc: Document, + image: HTMLImageElement, + onLoad: (e: HTMLImageElement) => void, +) => { + const copy = doc.createElement('img'); + const handler = () => { + copy.removeEventListener('load', handler); + onLoad(copy); + }; + copy.addEventListener('load', handler); + copy.crossOrigin = 'anonymous'; + for (let i = 0; i < image.attributes.length; i++) { + const attribute = image.attributes[i]; + if (attribute.name === 'crossOrigin') continue; + copy.setAttribute(attribute.name, attribute.value); + } +}; + function serializeElementNode( n: HTMLElement, options: { doc: Document; + mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; inlineStylesheet: boolean; @@ -605,10 +636,17 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + onNodeMutation?: (args: { + id: number; + attributes: { + [key: string]: string; + }; + }) => unknown; }, ): serializedNode | false { const { doc, + mirror, blockClass, blockSelector, inlineStylesheet, @@ -620,6 +658,7 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + onNodeMutation, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -736,16 +775,12 @@ function serializeElementNode( canvasService = doc.createElement('canvas'); canvasCtx = canvasService.getContext('2d'); } - const image = n as HTMLImageElement; - const oldValue = image.crossOrigin; - image.crossOrigin = 'anonymous'; - const recordInlineImage = () => { - image.removeEventListener('load', recordInlineImage); + const getDataUrl = (i: HTMLImageElement) => { try { - canvasService!.width = image.naturalWidth; - canvasService!.height = image.naturalHeight; - canvasCtx!.drawImage(image, 0, 0); - attributes.rr_dataURL = canvasService!.toDataURL( + canvasService!.width = i.naturalWidth; + canvasService!.height = i.naturalHeight; + canvasCtx!.drawImage(i, 0, 0); + return canvasService!.toDataURL( dataURLOptions.type, dataURLOptions.quality, ); @@ -754,13 +789,26 @@ function serializeElementNode( `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.addEventListener('load', recordInlineImage); + const image = n as HTMLImageElement; + if (mapSrcToDataUrl.has(image.src)) { + attributes.rr_dataURL = mapSrcToDataUrl.get(image.src) as string; + } else { + loadCrossOriginImage(doc, image, (i) => { + const durl = getDataUrl(i); + if (durl) { + mapSrcToDataUrl.set(image.src, durl); + if (onNodeMutation && mirror.hasNode(n)) { + onNodeMutation({ + id: mirror.getId(n), + attributes: { + rr_dataURL: durl, + }, + }); + } + } + }); + } } // media elements if (tagName === 'audio' || tagName === 'video') { @@ -946,6 +994,12 @@ export function serializeNodeWithId( node: serializedElementNodeWithId, ) => unknown; stylesheetLoadTimeout?: number; + onNodeMutation?: (args: { + id: number; + attributes: { + [key: string]: string; + }; + }) => unknown; }, ): serializedNodeWithId | null { const { @@ -971,6 +1025,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, newlyAddedElement = false, + onNodeMutation, } = options; let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { @@ -989,6 +1044,7 @@ export function serializeNodeWithId( recordCanvas, keepIframeSrcFn, newlyAddedElement, + onNodeMutation, }); if (!_serializedNode) { // TODO: dev only @@ -1068,6 +1124,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onNodeMutation, }; for (const childN of Array.from(n.childNodes)) { const serializedChildNode = serializeNodeWithId(childN, bypassOptions); @@ -1128,6 +1185,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onNodeMutation, }); if (serializedIframeNode) { @@ -1175,6 +1233,7 @@ export function serializeNodeWithId( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn, + onNodeMutation, }); if (serializedLinkNode) { @@ -1221,6 +1280,12 @@ function snapshot( ) => unknown; stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; + onNodeMutation?: (args: { + id: number; + attributes: { + [key: string]: string; + }; + }) => unknown; }, ): serializedNodeWithId | null { const { @@ -1244,6 +1309,7 @@ function snapshot( onStylesheetLoad, stylesheetLoadTimeout, keepIframeSrcFn = () => false, + onNodeMutation, } = options || {}; const maskInputOptions: MaskInputOptions = maskAllInputs === true @@ -1312,6 +1378,7 @@ function snapshot( stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, + onNodeMutation, }); } diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 1c2141bfef..75380615da 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -395,6 +395,14 @@ function record( stylesheetManager.attachLinkElement(linkEl, childSn); }, keepIframeSrcFn, + onNodeMutation: (mutation) => { + wrappedMutationEmit({ + adds: [], + removes: [], + texts: [], + attributes: [mutation], + }); + }, }); if (!node) { diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index aac84c2783..e50548fafa 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1760,6 +1760,19 @@ export class Replayer { } catch (e) { // for safe } + } else if ( + attributeName === 'rr_dataURL' && + target.nodeName === 'IMG' + ) { + const image = target as HTMLImageElement; + const src = image.getAttribute('src'); + if (src && !src.startsWith('data:')) { + const oldsrc = image.currentSrc; + // Backup original img src. It may not have been set yet. + image.setAttribute('rrweb-original-src', oldsrc); + image.setAttribute('src', value.toString()); + } + continue; } (target as Element | RRElement).setAttribute( attributeName,