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
This commit is contained in:
committed by
GitHub
parent
dd2cdedcd6
commit
151debad4a
1
guide.md
1
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) |
|
| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
|
||||||
| sampling | - | 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -226,18 +226,24 @@ function buildNode(
|
|||||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
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') {
|
if (name === 'rr_width') {
|
||||||
(node as HTMLElement).style.width = value;
|
(node as HTMLElement).style.width = value;
|
||||||
}
|
} else if (name === 'rr_height') {
|
||||||
if (name === 'rr_height') {
|
|
||||||
(node as HTMLElement).style.height = value;
|
(node as HTMLElement).style.height = value;
|
||||||
}
|
} else if (name === 'rr_mediaCurrentTime') {
|
||||||
if (name === 'rr_mediaCurrentTime') {
|
|
||||||
(node as HTMLMediaElement).currentTime = n.attributes
|
(node as HTMLMediaElement).currentTime = n.attributes
|
||||||
.rr_mediaCurrentTime as number;
|
.rr_mediaCurrentTime as number;
|
||||||
}
|
} else if (name === 'rr_mediaState') {
|
||||||
if (name === 'rr_mediaState') {
|
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'played':
|
case 'played':
|
||||||
(node as HTMLMediaElement)
|
(node as HTMLMediaElement)
|
||||||
|
|||||||
@@ -75,6 +75,20 @@ function extractOrigin(url: string): string {
|
|||||||
return origin;
|
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 URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
|
||||||
const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/;
|
const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/;
|
||||||
const DATA_URI = /^(data:)([^,]*),(.*)/i;
|
const DATA_URI = /^(data:)([^,]*),(.*)/i;
|
||||||
@@ -369,6 +383,7 @@ function serializeNode(
|
|||||||
maskInputOptions: MaskInputOptions;
|
maskInputOptions: MaskInputOptions;
|
||||||
maskTextFn: MaskTextFn | undefined;
|
maskTextFn: MaskTextFn | undefined;
|
||||||
maskInputFn: MaskInputFn | undefined;
|
maskInputFn: MaskInputFn | undefined;
|
||||||
|
inlineImages: boolean;
|
||||||
recordCanvas: boolean;
|
recordCanvas: boolean;
|
||||||
keepIframeSrcFn: KeepIframeSrcFn;
|
keepIframeSrcFn: KeepIframeSrcFn;
|
||||||
},
|
},
|
||||||
@@ -383,6 +398,7 @@ function serializeNode(
|
|||||||
maskInputOptions = {},
|
maskInputOptions = {},
|
||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
} = options;
|
} = options;
|
||||||
@@ -498,6 +514,19 @@ function serializeNode(
|
|||||||
if (tagName === 'canvas' && recordCanvas) {
|
if (tagName === 'canvas' && recordCanvas) {
|
||||||
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
|
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
|
// media elements
|
||||||
if (tagName === 'audio' || tagName === 'video') {
|
if (tagName === 'audio' || tagName === 'video') {
|
||||||
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
attributes.rr_mediaState = (n as HTMLMediaElement).paused
|
||||||
@@ -711,6 +740,7 @@ export function serializeNodeWithId(
|
|||||||
maskInputFn: MaskInputFn | undefined;
|
maskInputFn: MaskInputFn | undefined;
|
||||||
slimDOMOptions: SlimDOMOptions;
|
slimDOMOptions: SlimDOMOptions;
|
||||||
keepIframeSrcFn?: KeepIframeSrcFn;
|
keepIframeSrcFn?: KeepIframeSrcFn;
|
||||||
|
inlineImages?: boolean;
|
||||||
recordCanvas?: boolean;
|
recordCanvas?: boolean;
|
||||||
preserveWhiteSpace?: boolean;
|
preserveWhiteSpace?: boolean;
|
||||||
onSerialize?: (n: INode) => unknown;
|
onSerialize?: (n: INode) => unknown;
|
||||||
@@ -731,6 +761,7 @@ export function serializeNodeWithId(
|
|||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
slimDOMOptions,
|
slimDOMOptions,
|
||||||
|
inlineImages = false,
|
||||||
recordCanvas = false,
|
recordCanvas = false,
|
||||||
onSerialize,
|
onSerialize,
|
||||||
onIframeLoad,
|
onIframeLoad,
|
||||||
@@ -748,6 +779,7 @@ export function serializeNodeWithId(
|
|||||||
maskInputOptions,
|
maskInputOptions,
|
||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
keepIframeSrcFn,
|
keepIframeSrcFn,
|
||||||
});
|
});
|
||||||
@@ -800,6 +832,9 @@ export function serializeNodeWithId(
|
|||||||
) {
|
) {
|
||||||
preserveWhiteSpace = false;
|
preserveWhiteSpace = false;
|
||||||
}
|
}
|
||||||
|
if (inlineImages) {
|
||||||
|
initCanvasService(doc);
|
||||||
|
}
|
||||||
const bypassOptions = {
|
const bypassOptions = {
|
||||||
doc,
|
doc,
|
||||||
map,
|
map,
|
||||||
@@ -813,6 +848,7 @@ export function serializeNodeWithId(
|
|||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
slimDOMOptions,
|
slimDOMOptions,
|
||||||
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
preserveWhiteSpace,
|
preserveWhiteSpace,
|
||||||
onSerialize,
|
onSerialize,
|
||||||
@@ -865,6 +901,7 @@ export function serializeNodeWithId(
|
|||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
slimDOMOptions,
|
slimDOMOptions,
|
||||||
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
preserveWhiteSpace,
|
preserveWhiteSpace,
|
||||||
onSerialize,
|
onSerialize,
|
||||||
@@ -897,6 +934,7 @@ function snapshot(
|
|||||||
maskTextFn?: MaskTextFn;
|
maskTextFn?: MaskTextFn;
|
||||||
maskInputFn?: MaskTextFn;
|
maskInputFn?: MaskTextFn;
|
||||||
slimDOM?: boolean | SlimDOMOptions;
|
slimDOM?: boolean | SlimDOMOptions;
|
||||||
|
inlineImages?: boolean;
|
||||||
recordCanvas?: boolean;
|
recordCanvas?: boolean;
|
||||||
preserveWhiteSpace?: boolean;
|
preserveWhiteSpace?: boolean;
|
||||||
onSerialize?: (n: INode) => unknown;
|
onSerialize?: (n: INode) => unknown;
|
||||||
@@ -911,6 +949,7 @@ function snapshot(
|
|||||||
maskTextClass = 'rr-mask',
|
maskTextClass = 'rr-mask',
|
||||||
maskTextSelector = null,
|
maskTextSelector = null,
|
||||||
inlineStylesheet = true,
|
inlineStylesheet = true,
|
||||||
|
inlineImages = false,
|
||||||
recordCanvas = false,
|
recordCanvas = false,
|
||||||
maskAllInputs = false,
|
maskAllInputs = false,
|
||||||
maskTextFn,
|
maskTextFn,
|
||||||
@@ -980,6 +1019,7 @@ function snapshot(
|
|||||||
maskTextFn,
|
maskTextFn,
|
||||||
maskInputFn,
|
maskInputFn,
|
||||||
slimDOMOptions,
|
slimDOMOptions,
|
||||||
|
inlineImages,
|
||||||
recordCanvas,
|
recordCanvas,
|
||||||
preserveWhiteSpace,
|
preserveWhiteSpace,
|
||||||
onSerialize,
|
onSerialize,
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ exports[`integration tests [html file]: picture.html 1`] = `
|
|||||||
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
|
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
|
||||||
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
|
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
|
||||||
</picture>
|
</picture>
|
||||||
|
<img src=\\"http://localhost:3030/images/robot.png\\" alt=\\"This is a robot\\" />
|
||||||
</body></html>"
|
</body></html>"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
<source type="image/webp" srcset="assets/img/characters/robot.webp" />
|
<source type="image/webp" srcset="assets/img/characters/robot.webp" />
|
||||||
<img src="assets/img/characters/robot.png" />
|
<img src="assets/img/characters/robot.png" />
|
||||||
</picture>
|
</picture>
|
||||||
|
<img src="/images/robot.png" alt="This is a robot" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
packages/rrweb-snapshot/test/images/robot.png
Normal file
BIN
packages/rrweb-snapshot/test/images/robot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -28,6 +28,7 @@ const startServer = () =>
|
|||||||
'.html': 'text/html',
|
'.html': 'text/html',
|
||||||
'.js': 'text/javascript',
|
'.js': 'text/javascript',
|
||||||
'.css': 'text/css',
|
'.css': 'text/css',
|
||||||
|
'.png': 'image/png',
|
||||||
};
|
};
|
||||||
const s = http.createServer((req, res) => {
|
const s = http.createServer((req, res) => {
|
||||||
const parsedUrl = url.parse(req.url!);
|
const parsedUrl = url.parse(req.url!);
|
||||||
@@ -190,6 +191,25 @@ iframe.contentDocument.querySelector('center').clientHeight
|
|||||||
'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})',
|
'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) {
|
describe('iframe integration tests', function (this: ISuite) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export declare function serializeNodeWithId(n: Node | INode, options: {
|
|||||||
maskInputFn: MaskInputFn | undefined;
|
maskInputFn: MaskInputFn | undefined;
|
||||||
slimDOMOptions: SlimDOMOptions;
|
slimDOMOptions: SlimDOMOptions;
|
||||||
keepIframeSrcFn?: KeepIframeSrcFn;
|
keepIframeSrcFn?: KeepIframeSrcFn;
|
||||||
|
inlineImages?: boolean;
|
||||||
recordCanvas?: boolean;
|
recordCanvas?: boolean;
|
||||||
preserveWhiteSpace?: boolean;
|
preserveWhiteSpace?: boolean;
|
||||||
onSerialize?: (n: INode) => unknown;
|
onSerialize?: (n: INode) => unknown;
|
||||||
@@ -35,6 +36,7 @@ declare function snapshot(n: Document, options?: {
|
|||||||
maskTextFn?: MaskTextFn;
|
maskTextFn?: MaskTextFn;
|
||||||
maskInputFn?: MaskTextFn;
|
maskInputFn?: MaskTextFn;
|
||||||
slimDOM?: boolean | SlimDOMOptions;
|
slimDOM?: boolean | SlimDOMOptions;
|
||||||
|
inlineImages?: boolean;
|
||||||
recordCanvas?: boolean;
|
recordCanvas?: boolean;
|
||||||
preserveWhiteSpace?: boolean;
|
preserveWhiteSpace?: boolean;
|
||||||
onSerialize?: (n: INode) => unknown;
|
onSerialize?: (n: INode) => unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user