@abi-software/mapintegratedvuer 1.22.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ConnectivityGraph-BBZ3Dgat.js → ConnectivityGraph-CCxk8vcE.js} +6 -6
- package/dist/{ContentMixin-B-O_IwPg.js → ContentMixin-Db8nqZax.js} +303 -247
- package/dist/{DynamicMarkerMixin-B9iqtP_f.js → DynamicMarkerMixin-LB2RYdSY.js} +1 -1
- package/dist/{Flatmap-BoEFI50b.js → Flatmap-CqUzOkdl.js} +5 -5
- package/dist/{Iframe-Slj-LQTF.js → Iframe-I-xdZ2FP.js} +2 -2
- package/dist/{MultiFlatmap-l6aBn6x3.js → MultiFlatmap-11L97ycJ.js} +6 -6
- package/dist/{Plot-CIhEip5b.js → Plot-Bd9RqyVD.js} +2 -2
- package/dist/{Scaffold-D5ZGCDvt.js → Scaffold-CySWXQxI.js} +46 -36
- package/dist/Simulation-ChUbGudZ.js +32 -0
- package/dist/{connectivities-Br3MpoAv.js → connectivities-LtlFwLOp.js} +6399 -5790
- package/dist/es-D-YkSGXX.js +436 -0
- package/dist/mapintegratedvuer.js +13061 -11664
- package/dist/mapintegratedvuer.umd.cjs +191 -190
- package/dist/style.css +1 -1
- package/package.json +7 -7
- package/src/assets/header-icon.scss +9 -3
- package/src/components/ContentBar.vue +172 -92
- package/src/components/ContentVuer.vue +41 -1
- package/src/components/ContextCard.vue +15 -3
- package/src/components/DatasetHeader.vue +4 -1
- package/src/components/DialogToolbarContent.vue +45 -4
- package/src/components/EventBus.js +8 -0
- package/src/components/MapContent.vue +85 -31
- package/src/components/ResizeSensor.vue +7 -9
- package/src/components/ScreenshotPopover.vue +526 -0
- package/src/components/SplitDialog.vue +94 -93
- package/src/components/SplitFlow.vue +158 -96
- package/src/components/scripts/utilities.js +56 -1
- package/src/components/viewers/Flatmap.vue +1 -0
- package/src/components/viewers/MultiFlatmap.vue +3 -0
- package/src/components/viewers/Scaffold.vue +25 -13
- package/src/components/viewers/Simulation.vue +23 -1
- package/src/components.d.ts +2 -0
- package/src/mixins/ContentMixin.js +23 -10
- package/src/services/screenshot.js +1186 -0
- package/src/stores/connectivities.js +38 -5
- package/src/stores/settings.js +18 -0
- package/vite.config.js +27 -4
- package/dist/Simulation-C5o5DXRb.js +0 -25
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot capture for ContentVuer panes and the whole split view.
|
|
3
|
+
*
|
|
4
|
+
* Most viewers render into WebGL canvases without `preserveDrawingBuffer`,
|
|
5
|
+
* so a plain DOM-to-image capture would produce blank maps.
|
|
6
|
+
* Each viewer therefore provides `getScreenshotSources(pixelRatio)`
|
|
7
|
+
* which re-renders its canvas and reads it back in the same tick.
|
|
8
|
+
* The canvases are temporarily covered with placeholders while the DOM is rasterised
|
|
9
|
+
* with html-to-image, then the snapshots are composited into the result
|
|
10
|
+
* under anything drawn on top of them (see `compositeLayers`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Legend/checkbox panels drawn over the viewers. `wrapper` is the whole
|
|
14
|
+
// panel including its drawer button, `content` is what `only` captures.
|
|
15
|
+
const LEGEND_PANELS = [
|
|
16
|
+
// flatmapvuer
|
|
17
|
+
{ wrapper: '.pathway-location', content: '.pathway-container' },
|
|
18
|
+
// scaffoldvuer
|
|
19
|
+
{ wrapper: '.tree-controls', content: '.tree-controls-container' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Dividers between panes, which may cross a header row that is removed.
|
|
23
|
+
const TOOLBAR_ROW_DIVIDER_SELECTORS = ['.splitpanes__splitter'];
|
|
24
|
+
|
|
25
|
+
// Spacing in CSS pixels between the points checked along a header row.
|
|
26
|
+
const TOOLBAR_ROW_SAMPLE_STEP = 4;
|
|
27
|
+
|
|
28
|
+
const BLOB_URL_LIFETIME = 60000;
|
|
29
|
+
|
|
30
|
+
// Transparent pixel for images that fail to load (e.g. CORS).
|
|
31
|
+
const IMAGE_PLACEHOLDER =
|
|
32
|
+
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
33
|
+
|
|
34
|
+
// WebKit decodes images nested in an SVG `<foreignObject>` asynchronously,
|
|
35
|
+
// per drawn size, and draws them blank until they are ready,
|
|
36
|
+
// so images such as icons would be missing.
|
|
37
|
+
// The SVG is redrawn until the result stops changing.
|
|
38
|
+
const WEBKIT_MIN_DRAWS = 3;
|
|
39
|
+
const WEBKIT_MAX_DRAWS = 10;
|
|
40
|
+
const WEBKIT_DRAW_INTERVAL = 100;
|
|
41
|
+
const FINGERPRINT_WIDTH = 128;
|
|
42
|
+
|
|
43
|
+
// Canvas limits vary by browser and version, e.g. Safari before iOS 18
|
|
44
|
+
// allowed 16,777,216 pixels (4096²), later versions 67,108,864 (8192²),
|
|
45
|
+
// Chrome/Firefox about 268,435,456 (16384²). Rather than hard-coding one
|
|
46
|
+
// value, the output size is verified at capture time (see `fitCanvasSize`),
|
|
47
|
+
// falling back through these areas when the browser cannot allocate it.
|
|
48
|
+
const CANVAS_AREA_FALLBACKS = [268435456, 67108864, 16777216];
|
|
49
|
+
const MAX_CANVAS_SIDE = 16384;
|
|
50
|
+
|
|
51
|
+
// Border drawn around view captures to match the frame around the app in the UI.
|
|
52
|
+
const SCREENSHOT_BORDER = { width: 1, color: '#dcdfe6' };
|
|
53
|
+
|
|
54
|
+
// Fraction of the pixel area kept when retrying a failed capture.
|
|
55
|
+
const RETRY_AREA_FACTOR = 0.5;
|
|
56
|
+
|
|
57
|
+
const SCREENSHOT_OVERLAY_ATTR = 'data-screenshot-overlay';
|
|
58
|
+
const SCREENSHOT_PLACEHOLDER_ATTR = 'data-screenshot-placeholder';
|
|
59
|
+
|
|
60
|
+
// WebGL snapshots are not embedded in the SVG, WebKit draws large images nested
|
|
61
|
+
// in an SVG `<foreignObject>` blank. Instead the canvases are covered with placeholders,
|
|
62
|
+
// rendered once dark and once light, and the difference between the two tells how much
|
|
63
|
+
// of each snapshot pixel shows through whatever is drawn on top (see `compositeLayers`).
|
|
64
|
+
// Unusual values so they can be swapped in the SVG source without affecting anything else.
|
|
65
|
+
const LAYER_KEY_COLOR = 'rgb(0, 0, 1)';
|
|
66
|
+
const LAYER_FILL_COLOR = 'rgb(255, 255, 254)';
|
|
67
|
+
|
|
68
|
+
export const SCREENSHOT_SCALES = [1, 2, 3, 4];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How legend panels are handled:
|
|
72
|
+
* - `exclude`: hide them (default)
|
|
73
|
+
* - `include`: keep them as they appear on screen
|
|
74
|
+
* - `only`: capture only the legend panels, each as a separate image
|
|
75
|
+
*/
|
|
76
|
+
export const SCREENSHOT_LEGEND_MODES = ['exclude', 'include', 'only'];
|
|
77
|
+
|
|
78
|
+
export const SCREENSHOT_LEGEND_SELECTORS = LEGEND_PANELS.map((panel) => panel.wrapper);
|
|
79
|
+
|
|
80
|
+
// ContentBar header of each pane, hidden unless the `toolbar` option is set.
|
|
81
|
+
export const SCREENSHOT_TOOLBAR_SELECTORS = ['.content-container > .toolbar'];
|
|
82
|
+
|
|
83
|
+
// UI controls that should not appear in the screenshot.
|
|
84
|
+
export const SCREENSHOT_HIDE_SELECTORS = [
|
|
85
|
+
'[data-screenshot-ignore]',
|
|
86
|
+
// flatmapvuer
|
|
87
|
+
'.bottom-right-control',
|
|
88
|
+
'.settings-group',
|
|
89
|
+
'.beta-popovers',
|
|
90
|
+
'.drawer-button',
|
|
91
|
+
'.minimap-resize',
|
|
92
|
+
'.maplibregl-ctrl-minimap',
|
|
93
|
+
// scaffoldvuer, the tree controls also have this class but are a legend panel,
|
|
94
|
+
// as do the primitive controls (opacity, transformation) placed inside it
|
|
95
|
+
'.control-layer:not(.tree-controls, .tree-controls *)',
|
|
96
|
+
// map-side-bar drawer tabs
|
|
97
|
+
'.open-tab',
|
|
98
|
+
'.close-tab',
|
|
99
|
+
// element-plus tooltips / loading masks
|
|
100
|
+
'.el-popper.header-popper',
|
|
101
|
+
'.el-loading-mask',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Largest canvas area known to work, lowered when a probe fails.
|
|
105
|
+
let maxCanvasArea = CANVAS_AREA_FALLBACKS[0];
|
|
106
|
+
|
|
107
|
+
// Pending captures, run one at a time (see `serialize`).
|
|
108
|
+
let captureQueue = Promise.resolve();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reasons a capture can fail, used by the UI to explain what to do:
|
|
112
|
+
* - `no-legend`: legend only capture without a legend
|
|
113
|
+
* - `failed`: the browser could not create the image, even at the lowest resolution,
|
|
114
|
+
* usually because the view is too large or complex for its memory limits
|
|
115
|
+
* - `context-lost`: a viewer lost its WebGL context (out of graphics memory)
|
|
116
|
+
* and stays blank until the page is reloaded
|
|
117
|
+
*/
|
|
118
|
+
export class ScreenshotError extends Error {
|
|
119
|
+
constructor(code, message, options) {
|
|
120
|
+
super(message, options);
|
|
121
|
+
this.name = 'ScreenshotError';
|
|
122
|
+
this.code = code;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Wait until fonts and pending layout/paint have settled.
|
|
130
|
+
*/
|
|
131
|
+
const waitForRender = async () => {
|
|
132
|
+
if (document.fonts?.ready) {
|
|
133
|
+
await document.fonts.ready;
|
|
134
|
+
}
|
|
135
|
+
await nextFrame();
|
|
136
|
+
await nextFrame();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const getMaxRenderbufferSize = () => {
|
|
140
|
+
try {
|
|
141
|
+
const canvas = document.createElement('canvas');
|
|
142
|
+
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
|
143
|
+
const size = gl?.getParameter(gl.MAX_RENDERBUFFER_SIZE);
|
|
144
|
+
gl?.getExtension('WEBGL_lose_context')?.loseContext();
|
|
145
|
+
return size || MAX_CANVAS_SIDE;
|
|
146
|
+
} catch {
|
|
147
|
+
return MAX_CANVAS_SIDE;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check whether the browser can allocate and draw to a canvas of this size.
|
|
153
|
+
* Oversized canvases fail silently (null context or blank pixels).
|
|
154
|
+
*/
|
|
155
|
+
const canvasSupportsSize = (width, height) => {
|
|
156
|
+
const canvas = document.createElement('canvas');
|
|
157
|
+
try {
|
|
158
|
+
canvas.width = width;
|
|
159
|
+
canvas.height = height;
|
|
160
|
+
const ctx = canvas.getContext('2d');
|
|
161
|
+
if (!ctx) return false;
|
|
162
|
+
ctx.fillStyle = '#000';
|
|
163
|
+
ctx.fillRect(width - 1, height - 1, 1, 1);
|
|
164
|
+
return ctx.getImageData(width - 1, height - 1, 1, 1).data[3] !== 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
} finally {
|
|
168
|
+
// Release the memory immediately, Safari keeps it otherwise.
|
|
169
|
+
canvas.width = 0;
|
|
170
|
+
canvas.height = 0;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Like `getEffectivePixelRatio`, but verifies the browser can allocate the
|
|
176
|
+
* output canvas, stepping down through `CANVAS_AREA_FALLBACKS` if not.
|
|
177
|
+
*/
|
|
178
|
+
const fitCanvasSize = (width, height, scale) => {
|
|
179
|
+
let result = getEffectivePixelRatio(width, height, scale);
|
|
180
|
+
while (
|
|
181
|
+
!canvasSupportsSize(
|
|
182
|
+
Math.round(width * result.pixelRatio),
|
|
183
|
+
Math.round(height * result.pixelRatio),
|
|
184
|
+
)
|
|
185
|
+
) {
|
|
186
|
+
const area = width * height * result.pixelRatio ** 2;
|
|
187
|
+
const nextArea = CANVAS_AREA_FALLBACKS.find((a) => a < area);
|
|
188
|
+
if (!nextArea || result.pixelRatio <= 1) break;
|
|
189
|
+
maxCanvasArea = nextArea;
|
|
190
|
+
result = { ...getEffectivePixelRatio(width, height, scale), clamped: true };
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Full size of a legend panel, including the part scrolled out of view.
|
|
197
|
+
*/
|
|
198
|
+
const getLegendSize = (node) => {
|
|
199
|
+
const style = getComputedStyle(node);
|
|
200
|
+
const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
|
201
|
+
return { width: node.offsetWidth, height: node.scrollHeight + borders };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const isClipped = (node) => {
|
|
205
|
+
const { overflowX, overflowY } = getComputedStyle(node);
|
|
206
|
+
return (
|
|
207
|
+
(overflowX !== 'visible' && node.scrollWidth > node.clientWidth) ||
|
|
208
|
+
(overflowY !== 'visible' && node.scrollHeight > node.clientHeight)
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Some panels scroll inside (e.g. the scaffoldvuer region tree),
|
|
214
|
+
* which `getLegendSize` and the root style of `renderToCanvas` cannot expand.
|
|
215
|
+
* In that case, return an off-screen copy of `legend` with those scroll containers
|
|
216
|
+
* and their ancestors grown to fit their content.
|
|
217
|
+
* The copy is placed next to the original so the same styles apply,
|
|
218
|
+
* and the panel on screen is left untouched.
|
|
219
|
+
* @returns {Object} { node, cleanup } where `node` is the element to capture.
|
|
220
|
+
*/
|
|
221
|
+
const expandLegend = (legend) => {
|
|
222
|
+
const nodes = [...legend.querySelectorAll('*')];
|
|
223
|
+
const expand = new Set();
|
|
224
|
+
nodes.filter(isClipped).forEach((node) => {
|
|
225
|
+
for (let n = node; n && n !== legend; n = n.parentElement) expand.add(n);
|
|
226
|
+
});
|
|
227
|
+
if (!expand.size) return { node: legend, cleanup: () => {} };
|
|
228
|
+
|
|
229
|
+
const copy = legend.cloneNode(true);
|
|
230
|
+
const copies = [...copy.querySelectorAll('*')];
|
|
231
|
+
nodes.forEach((node, index) => {
|
|
232
|
+
if (!expand.has(node)) return;
|
|
233
|
+
Object.assign(copies[index].style, {
|
|
234
|
+
width: 'max-content',
|
|
235
|
+
minWidth: getComputedStyle(node).width,
|
|
236
|
+
maxWidth: 'none',
|
|
237
|
+
height: 'auto',
|
|
238
|
+
maxHeight: 'none',
|
|
239
|
+
overflow: 'visible',
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
// Overridden by the root style in `captureLegends`.
|
|
243
|
+
Object.assign(copy.style, {
|
|
244
|
+
position: 'fixed',
|
|
245
|
+
left: '-100000px',
|
|
246
|
+
top: '0',
|
|
247
|
+
opacity: '0',
|
|
248
|
+
pointerEvents: 'none',
|
|
249
|
+
});
|
|
250
|
+
copy.setAttribute(SCREENSHOT_OVERLAY_ATTR, '');
|
|
251
|
+
legend.after(copy);
|
|
252
|
+
return { node: copy, cleanup: () => copy.remove() };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Find the rows of `el` that only show hidden header bars, which would be left blank.
|
|
257
|
+
* A row is kept if anything else is on top at that height,
|
|
258
|
+
* e.g. the open sidebar or a pane with its header elsewhere.
|
|
259
|
+
* @param {HTMLElement} el - Element to capture.
|
|
260
|
+
* @param {Array} hideSelectors - Selectors of nodes excluded from the capture,
|
|
261
|
+
* ignored when checking what is on top (e.g. the screenshot popover).
|
|
262
|
+
* @returns {Array} { top, bottom } in CSS pixels relative to `el`, sorted and merged.
|
|
263
|
+
*/
|
|
264
|
+
const getToolbarRows = (el, hideSelectors = SCREENSHOT_HIDE_SELECTORS) => {
|
|
265
|
+
const rect = el.getBoundingClientRect();
|
|
266
|
+
const toolbarSelector = SCREENSHOT_TOOLBAR_SELECTORS.join(',');
|
|
267
|
+
const allowedSelector = [...SCREENSHOT_TOOLBAR_SELECTORS, ...TOOLBAR_ROW_DIVIDER_SELECTORS].join(
|
|
268
|
+
',',
|
|
269
|
+
);
|
|
270
|
+
const ignoredSelector = hideSelectors.join(',');
|
|
271
|
+
|
|
272
|
+
const edges = new Set();
|
|
273
|
+
el.querySelectorAll(toolbarSelector).forEach((toolbar) => {
|
|
274
|
+
if (getComputedStyle(toolbar).visibility !== 'visible') return;
|
|
275
|
+
const { top, bottom } = toolbar.getBoundingClientRect();
|
|
276
|
+
const rowTop = Math.max(top, rect.top) - rect.top;
|
|
277
|
+
const rowBottom = Math.min(bottom, rect.bottom) - rect.top;
|
|
278
|
+
if (rowBottom > rowTop) {
|
|
279
|
+
edges.add(rowTop);
|
|
280
|
+
edges.add(rowBottom);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const isToolbarOnly = (y) => {
|
|
285
|
+
for (let x = rect.left + 0.5; x < rect.right; x += TOOLBAR_ROW_SAMPLE_STEP) {
|
|
286
|
+
const node = document
|
|
287
|
+
.elementsFromPoint(x, y)
|
|
288
|
+
.find((n) => !ignoredSelector || !n.closest(ignoredSelector));
|
|
289
|
+
if (!node || !el.contains(node) || !node.closest(allowedSelector)) return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Check each strip between consecutive edges,
|
|
295
|
+
// as headers of side by side panes may not line up exactly.
|
|
296
|
+
const sorted = [...edges].sort((a, b) => a - b);
|
|
297
|
+
const rows = [];
|
|
298
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
299
|
+
const top = sorted[i];
|
|
300
|
+
const bottom = sorted[i + 1];
|
|
301
|
+
if (!isToolbarOnly(rect.top + (top + bottom) / 2)) continue;
|
|
302
|
+
const last = rows[rows.length - 1];
|
|
303
|
+
if (last && last.bottom === top) {
|
|
304
|
+
last.bottom = bottom;
|
|
305
|
+
} else {
|
|
306
|
+
rows.push({ top, bottom });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return rows;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Convert `rows` from `getToolbarRows` to canvas pixels.
|
|
314
|
+
*/
|
|
315
|
+
const toCanvasRows = (rows, pixelRatio) =>
|
|
316
|
+
rows
|
|
317
|
+
.map(({ top, bottom }) => ({
|
|
318
|
+
top: Math.round(top * pixelRatio),
|
|
319
|
+
bottom: Math.round(bottom * pixelRatio),
|
|
320
|
+
}))
|
|
321
|
+
.filter(({ top, bottom }) => bottom > top);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Return a copy of `canvas` without `rows` (in canvas pixels),
|
|
325
|
+
* framed with `SCREENSHOT_BORDER`, in a single copy to limit memory use.
|
|
326
|
+
*/
|
|
327
|
+
const frameCanvas = (canvas, rows, pixelRatio) => {
|
|
328
|
+
const border = Math.max(1, Math.round(SCREENSHOT_BORDER.width * pixelRatio));
|
|
329
|
+
const removed = rows.reduce((sum, { top, bottom }) => sum + bottom - top, 0);
|
|
330
|
+
const framed = document.createElement('canvas');
|
|
331
|
+
framed.width = canvas.width + border * 2;
|
|
332
|
+
framed.height = canvas.height - removed + border * 2;
|
|
333
|
+
const ctx = framed.getContext('2d');
|
|
334
|
+
if (!ctx) {
|
|
335
|
+
releaseCanvas(canvas);
|
|
336
|
+
throw new Error('Failed to allocate canvas');
|
|
337
|
+
}
|
|
338
|
+
ctx.fillStyle = SCREENSHOT_BORDER.color;
|
|
339
|
+
ctx.fillRect(0, 0, framed.width, framed.height);
|
|
340
|
+
let source = 0;
|
|
341
|
+
let target = border;
|
|
342
|
+
[...rows, { top: canvas.height, bottom: canvas.height }].forEach(({ top, bottom }) => {
|
|
343
|
+
const height = top - source;
|
|
344
|
+
if (height > 0) {
|
|
345
|
+
ctx.drawImage(canvas, 0, source, canvas.width, height, border, target, canvas.width, height);
|
|
346
|
+
target += height;
|
|
347
|
+
}
|
|
348
|
+
source = bottom;
|
|
349
|
+
});
|
|
350
|
+
releaseCanvas(canvas);
|
|
351
|
+
return framed;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Free the memory of a canvas that is no longer needed.
|
|
356
|
+
* WebKit limits the total memory of all canvases and keeps it until garbage collection,
|
|
357
|
+
* so large captures fail otherwise.
|
|
358
|
+
*/
|
|
359
|
+
const releaseCanvas = (canvas) => {
|
|
360
|
+
canvas.width = 0;
|
|
361
|
+
canvas.height = 0;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Size of a view capture including `SCREENSHOT_BORDER`.
|
|
366
|
+
*/
|
|
367
|
+
const getBorderedSize = ({ width, height }) => ({
|
|
368
|
+
width: width + SCREENSHOT_BORDER.width * 2,
|
|
369
|
+
height: height + SCREENSHOT_BORDER.width * 2,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Place `overlay` exactly on top of `target` without affecting layout.
|
|
374
|
+
* It is transparent on screen and only shows in the capture (see `withComputedCssText`).
|
|
375
|
+
*/
|
|
376
|
+
const placeOverlay = (target, overlay) => {
|
|
377
|
+
overlay.setAttribute(SCREENSHOT_OVERLAY_ATTR, '');
|
|
378
|
+
overlay.setAttribute(SCREENSHOT_PLACEHOLDER_ATTR, '');
|
|
379
|
+
Object.assign(overlay.style, {
|
|
380
|
+
position: 'absolute',
|
|
381
|
+
left: '0px',
|
|
382
|
+
top: '0px',
|
|
383
|
+
margin: '0',
|
|
384
|
+
pointerEvents: 'none',
|
|
385
|
+
opacity: '0',
|
|
386
|
+
});
|
|
387
|
+
target.after(overlay);
|
|
388
|
+
const targetRect = target.getBoundingClientRect();
|
|
389
|
+
const overlayRect = overlay.getBoundingClientRect();
|
|
390
|
+
Object.assign(overlay.style, {
|
|
391
|
+
left: `${targetRect.left - overlayRect.left}px`,
|
|
392
|
+
top: `${targetRect.top - overlayRect.top}px`,
|
|
393
|
+
width: `${targetRect.width}px`,
|
|
394
|
+
height: `${targetRect.height}px`,
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const loadImage = (src) =>
|
|
399
|
+
new Promise((resolve, reject) => {
|
|
400
|
+
const img = new Image();
|
|
401
|
+
img.onload = () => resolve(img);
|
|
402
|
+
img.onerror = () => reject(new Error('Failed to load image'));
|
|
403
|
+
img.src = src;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Background colour showing through transparent pixels of `node`,
|
|
408
|
+
* from the closest ancestor with an opaque background.
|
|
409
|
+
*/
|
|
410
|
+
const getBackdropColor = (node) => {
|
|
411
|
+
for (let n = node.parentElement; n; n = n.parentElement) {
|
|
412
|
+
const color = getComputedStyle(n).backgroundColor;
|
|
413
|
+
const alpha = color.match(/rgba\(.*,\s*([\d.]+)\)/)?.[1];
|
|
414
|
+
if (color !== 'transparent' && (alpha === undefined || parseFloat(alpha) > 0)) return color;
|
|
415
|
+
}
|
|
416
|
+
return '#ffffff';
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Place placeholders on WebGL canvases and iframes.
|
|
421
|
+
* The snapshots of the WebGL canvases are composited later (see `compositeLayers`).
|
|
422
|
+
* @returns {Object} { excluded, layers, cleanup } where `excluded` is the set of nodes
|
|
423
|
+
* to be excluded and `layers` the snapshots with their position relative to `el`.
|
|
424
|
+
*/
|
|
425
|
+
const prepareOverlays = (el, sources) => {
|
|
426
|
+
const excluded = new Set();
|
|
427
|
+
const overlays = [];
|
|
428
|
+
const layers = [];
|
|
429
|
+
const cleanup = () => overlays.forEach((overlay) => overlay.remove());
|
|
430
|
+
const elRect = el.getBoundingClientRect();
|
|
431
|
+
|
|
432
|
+
sources.forEach(({ canvas, image }) => {
|
|
433
|
+
if (!canvas || !image || !el.contains(canvas)) return;
|
|
434
|
+
const placeholder = document.createElement('div');
|
|
435
|
+
placeholder.style.background = LAYER_KEY_COLOR;
|
|
436
|
+
placeOverlay(canvas, placeholder);
|
|
437
|
+
overlays.push(placeholder);
|
|
438
|
+
excluded.add(canvas);
|
|
439
|
+
const rect = canvas.getBoundingClientRect();
|
|
440
|
+
layers.push({
|
|
441
|
+
image,
|
|
442
|
+
backdrop: getBackdropColor(canvas),
|
|
443
|
+
x: rect.left - elRect.left,
|
|
444
|
+
y: rect.top - elRect.top,
|
|
445
|
+
width: rect.width,
|
|
446
|
+
height: rect.height,
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Cross-origin iframes cannot be captured.
|
|
451
|
+
el.querySelectorAll('iframe').forEach((iframe) => {
|
|
452
|
+
const placeholder = document.createElement('div');
|
|
453
|
+
placeholder.textContent = 'Embedded content cannot be captured';
|
|
454
|
+
Object.assign(placeholder.style, {
|
|
455
|
+
display: 'flex',
|
|
456
|
+
alignItems: 'center',
|
|
457
|
+
justifyContent: 'center',
|
|
458
|
+
background: '#f5f7fa',
|
|
459
|
+
color: '#606266',
|
|
460
|
+
font: '14px Asap, Arial, sans-serif',
|
|
461
|
+
});
|
|
462
|
+
placeOverlay(iframe, placeholder);
|
|
463
|
+
overlays.push(placeholder);
|
|
464
|
+
excluded.add(iframe);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return { excluded, layers, cleanup };
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const canvasToBlob = (canvas, type) =>
|
|
471
|
+
new Promise((resolve, reject) => {
|
|
472
|
+
canvas.toBlob(
|
|
473
|
+
(blob) => (blob ? resolve(blob) : reject(new Error('Failed to encode image'))),
|
|
474
|
+
type,
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const downloadBlob = (blob, filename) => {
|
|
479
|
+
const url = URL.createObjectURL(blob);
|
|
480
|
+
const link = document.createElement('a');
|
|
481
|
+
link.href = url;
|
|
482
|
+
link.download = filename;
|
|
483
|
+
document.body.appendChild(link);
|
|
484
|
+
link.click();
|
|
485
|
+
link.remove();
|
|
486
|
+
// Safari reads the blob after `click` returns, and fails the download
|
|
487
|
+
// ("WebKitBlobResource error 1") if the URL is revoked too early,
|
|
488
|
+
// which happens with large images.
|
|
489
|
+
setTimeout(() => URL.revokeObjectURL(url), BLOB_URL_LIFETIME);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
493
|
+
|
|
494
|
+
// Safari and every iOS browser use WebKit.
|
|
495
|
+
const isWebKit = () => navigator.vendor === 'Apple Computer, Inc.';
|
|
496
|
+
|
|
497
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Return a function giving a downscaled copy of `canvas` pixels as a string.
|
|
501
|
+
*/
|
|
502
|
+
const createFingerprint = (canvas) => {
|
|
503
|
+
const probe = document.createElement('canvas');
|
|
504
|
+
probe.width = Math.min(FINGERPRINT_WIDTH, canvas.width);
|
|
505
|
+
probe.height = Math.max(1, Math.round((probe.width * canvas.height) / canvas.width));
|
|
506
|
+
const ctx = probe.getContext('2d', { willReadFrequently: true });
|
|
507
|
+
return () => {
|
|
508
|
+
ctx.clearRect(0, 0, probe.width, probe.height);
|
|
509
|
+
ctx.drawImage(canvas, 0, 0, probe.width, probe.height);
|
|
510
|
+
try {
|
|
511
|
+
return ctx.getImageData(0, 0, probe.width, probe.height).data.join();
|
|
512
|
+
} catch {
|
|
513
|
+
// Unreadable, fall back to the minimum number of draws
|
|
514
|
+
return '';
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Whether a fingerprint only contains white opaque pixels, i.e. only the background
|
|
521
|
+
* was drawn. Browsers fail silently when the SVG is too large to rasterise.
|
|
522
|
+
*/
|
|
523
|
+
const isBlankFingerprint = (fingerprint) =>
|
|
524
|
+
fingerprint !== '' && fingerprint.split(',').every((value) => value === '255');
|
|
525
|
+
|
|
526
|
+
const redrawUntilStable = async (fingerprint, draw, isBlank) => {
|
|
527
|
+
let previous = fingerprint();
|
|
528
|
+
for (let count = 2; count <= WEBKIT_MAX_DRAWS; count++) {
|
|
529
|
+
await delay(WEBKIT_DRAW_INTERVAL * (count - 1));
|
|
530
|
+
await nextFrame();
|
|
531
|
+
draw();
|
|
532
|
+
const current = fingerprint();
|
|
533
|
+
// A blank result may still be decoding, keep waiting.
|
|
534
|
+
if (count >= WEBKIT_MIN_DRAWS && current === previous && !isBlank(current)) return;
|
|
535
|
+
previous = current;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Serialise a computed style the same way html-to-image copies it property by property.
|
|
541
|
+
*/
|
|
542
|
+
const formatComputedStyle = (node, style) => {
|
|
543
|
+
let text = '';
|
|
544
|
+
for (let i = 0; i < style.length; i++) {
|
|
545
|
+
const name = style[i];
|
|
546
|
+
let value = style.getPropertyValue(name);
|
|
547
|
+
if (name === 'font-size' && value.endsWith('px')) {
|
|
548
|
+
value = `${Math.floor(parseFloat(value)) - 0.1}px`;
|
|
549
|
+
}
|
|
550
|
+
if (name === 'display' && value === 'inline' && node instanceof HTMLIFrameElement) {
|
|
551
|
+
value = 'block';
|
|
552
|
+
}
|
|
553
|
+
if (name === 'd' && node.getAttribute?.('d')) {
|
|
554
|
+
value = `path(${node.getAttribute('d')})`;
|
|
555
|
+
}
|
|
556
|
+
text += `${name}: ${value}; `;
|
|
557
|
+
}
|
|
558
|
+
return text;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* When the computed `cssText` is empty (Safari, Firefox),
|
|
563
|
+
* html-to-image copies each of the ~500 properties with `setProperty`,
|
|
564
|
+
* which takes tens of seconds in Safari on a flatmap and freezes the page.
|
|
565
|
+
* While `callback` runs, provide `cssText` so each style is assigned at once.
|
|
566
|
+
* Placeholders, transparent on screen so the viewers stay visible during the capture,
|
|
567
|
+
* are also made opaque in the copy.
|
|
568
|
+
*/
|
|
569
|
+
const withComputedCssText = async (callback) => {
|
|
570
|
+
const getComputedStyle = window.getComputedStyle;
|
|
571
|
+
const hasCssText = !!getComputedStyle(document.documentElement).cssText;
|
|
572
|
+
window.getComputedStyle = function (node, pseudo) {
|
|
573
|
+
const style = getComputedStyle.call(window, node, pseudo);
|
|
574
|
+
const isPlaceholder = !pseudo && node?.hasAttribute?.(SCREENSHOT_PLACEHOLDER_ATTR);
|
|
575
|
+
if (isPlaceholder || (!hasCssText && !pseudo && !style.cssText)) {
|
|
576
|
+
const cssText = style.cssText;
|
|
577
|
+
let text;
|
|
578
|
+
try {
|
|
579
|
+
Object.defineProperty(style, 'cssText', {
|
|
580
|
+
configurable: true,
|
|
581
|
+
// The last declaration wins
|
|
582
|
+
get: () =>
|
|
583
|
+
(text ??=
|
|
584
|
+
(cssText || formatComputedStyle(node, style)) +
|
|
585
|
+
(isPlaceholder ? ' opacity: 1;' : '')),
|
|
586
|
+
});
|
|
587
|
+
} catch {
|
|
588
|
+
// Keep the per-property copy
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return style;
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
return await callback();
|
|
595
|
+
} finally {
|
|
596
|
+
window.getComputedStyle = getComputedStyle;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Composite the WebGL snapshots of `layers` into `canvas`,
|
|
602
|
+
* which was drawn from `svg` with the placeholders in `LAYER_KEY_COLOR`.
|
|
603
|
+
* The area of each layer is drawn again with the placeholders in `LAYER_FILL_COLOR`.
|
|
604
|
+
* The two match where something opaque covers the placeholder, differ by about 255
|
|
605
|
+
* where nothing does, and are in between for semi-transparent or anti-aliased content
|
|
606
|
+
* on top (e.g. markers, tooltips, legends). Adding `snapshot × (light − dark) / 255`
|
|
607
|
+
* to the dark render therefore draws the snapshot under that content, as the browser does.
|
|
608
|
+
* Each area only needs a canvas of its own size, and each snapshot is drawn only once.
|
|
609
|
+
*/
|
|
610
|
+
const compositeLayers = async (canvas, svg, size, layers) => {
|
|
611
|
+
const key = encodeURIComponent(LAYER_KEY_COLOR);
|
|
612
|
+
if (!svg.includes(key)) throw new Error('Placeholders are missing from the capture');
|
|
613
|
+
const light = await loadImage(svg.replaceAll(key, encodeURIComponent(LAYER_FILL_COLOR)));
|
|
614
|
+
const ctx = canvas.getContext('2d');
|
|
615
|
+
// From CSS pixels to canvas pixels
|
|
616
|
+
const scaleX = canvas.width / size.width;
|
|
617
|
+
const scaleY = canvas.height / size.height;
|
|
618
|
+
try {
|
|
619
|
+
await light.decode?.();
|
|
620
|
+
for (const layer of layers) {
|
|
621
|
+
const left = Math.max(0, Math.round(layer.x * scaleX));
|
|
622
|
+
const top = Math.max(0, Math.round(layer.y * scaleY));
|
|
623
|
+
const width = Math.min(canvas.width, Math.round((layer.x + layer.width) * scaleX)) - left;
|
|
624
|
+
const height = Math.min(canvas.height, Math.round((layer.y + layer.height) * scaleY)) - top;
|
|
625
|
+
if (width <= 0 || height <= 0) continue;
|
|
626
|
+
|
|
627
|
+
const { image } = layer;
|
|
628
|
+
// Fill transparent pixels with what is behind the WebGL canvas,
|
|
629
|
+
// as the snapshot must be opaque to be multiplied.
|
|
630
|
+
const imageCtx = image.getContext('2d');
|
|
631
|
+
imageCtx.globalCompositeOperation = 'destination-over';
|
|
632
|
+
imageCtx.fillStyle = layer.backdrop;
|
|
633
|
+
imageCtx.fillRect(0, 0, image.width, image.height);
|
|
634
|
+
|
|
635
|
+
const area = document.createElement('canvas');
|
|
636
|
+
try {
|
|
637
|
+
area.width = width;
|
|
638
|
+
area.height = height;
|
|
639
|
+
const areaCtx = area.getContext('2d');
|
|
640
|
+
if (!areaCtx) throw new Error('Failed to allocate canvas');
|
|
641
|
+
const drawLight = () => {
|
|
642
|
+
areaCtx.fillStyle = '#ffffff';
|
|
643
|
+
areaCtx.fillRect(0, 0, width, height);
|
|
644
|
+
areaCtx.drawImage(
|
|
645
|
+
light,
|
|
646
|
+
left / scaleX,
|
|
647
|
+
top / scaleY,
|
|
648
|
+
width / scaleX,
|
|
649
|
+
height / scaleY,
|
|
650
|
+
0,
|
|
651
|
+
0,
|
|
652
|
+
width,
|
|
653
|
+
height,
|
|
654
|
+
);
|
|
655
|
+
};
|
|
656
|
+
drawLight();
|
|
657
|
+
if (isWebKit()) {
|
|
658
|
+
await redrawUntilStable(createFingerprint(area), drawLight, isBlankFingerprint);
|
|
659
|
+
}
|
|
660
|
+
areaCtx.globalCompositeOperation = 'difference';
|
|
661
|
+
areaCtx.drawImage(canvas, left, top, width, height, 0, 0, width, height);
|
|
662
|
+
const imageScaleX = image.width / layer.width;
|
|
663
|
+
const imageScaleY = image.height / layer.height;
|
|
664
|
+
areaCtx.globalCompositeOperation = 'multiply';
|
|
665
|
+
areaCtx.drawImage(
|
|
666
|
+
image,
|
|
667
|
+
(left / scaleX - layer.x) * imageScaleX,
|
|
668
|
+
(top / scaleY - layer.y) * imageScaleY,
|
|
669
|
+
(width / scaleX) * imageScaleX,
|
|
670
|
+
(height / scaleY) * imageScaleY,
|
|
671
|
+
0,
|
|
672
|
+
0,
|
|
673
|
+
width,
|
|
674
|
+
height,
|
|
675
|
+
);
|
|
676
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
677
|
+
ctx.drawImage(area, left, top);
|
|
678
|
+
} finally {
|
|
679
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
680
|
+
releaseCanvas(area);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
} finally {
|
|
684
|
+
light.removeAttribute('src');
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Rasterise `el` with html-to-image, skipping `excluded` nodes
|
|
690
|
+
* and nodes matching `hideSelectors`, then composite `layers` (see `compositeLayers`).
|
|
691
|
+
*/
|
|
692
|
+
const renderToCanvas = async (el, options) => {
|
|
693
|
+
const {
|
|
694
|
+
pixelRatio,
|
|
695
|
+
width,
|
|
696
|
+
height,
|
|
697
|
+
style,
|
|
698
|
+
excluded = new Set(),
|
|
699
|
+
hideSelectors = [],
|
|
700
|
+
layers = [],
|
|
701
|
+
} = options;
|
|
702
|
+
const { toSvg } = await import('html-to-image');
|
|
703
|
+
const hideSelector = hideSelectors.join(',');
|
|
704
|
+
const backgroundColor = '#ffffff';
|
|
705
|
+
// Draw the SVG ourselves rather than with `toCanvas`, to handle WebKit.
|
|
706
|
+
const svg = await withComputedCssText(() =>
|
|
707
|
+
toSvg(el, {
|
|
708
|
+
width,
|
|
709
|
+
height,
|
|
710
|
+
backgroundColor,
|
|
711
|
+
// The root clone keeps its computed position (e.g. `top: 32px` on the pane container),
|
|
712
|
+
// reset it so the content starts at the origin.
|
|
713
|
+
style: {
|
|
714
|
+
margin: '0',
|
|
715
|
+
top: '0',
|
|
716
|
+
left: '0',
|
|
717
|
+
right: 'auto',
|
|
718
|
+
bottom: 'auto',
|
|
719
|
+
transform: 'none',
|
|
720
|
+
...style,
|
|
721
|
+
},
|
|
722
|
+
imagePlaceholder: IMAGE_PLACEHOLDER,
|
|
723
|
+
filter: (node) => {
|
|
724
|
+
if (excluded.has(node)) return false;
|
|
725
|
+
if (node.nodeType === Node.ELEMENT_NODE && hideSelector && node.matches(hideSelector)) {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
return true;
|
|
729
|
+
},
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const img = await loadImage(svg);
|
|
734
|
+
await img.decode?.();
|
|
735
|
+
const canvas = document.createElement('canvas');
|
|
736
|
+
try {
|
|
737
|
+
canvas.width = Math.round(width * pixelRatio);
|
|
738
|
+
canvas.height = Math.round(height * pixelRatio);
|
|
739
|
+
const ctx = canvas.getContext('2d');
|
|
740
|
+
if (!ctx) throw new Error('Failed to allocate canvas');
|
|
741
|
+
const draw = () => {
|
|
742
|
+
ctx.fillStyle = backgroundColor;
|
|
743
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
744
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
745
|
+
};
|
|
746
|
+
draw();
|
|
747
|
+
const fingerprint = createFingerprint(canvas);
|
|
748
|
+
if (isWebKit()) {
|
|
749
|
+
await redrawUntilStable(fingerprint, draw, isBlankFingerprint);
|
|
750
|
+
}
|
|
751
|
+
// The captured views always have content, a blank image means drawing failed.
|
|
752
|
+
if (isBlankFingerprint(fingerprint())) throw new Error('Captured image is blank');
|
|
753
|
+
if (layers.length) await compositeLayers(canvas, svg, { width, height }, layers);
|
|
754
|
+
return canvas;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
releaseCanvas(canvas);
|
|
757
|
+
throw error;
|
|
758
|
+
} finally {
|
|
759
|
+
img.removeAttribute('src');
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Run `attempt(pixelRatio)` at the ratio from `fitCanvasSize`.
|
|
765
|
+
* Browsers often fail silently (blank image, null blob, image load error)
|
|
766
|
+
* when the output is too large for their memory limits, even though the
|
|
767
|
+
* canvas size probe passed, so retry at lower ratios until the ratio reaches 1.
|
|
768
|
+
* The lowered area limit is kept for later captures and size previews.
|
|
769
|
+
* @returns {Object} the result of `attempt` with `pixelRatio`, `clamped`,
|
|
770
|
+
* and `reduced` when a retry lowered the ratio.
|
|
771
|
+
* @throws {ScreenshotError} `failed` when the attempt at ratio 1 also fails.
|
|
772
|
+
*/
|
|
773
|
+
const withSizeFallback = async (width, height, scale, attempt) => {
|
|
774
|
+
let { pixelRatio, clamped } = fitCanvasSize(width, height, scale);
|
|
775
|
+
let reduced = false;
|
|
776
|
+
for (;;) {
|
|
777
|
+
try {
|
|
778
|
+
const result = await attempt(pixelRatio);
|
|
779
|
+
return { ...result, pixelRatio, clamped, reduced };
|
|
780
|
+
} catch (error) {
|
|
781
|
+
// Retrying does not help, the viewer is blank until the page is reloaded.
|
|
782
|
+
if (error instanceof ScreenshotError) throw error;
|
|
783
|
+
if (pixelRatio <= 1) {
|
|
784
|
+
throw new ScreenshotError('failed', 'Failed to create the screenshot', { cause: error });
|
|
785
|
+
}
|
|
786
|
+
console.warn(`Screenshot failed at pixel ratio ${pixelRatio}, retrying smaller`, error);
|
|
787
|
+
maxCanvasArea = Math.floor(width * height * pixelRatio ** 2 * RETRY_AREA_FACTOR);
|
|
788
|
+
({ pixelRatio } = getEffectivePixelRatio(width, height, scale));
|
|
789
|
+
clamped = true;
|
|
790
|
+
reduced = true;
|
|
791
|
+
await nextFrame();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Encode `canvas` as PNG and release it.
|
|
798
|
+
*/
|
|
799
|
+
const encodeCanvas = async (canvas) => {
|
|
800
|
+
try {
|
|
801
|
+
return await canvasToBlob(canvas, 'image/png');
|
|
802
|
+
} finally {
|
|
803
|
+
releaseCanvas(canvas);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Capture each legend panel inside `el` as a separate PNG,
|
|
809
|
+
* expanded to show all its content and visible even when the drawer is closed.
|
|
810
|
+
*/
|
|
811
|
+
const captureLegends = async (el, options) => {
|
|
812
|
+
const { scale, title, hideSelectors } = options;
|
|
813
|
+
const legends = findLegends(el);
|
|
814
|
+
if (!legends.length) throw new ScreenshotError('no-legend', 'No legend to capture');
|
|
815
|
+
|
|
816
|
+
const results = [];
|
|
817
|
+
for (const [index, legend] of legends.entries()) {
|
|
818
|
+
const { node, cleanup } = expandLegend(legend);
|
|
819
|
+
let result;
|
|
820
|
+
try {
|
|
821
|
+
const { width, height } = getLegendSize(node);
|
|
822
|
+
result = await withSizeFallback(width, height, scale, async (pixelRatio) => {
|
|
823
|
+
const canvas = await renderToCanvas(node, {
|
|
824
|
+
pixelRatio,
|
|
825
|
+
width,
|
|
826
|
+
height,
|
|
827
|
+
hideSelectors,
|
|
828
|
+
style: {
|
|
829
|
+
position: 'relative',
|
|
830
|
+
maxHeight: 'none',
|
|
831
|
+
height: 'auto',
|
|
832
|
+
overflow: 'visible',
|
|
833
|
+
opacity: '1',
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
const size = { width: canvas.width, height: canvas.height };
|
|
837
|
+
return { ...size, blob: await encodeCanvas(canvas) };
|
|
838
|
+
});
|
|
839
|
+
} finally {
|
|
840
|
+
cleanup();
|
|
841
|
+
}
|
|
842
|
+
const { blob, ...info } = result;
|
|
843
|
+
const filename = getScreenshotFilename(title, {
|
|
844
|
+
scale,
|
|
845
|
+
legend: 'only',
|
|
846
|
+
width: info.width,
|
|
847
|
+
height: info.height,
|
|
848
|
+
index,
|
|
849
|
+
});
|
|
850
|
+
downloadBlob(blob, filename);
|
|
851
|
+
results.push(info);
|
|
852
|
+
}
|
|
853
|
+
return results;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Run captures one at a time, as they patch `window.getComputedStyle`,
|
|
858
|
+
* add overlays to the page and change the viewers' pixel ratio.
|
|
859
|
+
*/
|
|
860
|
+
const serialize = (task) => {
|
|
861
|
+
const run = captureQueue.then(task);
|
|
862
|
+
captureQueue = run.catch(() => {});
|
|
863
|
+
return run;
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const captureElementNow = async (el, options = {}) => {
|
|
867
|
+
const {
|
|
868
|
+
scale = 1,
|
|
869
|
+
title,
|
|
870
|
+
legend = 'exclude',
|
|
871
|
+
toolbar = false,
|
|
872
|
+
viewers = [],
|
|
873
|
+
hideSelectors = SCREENSHOT_HIDE_SELECTORS,
|
|
874
|
+
} = options;
|
|
875
|
+
if (!el) throw new Error('Nothing to capture');
|
|
876
|
+
|
|
877
|
+
await waitForRender();
|
|
878
|
+
|
|
879
|
+
if (legend === 'only') {
|
|
880
|
+
return captureLegends(el, { scale, title, hideSelectors });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const rect = el.getBoundingClientRect();
|
|
884
|
+
const { width, height } = getBorderedSize(rect);
|
|
885
|
+
|
|
886
|
+
// Find the blank header rows before the overlays are added.
|
|
887
|
+
const toolbarRows = toolbar ? [] : getToolbarRows(el, hideSelectors);
|
|
888
|
+
|
|
889
|
+
const hidden = [...hideSelectors];
|
|
890
|
+
if (legend !== 'include') hidden.push(...SCREENSHOT_LEGEND_SELECTORS);
|
|
891
|
+
if (!toolbar) hidden.push(...SCREENSHOT_TOOLBAR_SELECTORS);
|
|
892
|
+
|
|
893
|
+
const { blob, ...info } = await withSizeFallback(width, height, scale, async (pixelRatio) => {
|
|
894
|
+
const sources = [];
|
|
895
|
+
let canvas;
|
|
896
|
+
try {
|
|
897
|
+
for (const viewer of viewers) {
|
|
898
|
+
const viewerSources = await viewer?.getScreenshotSources?.(pixelRatio);
|
|
899
|
+
if (viewerSources) sources.push(...viewerSources);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const { excluded, layers, cleanup } = prepareOverlays(el, sources);
|
|
903
|
+
try {
|
|
904
|
+
canvas = await renderToCanvas(el, {
|
|
905
|
+
pixelRatio,
|
|
906
|
+
width: rect.width,
|
|
907
|
+
height: rect.height,
|
|
908
|
+
excluded,
|
|
909
|
+
hideSelectors: hidden,
|
|
910
|
+
layers,
|
|
911
|
+
});
|
|
912
|
+
} finally {
|
|
913
|
+
cleanup();
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
sources.forEach(({ image }) => image && releaseCanvas(image));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
canvas = frameCanvas(canvas, toCanvasRows(toolbarRows, pixelRatio), pixelRatio);
|
|
920
|
+
const size = { width: canvas.width, height: canvas.height };
|
|
921
|
+
return { ...size, blob: await encodeCanvas(canvas) };
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const filename = getScreenshotFilename(title, {
|
|
925
|
+
scale,
|
|
926
|
+
legend,
|
|
927
|
+
toolbar,
|
|
928
|
+
width: info.width,
|
|
929
|
+
height: info.height,
|
|
930
|
+
});
|
|
931
|
+
downloadBlob(blob, filename);
|
|
932
|
+
|
|
933
|
+
return info;
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const contextLostError = () =>
|
|
937
|
+
new ScreenshotError('context-lost', 'The WebGL context of the viewer was lost');
|
|
938
|
+
|
|
939
|
+
const assertContext = (gl) => {
|
|
940
|
+
if (gl?.isContextLost?.()) throw contextLostError();
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Copy a WebGL canvas just rendered at the requested size into a 2D canvas.
|
|
945
|
+
* Must be called in the same tick as the render, as the drawing buffer is not preserved.
|
|
946
|
+
* A copy is used rather than a data URL, which takes tens of MB at high resolutions.
|
|
947
|
+
* Throws if the browser could not provide a drawing buffer that large
|
|
948
|
+
* (it is then silently clamped), so the capture is retried smaller.
|
|
949
|
+
*/
|
|
950
|
+
const readWebGLCanvas = (canvas, gl) => {
|
|
951
|
+
assertContext(gl);
|
|
952
|
+
if (gl && (gl.drawingBufferWidth < canvas.width || gl.drawingBufferHeight < canvas.height)) {
|
|
953
|
+
throw new Error('WebGL drawing buffer is smaller than requested');
|
|
954
|
+
}
|
|
955
|
+
const copy = document.createElement('canvas');
|
|
956
|
+
copy.width = canvas.width;
|
|
957
|
+
copy.height = canvas.height;
|
|
958
|
+
const ctx = copy.getContext('2d');
|
|
959
|
+
if (!ctx) {
|
|
960
|
+
releaseCanvas(copy);
|
|
961
|
+
throw new Error('Failed to read WebGL canvas');
|
|
962
|
+
}
|
|
963
|
+
ctx.drawImage(canvas, 0, 0);
|
|
964
|
+
return copy;
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Compute the pixel ratio for the requested scale, reduced if
|
|
969
|
+
* the output would exceed known browser canvas limits.
|
|
970
|
+
*/
|
|
971
|
+
export const getEffectivePixelRatio = (width, height, scale) => {
|
|
972
|
+
const requested = (window.devicePixelRatio || 1) * scale;
|
|
973
|
+
const maxSide = Math.min(MAX_CANVAS_SIDE, getMaxRenderbufferSize());
|
|
974
|
+
let ratio = requested;
|
|
975
|
+
if (width > 0 && height > 0) {
|
|
976
|
+
ratio = Math.min(
|
|
977
|
+
ratio,
|
|
978
|
+
maxSide / width,
|
|
979
|
+
maxSide / height,
|
|
980
|
+
Math.sqrt(maxCanvasArea / (width * height)),
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
ratio = Math.max(1, Math.floor(ratio * 100) / 100);
|
|
984
|
+
return { pixelRatio: ratio, clamped: ratio < requested };
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Find the legend panels inside `el` that have content.
|
|
989
|
+
* Panels in a closed drawer are included, panels in inactive panes are not.
|
|
990
|
+
* @returns {Array} the content elements of the panels.
|
|
991
|
+
*/
|
|
992
|
+
export const findLegends = (el) => {
|
|
993
|
+
if (!el) return [];
|
|
994
|
+
const wrapperSelector = SCREENSHOT_LEGEND_SELECTORS.join(',');
|
|
995
|
+
const legends = [];
|
|
996
|
+
el.querySelectorAll(wrapperSelector).forEach((wrapper) => {
|
|
997
|
+
// Skip panels hidden with `display: none` or nested in another panel
|
|
998
|
+
if (!wrapper.getClientRects().length) return;
|
|
999
|
+
// Skip panels in inactive panes, which SplitDialog hides with `visibility: hidden`.
|
|
1000
|
+
// Closed drawers only use opacity/transform so they are still found.
|
|
1001
|
+
if (getComputedStyle(wrapper).visibility !== 'visible') return;
|
|
1002
|
+
if (wrapper.parentElement?.closest(wrapperSelector)) return;
|
|
1003
|
+
const { content } = LEGEND_PANELS.find((panel) => wrapper.matches(panel.wrapper));
|
|
1004
|
+
const node = wrapper.querySelector(content) || wrapper;
|
|
1005
|
+
const { width, height } = getLegendSize(node);
|
|
1006
|
+
if (width > 0 && height > 0 && node.textContent.trim()) {
|
|
1007
|
+
legends.push(node);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
return legends;
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Get the expected output size in pixels, used by the UI for preview.
|
|
1015
|
+
* With `legend` set to `only`, `count` is the number of images
|
|
1016
|
+
* and the size is the largest one.
|
|
1017
|
+
*/
|
|
1018
|
+
export const getOutputSize = (el, scale, legend = 'exclude', toolbar = false) => {
|
|
1019
|
+
if (!el) return { width: 0, height: 0, clamped: false, count: 0 };
|
|
1020
|
+
const sizes =
|
|
1021
|
+
legend === 'only'
|
|
1022
|
+
? findLegends(el).map((legendNode) => {
|
|
1023
|
+
const { node, cleanup } = expandLegend(legendNode);
|
|
1024
|
+
const size = getLegendSize(node);
|
|
1025
|
+
cleanup();
|
|
1026
|
+
return size;
|
|
1027
|
+
})
|
|
1028
|
+
: [
|
|
1029
|
+
{
|
|
1030
|
+
...getBorderedSize(el.getBoundingClientRect()),
|
|
1031
|
+
rows: toolbar ? [] : getToolbarRows(el),
|
|
1032
|
+
},
|
|
1033
|
+
];
|
|
1034
|
+
const result = { width: 0, height: 0, clamped: false, count: sizes.length };
|
|
1035
|
+
sizes.forEach(({ width, height, rows = [] }) => {
|
|
1036
|
+
const { pixelRatio, clamped } = getEffectivePixelRatio(width, height, scale);
|
|
1037
|
+
const removed = toCanvasRows(rows, pixelRatio).reduce(
|
|
1038
|
+
(sum, { top, bottom }) => sum + bottom - top,
|
|
1039
|
+
0,
|
|
1040
|
+
);
|
|
1041
|
+
const outWidth = Math.round(width * pixelRatio);
|
|
1042
|
+
const outHeight = Math.round(height * pixelRatio) - removed;
|
|
1043
|
+
if (outWidth * outHeight > result.width * result.height) {
|
|
1044
|
+
result.width = outWidth;
|
|
1045
|
+
result.height = outHeight;
|
|
1046
|
+
}
|
|
1047
|
+
result.clamped = result.clamped || clamped;
|
|
1048
|
+
});
|
|
1049
|
+
return result;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Build a filename describing the capture,
|
|
1054
|
+
* e.g. `Rat-flatmap_2x_with-header-and-legend_2400x1800_261021143005.png`.
|
|
1055
|
+
* Parts are separated by `_`, words within a part by `-`.
|
|
1056
|
+
* The size is the actual output, which may be smaller than requested when clamped.
|
|
1057
|
+
* The timestamp comes last and keeps repeated captures with the same options apart.
|
|
1058
|
+
* @param {String} title - Name of the captured content.
|
|
1059
|
+
* @param {Object} options - { scale, legend, toolbar, width, height, index }
|
|
1060
|
+
* where `index` numbers the images of a multi-image capture.
|
|
1061
|
+
*/
|
|
1062
|
+
export const getScreenshotFilename = (title, options = {}) => {
|
|
1063
|
+
const { scale, legend, toolbar, width, height, index } = options;
|
|
1064
|
+
const now = new Date();
|
|
1065
|
+
const stamp =
|
|
1066
|
+
`${pad(now.getFullYear() % 100)}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
1067
|
+
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
1068
|
+
const name = (title || 'screenshot')
|
|
1069
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
1070
|
+
.replace(/^-+|-+$/g, '')
|
|
1071
|
+
.slice(0, 60)
|
|
1072
|
+
.replace(/-+$/, '');
|
|
1073
|
+
const parts = [name || 'screenshot'];
|
|
1074
|
+
if (legend === 'only') {
|
|
1075
|
+
parts.push(index > 0 ? `legend-${index + 1}` : 'legend');
|
|
1076
|
+
}
|
|
1077
|
+
if (scale) parts.push(`${scale}x`);
|
|
1078
|
+
if (legend !== 'only') {
|
|
1079
|
+
const extras = [];
|
|
1080
|
+
if (toolbar) extras.push('header');
|
|
1081
|
+
if (legend === 'include') extras.push('legend');
|
|
1082
|
+
if (extras.length) parts.push(`with-${extras.join('-and-')}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (width && height) parts.push(`${width}x${height}`);
|
|
1085
|
+
parts.push(stamp);
|
|
1086
|
+
return `${parts.join('_')}.png`;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Capture `el` and download it as PNG.
|
|
1091
|
+
* Overlapping calls run one after another.
|
|
1092
|
+
*
|
|
1093
|
+
* @param {HTMLElement} el - Element to capture.
|
|
1094
|
+
* @param {Object} options
|
|
1095
|
+
* @param {Number} options.scale - Multiplier on top of the device pixel ratio.
|
|
1096
|
+
* @param {String} options.title - Name of the content, used in the filename
|
|
1097
|
+
* along with the options and output size.
|
|
1098
|
+
* @param {String} options.legend - One of `SCREENSHOT_LEGEND_MODES`.
|
|
1099
|
+
* @param {Boolean} options.toolbar - Include the header bar of each pane.
|
|
1100
|
+
* @param {Array} options.viewers - ContentVuer instances inside `el`
|
|
1101
|
+
* providing `getScreenshotSources(pixelRatio)`.
|
|
1102
|
+
* @param {Array} options.hideSelectors - Selectors of nodes to exclude.
|
|
1103
|
+
* @returns {Object} { width, height, pixelRatio, clamped, reduced },
|
|
1104
|
+
* or an array of these when `legend` is `only`.
|
|
1105
|
+
* `reduced` is set when the size was lowered after a failed attempt.
|
|
1106
|
+
* @throws {ScreenshotError} see `ScreenshotError` for the codes.
|
|
1107
|
+
*/
|
|
1108
|
+
export const captureElement = (el, options) => serialize(() => captureElementNow(el, options));
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Snapshot a three.js renderer at the given pixel ratio.
|
|
1112
|
+
* `render` must draw the scene synchronously.
|
|
1113
|
+
*/
|
|
1114
|
+
export const snapshotThreeRenderer = (zincRenderer, pixelRatio) => {
|
|
1115
|
+
const renderer = zincRenderer?.getThreeJSRenderer();
|
|
1116
|
+
const canvas = renderer.domElement;
|
|
1117
|
+
const gl = renderer.getContext?.();
|
|
1118
|
+
assertContext(gl);
|
|
1119
|
+
const originalPixelRatio = renderer.getPixelRatio();
|
|
1120
|
+
try {
|
|
1121
|
+
renderer.setPixelRatio(pixelRatio);
|
|
1122
|
+
zincRenderer.invalidate?.();
|
|
1123
|
+
zincRenderer.render();
|
|
1124
|
+
return { canvas, image: readWebGLCanvas(canvas, gl) };
|
|
1125
|
+
} finally {
|
|
1126
|
+
renderer.setPixelRatio(originalPixelRatio);
|
|
1127
|
+
zincRenderer.invalidate?.();
|
|
1128
|
+
zincRenderer.render();
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Snapshot a maplibre map at the given pixel ratio.
|
|
1134
|
+
*/
|
|
1135
|
+
export const snapshotMaplibre = async (map, pixelRatio) => {
|
|
1136
|
+
const canvas = map.getCanvas();
|
|
1137
|
+
const gl = map.painter?.context?.gl;
|
|
1138
|
+
// Checked first, as `render` never fires once the context is lost.
|
|
1139
|
+
assertContext(gl);
|
|
1140
|
+
const originalPixelRatio = map.getPixelRatio?.();
|
|
1141
|
+
// maplibre caps the drawing buffer (default 4096px), raise it temporarily.
|
|
1142
|
+
const originalMaxCanvasSize = map._maxCanvasSize;
|
|
1143
|
+
const restore = () => {
|
|
1144
|
+
if (originalMaxCanvasSize) map._maxCanvasSize = originalMaxCanvasSize;
|
|
1145
|
+
if (originalPixelRatio !== undefined) map.setPixelRatio(originalPixelRatio);
|
|
1146
|
+
};
|
|
1147
|
+
try {
|
|
1148
|
+
if (originalMaxCanvasSize) {
|
|
1149
|
+
map._maxCanvasSize = [MAX_CANVAS_SIDE, MAX_CANVAS_SIDE];
|
|
1150
|
+
}
|
|
1151
|
+
if (originalPixelRatio !== undefined) map.setPixelRatio(pixelRatio);
|
|
1152
|
+
if (typeof map.redraw === 'function') {
|
|
1153
|
+
map.redraw();
|
|
1154
|
+
const image = readWebGLCanvas(canvas, gl);
|
|
1155
|
+
restore();
|
|
1156
|
+
return { canvas, image };
|
|
1157
|
+
}
|
|
1158
|
+
const image = await new Promise((resolve, reject) => {
|
|
1159
|
+
const cleanup = () => {
|
|
1160
|
+
map.off('render', onRender);
|
|
1161
|
+
canvas.removeEventListener('webglcontextlost', onContextLost);
|
|
1162
|
+
};
|
|
1163
|
+
const onRender = () => {
|
|
1164
|
+
cleanup();
|
|
1165
|
+
try {
|
|
1166
|
+
resolve(readWebGLCanvas(canvas, gl));
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
reject(error);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
// `render` never fires once the context is lost.
|
|
1172
|
+
const onContextLost = () => {
|
|
1173
|
+
cleanup();
|
|
1174
|
+
reject(contextLostError());
|
|
1175
|
+
};
|
|
1176
|
+
map.on('render', onRender);
|
|
1177
|
+
canvas.addEventListener('webglcontextlost', onContextLost);
|
|
1178
|
+
map.triggerRepaint();
|
|
1179
|
+
});
|
|
1180
|
+
restore();
|
|
1181
|
+
return { canvas, image };
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
restore();
|
|
1184
|
+
throw error;
|
|
1185
|
+
}
|
|
1186
|
+
};
|