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
320a454c49
commit
69a1b9ffe6
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) |
|
||||
| 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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\\" />
|
||||
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
|
||||
</picture>
|
||||
<img src=\\"http://localhost:3030/images/robot.png\\" alt=\\"This is a robot\\" />
|
||||
</body></html>"
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
<source type="image/webp" srcset="assets/img/characters/robot.webp" />
|
||||
<img src="assets/img/characters/robot.png" />
|
||||
</picture>
|
||||
<img src="/images/robot.png" alt="This is a robot" />
|
||||
</body>
|
||||
</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',
|
||||
'.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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user