From 69a1b9ffe67dbf8ba72fc5647f90fcb07ed23550 Mon Sep 17 00:00:00 2001
From: Cristi Constantin <1748317+croqaz@users.noreply.github.com>
Date: Tue, 11 Jan 2022 15:54:58 +0000
Subject: [PATCH] Save images offline, in the snapshot (#770)
* Implemented image restore from rr_dataURL
* Implement saving images in the snapshot
* Fixed image saving, added a test
* Rename data-src to data-rrweb-src
* Updated the guide
* Rename recordImages to inlineImages and try catch
---
guide.md | 1 +
packages/rrweb-snapshot/src/rebuild.ts | 18 +++++---
packages/rrweb-snapshot/src/snapshot.ts | 40 ++++++++++++++++++
.../__snapshots__/integration.test.ts.snap | 1 +
.../rrweb-snapshot/test/html/picture.html | 1 +
packages/rrweb-snapshot/test/images/robot.png | Bin 0 -> 11004 bytes
.../rrweb-snapshot/test/integration.test.ts | 20 +++++++++
packages/rrweb-snapshot/typings/snapshot.d.ts | 2 +
8 files changed, 77 insertions(+), 6 deletions(-)
create mode 100644 packages/rrweb-snapshot/test/images/robot.png
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`] = `
+