diff --git a/guide.md b/guide.md index 19f26bc204..3c6ed36ff3 100644 --- a/guide.md +++ b/guide.md @@ -155,6 +155,7 @@ The parameter of `rrweb.record` accepts the following options. | packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | | sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | | recordCanvas | false | whether to record the canvas element | +| inlineImages | false | whether to record the image content | | collectFonts | false | whether to collect fonts in the website | | recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) | | userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 6aef3c4e0f..1719569c36 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -226,18 +226,24 @@ function buildNode( ctx.drawImage(image, 0, 0, image.width, image.height); } }; + } else if (tagName === 'img' && name === 'rr_dataURL') { + const image = (node as HTMLImageElement); + if (!image.currentSrc.startsWith('data:')) { + // backup original img src + image.setAttribute('data-rrweb-src', image.currentSrc); + image.src = value; + } + image.removeAttribute(name); } + if (name === 'rr_width') { (node as HTMLElement).style.width = value; - } - if (name === 'rr_height') { + } else if (name === 'rr_height') { (node as HTMLElement).style.height = value; - } - if (name === 'rr_mediaCurrentTime') { + } else if (name === 'rr_mediaCurrentTime') { (node as HTMLMediaElement).currentTime = n.attributes .rr_mediaCurrentTime as number; - } - if (name === 'rr_mediaState') { + } else if (name === 'rr_mediaState') { switch (value) { case 'played': (node as HTMLMediaElement) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 6c6e136e0d..e666eb4ade 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -75,6 +75,20 @@ function extractOrigin(url: string): string { return origin; } +let canvasService: HTMLCanvasElement | null; +let canvasCtx: CanvasRenderingContext2D | null; + +function initCanvasService(doc: Document) { + if (!canvasService) { + canvasService = doc.createElement('canvas'); + } + if (!canvasCtx) { + canvasCtx = canvasService.getContext('2d'); + } + canvasService.width = 0; + canvasService.height = 0; +} + const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/; const DATA_URI = /^(data:)([^,]*),(.*)/i; @@ -369,6 +383,7 @@ function serializeNode( maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; + inlineImages: boolean; recordCanvas: boolean; keepIframeSrcFn: KeepIframeSrcFn; }, @@ -383,6 +398,7 @@ function serializeNode( maskInputOptions = {}, maskTextFn, maskInputFn, + inlineImages, recordCanvas, keepIframeSrcFn, } = options; @@ -498,6 +514,19 @@ function serializeNode( if (tagName === 'canvas' && recordCanvas) { attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(); } + // save image offline + if (tagName === 'img' && inlineImages && canvasService && canvasCtx) { + const image = (n as HTMLImageElement); + image.crossOrigin = 'anonymous'; + try { + canvasService.width = image.naturalWidth; + canvasService.height = image.naturalHeight; + canvasCtx.drawImage(image, 0, 0); + attributes.rr_dataURL = canvasService.toDataURL(); + } catch { + // ignore error + } + } // media elements if (tagName === 'audio' || tagName === 'video') { attributes.rr_mediaState = (n as HTMLMediaElement).paused @@ -711,6 +740,7 @@ export function serializeNodeWithId( maskInputFn: MaskInputFn | undefined; slimDOMOptions: SlimDOMOptions; keepIframeSrcFn?: KeepIframeSrcFn; + inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown; @@ -731,6 +761,7 @@ export function serializeNodeWithId( maskTextFn, maskInputFn, slimDOMOptions, + inlineImages = false, recordCanvas = false, onSerialize, onIframeLoad, @@ -748,6 +779,7 @@ export function serializeNodeWithId( maskInputOptions, maskTextFn, maskInputFn, + inlineImages, recordCanvas, keepIframeSrcFn, }); @@ -800,6 +832,9 @@ export function serializeNodeWithId( ) { preserveWhiteSpace = false; } + if (inlineImages) { + initCanvasService(doc); + } const bypassOptions = { doc, map, @@ -813,6 +848,7 @@ export function serializeNodeWithId( maskTextFn, maskInputFn, slimDOMOptions, + inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, @@ -865,6 +901,7 @@ export function serializeNodeWithId( maskTextFn, maskInputFn, slimDOMOptions, + inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, @@ -897,6 +934,7 @@ function snapshot( maskTextFn?: MaskTextFn; maskInputFn?: MaskTextFn; slimDOM?: boolean | SlimDOMOptions; + inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown; @@ -911,6 +949,7 @@ function snapshot( maskTextClass = 'rr-mask', maskTextSelector = null, inlineStylesheet = true, + inlineImages = false, recordCanvas = false, maskAllInputs = false, maskTextFn, @@ -980,6 +1019,7 @@ function snapshot( maskTextFn, maskInputFn, slimDOMOptions, + inlineImages, recordCanvas, preserveWhiteSpace, onSerialize, diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index a0aaace1b3..0706eba11c 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -326,6 +326,7 @@ exports[`integration tests [html file]: picture.html 1`] = ` + \\"This " `; diff --git a/packages/rrweb-snapshot/test/html/picture.html b/packages/rrweb-snapshot/test/html/picture.html index 90d0b421a1..e005310b77 100644 --- a/packages/rrweb-snapshot/test/html/picture.html +++ b/packages/rrweb-snapshot/test/html/picture.html @@ -4,5 +4,6 @@ + This is a robot diff --git a/packages/rrweb-snapshot/test/images/robot.png b/packages/rrweb-snapshot/test/images/robot.png new file mode 100644 index 0000000000..cc486cc8b7 Binary files /dev/null and b/packages/rrweb-snapshot/test/images/robot.png differ diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 53c6e6e81f..55daa59cca 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -28,6 +28,7 @@ const startServer = () => '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.png': 'image/png', }; const s = http.createServer((req, res) => { const parsedUrl = url.parse(req.url!); @@ -190,6 +191,25 @@ iframe.contentDocument.querySelector('center').clientHeight 'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})', ); }); + + it('correctly saves images offline', async () => { + const page: puppeteer.Page = await browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + + await page.goto('http://localhost:3030/html/picture.html', { waitUntil: 'load' }); + await page.waitForSelector('img', { timeout: 1000 }); + + const snapshot = await page.evaluate(`${code} + const [snap] = rrweb.snapshot(document, {inlineImages: true, inlineStylesheet: false}); + JSON.stringify(snap, null, 2); + `); + + assert(snapshot.includes('"rr_dataURL"')); + assert(snapshot.includes('data:image/png;base64,')); + }); + }); describe('iframe integration tests', function (this: ISuite) { diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 9ccb147aa1..af06efc2b2 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -19,6 +19,7 @@ export declare function serializeNodeWithId(n: Node | INode, options: { maskInputFn: MaskInputFn | undefined; slimDOMOptions: SlimDOMOptions; keepIframeSrcFn?: KeepIframeSrcFn; + inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown; @@ -35,6 +36,7 @@ declare function snapshot(n: Document, options?: { maskTextFn?: MaskTextFn; maskInputFn?: MaskTextFn; slimDOM?: boolean | SlimDOMOptions; + inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: INode) => unknown;