@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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/README.md +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws the visible page onto a canvas without reading a computed style per element.
|
|
3
|
+
*
|
|
4
|
+
* The page is cloned natively (`cloneNode(true)`, a few milliseconds for thousands of nodes), the page's own
|
|
5
|
+
* stylesheets are inlined once (cached per page, with fonts and `url()` assets embedded as data URLs), and the
|
|
6
|
+
* result is rasterized through an SVG `<foreignObject>`. The previous renderer (modern-screenshot) copied the
|
|
7
|
+
* diff of ~400 computed properties for every element and its pseudo-elements, which cost about a second of
|
|
8
|
+
* main-thread time per capture on an ordinary page and grew with the page. This one spends the main thread on
|
|
9
|
+
* the clone walk and on the browser's own SVG layout, and nothing on the network once warmed.
|
|
10
|
+
*
|
|
11
|
+
* The copy is built to land every element exactly where the person sees it, whatever is scrolled:
|
|
12
|
+
*
|
|
13
|
+
* - A scrolled element's children are shifted by its scroll offset, the window's included, so the root is never
|
|
14
|
+
* transformed and never clips the part of the page that scrolled into view.
|
|
15
|
+
* - `position: fixed` and `position: sticky` elements are pinned at their on-screen box, since neither survives a
|
|
16
|
+
* scroll offset expressed as a translation (a transform re-anchors fixed descendants; a static image has nothing
|
|
17
|
+
* to stick to). Every box is measured while the page is copied, never when it is drawn: drawing waits for
|
|
18
|
+
* fonts and pictures, and a page that scrolled on in the meantime would put its header wherever it had got to.
|
|
19
|
+
* - What is in the browser's top layer (a modal dialog with its backdrop, an open popover) is drawn open, over
|
|
20
|
+
* everything else.
|
|
21
|
+
* - What is moving (a CSS animation or transition, a script animation, one driven by scrolling) is drawn at the
|
|
22
|
+
* frame it was on.
|
|
23
|
+
* - Replaced elements (images, video, canvas, iframes, plugins) keep their on-screen box and their own look
|
|
24
|
+
* (border, margin, fit) whatever happens to their pixels, so a slow or refused asset never reflows what
|
|
25
|
+
* follows it.
|
|
26
|
+
* - A classic (non-overlay) scrollbar's gutter is kept as padding, so content beside one is not laid out wider.
|
|
27
|
+
*
|
|
28
|
+
* What is not carried over: what the page itself may not read (a frame's or plugin's content, a closed shadow
|
|
29
|
+
* tree, a picture or video from another origin that sends no CORS header, a WebGL canvas that does not keep
|
|
30
|
+
* its drawing buffer): a blank box of the same size. And styles that depend on `:hover`/`:focus` (dropped,
|
|
31
|
+
* which is what a screenshot should show anyway).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { fieldIsSensitive } from "./redact.js";
|
|
35
|
+
|
|
36
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
37
|
+
/** Attribute names SVG can parse. Framework leftovers such as `@click` or `:class` would break the image. */
|
|
38
|
+
const XML_ATTRIBUTE = /^[A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?$/;
|
|
39
|
+
// oxlint-disable-next-line no-control-regex -- these are exactly the characters XML forbids
|
|
40
|
+
const CONTROL_CHARS = /[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF]/gu;
|
|
41
|
+
const CSS_URL = /url\((['"]?)([^'")]+?)\1\)/g;
|
|
42
|
+
/** How long one asset (a font, an image) may take before the capture goes on without it. */
|
|
43
|
+
const ASSET_TIMEOUT_MS = 1500;
|
|
44
|
+
/** Elements that never contribute pixels, or that must not run inside the image. */
|
|
45
|
+
const DROPPED = new Set([
|
|
46
|
+
"SCRIPT",
|
|
47
|
+
"NOSCRIPT",
|
|
48
|
+
"STYLE",
|
|
49
|
+
"LINK",
|
|
50
|
+
"TEMPLATE",
|
|
51
|
+
"HEAD",
|
|
52
|
+
"META",
|
|
53
|
+
"TITLE",
|
|
54
|
+
"BASE",
|
|
55
|
+
"AUDIO",
|
|
56
|
+
]);
|
|
57
|
+
/** A gutter narrower than this is rounding between `offsetWidth` and `clientWidth`, not a scrollbar. */
|
|
58
|
+
const MIN_GUTTER = 4;
|
|
59
|
+
/**
|
|
60
|
+
* Animations restart from their first frame inside the image, so a popover drawn while its enter animation
|
|
61
|
+
* (`fade-in-0`, `zoom-in-95`) is at opacity 0 would be missing. Every animation is settled at its end state;
|
|
62
|
+
* one that is still running when the page is copied is held at its current frame instead (`holdMotion`).
|
|
63
|
+
*/
|
|
64
|
+
const SETTLED_MOTION =
|
|
65
|
+
"*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;" +
|
|
66
|
+
"animation-iteration-count:1!important;transition:none!important}";
|
|
67
|
+
/**
|
|
68
|
+
* The image draws no scrollbars. A classic scrollbar's gutter is already kept as padding (`reserveScrollbarGutter`);
|
|
69
|
+
* without this, an element whose scrollbar overlays on screen (macOS, headless Chrome) would get a classic one in the
|
|
70
|
+
* image and lay its content out narrower than the person sees it.
|
|
71
|
+
*/
|
|
72
|
+
const NO_SCROLLBARS = "*{scrollbar-width:none!important}";
|
|
73
|
+
const TRANSPARENT_PIXEL =
|
|
74
|
+
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
75
|
+
|
|
76
|
+
/** An address that is not a picture: an `<img>` with it is drawn the way a browser draws one that failed. */
|
|
77
|
+
const BROKEN_IMAGE = "data:image/png;base64,AA==";
|
|
78
|
+
|
|
79
|
+
export type PageImageOptions = {
|
|
80
|
+
/** Device pixels per CSS pixel in the output. */
|
|
81
|
+
scale: number;
|
|
82
|
+
/** Upper bound on the whole capture. */
|
|
83
|
+
timeout?: number;
|
|
84
|
+
/** Subtrees to leave out (the toolbar). */
|
|
85
|
+
exclude?: (element: Element) => boolean;
|
|
86
|
+
/** Painted behind the page. */
|
|
87
|
+
backgroundColor?: string;
|
|
88
|
+
/** CSS width/height of the captured area; defaults to the viewport. */
|
|
89
|
+
width?: number;
|
|
90
|
+
height?: number;
|
|
91
|
+
/** Window scroll offsets the page is shifted by. */
|
|
92
|
+
scrollX?: number;
|
|
93
|
+
scrollY?: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type Viewport = { width: number; height: number };
|
|
97
|
+
type Scroll = { left: number; top: number };
|
|
98
|
+
|
|
99
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms);
|
|
102
|
+
promise.then(
|
|
103
|
+
(value) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve(value);
|
|
106
|
+
},
|
|
107
|
+
(error) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
reject(error);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toDataUrl(bytes: ArrayBuffer, type: string): string {
|
|
116
|
+
const view = new Uint8Array(bytes);
|
|
117
|
+
let binary = "";
|
|
118
|
+
// btoa wants a binary string; building it in slices keeps the argument list bounded.
|
|
119
|
+
for (let offset = 0; offset < view.length; offset += 0x8000) {
|
|
120
|
+
binary += String.fromCharCode(...view.subarray(offset, offset + 0x8000));
|
|
121
|
+
}
|
|
122
|
+
return `data:${type || "application/octet-stream"};base64,${btoa(binary)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------------------------------------
|
|
126
|
+
* Assets: every URL is fetched at most once per page and kept as a data URL.
|
|
127
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
128
|
+
|
|
129
|
+
const assets = new Map<string, Promise<string | null>>();
|
|
130
|
+
|
|
131
|
+
/** A data URL for `url`, or null when it can't be fetched in time. Never rejects. */
|
|
132
|
+
export function assetDataUrl(url: string, timeout = ASSET_TIMEOUT_MS): Promise<string | null> {
|
|
133
|
+
if (url.startsWith("data:")) return Promise.resolve(url);
|
|
134
|
+
const cached = assets.get(url);
|
|
135
|
+
if (cached) return cached;
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
138
|
+
const request = fetch(url, {
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
mode: "cors",
|
|
141
|
+
credentials: "same-origin",
|
|
142
|
+
})
|
|
143
|
+
.then(async (response) => {
|
|
144
|
+
if (!response.ok) throw new Error(`asset ${response.status}`);
|
|
145
|
+
const type = (response.headers.get("content-type") ?? "").split(";")[0]!.trim();
|
|
146
|
+
return toDataUrl(await response.arrayBuffer(), type);
|
|
147
|
+
})
|
|
148
|
+
.catch(() => null)
|
|
149
|
+
.finally(() => clearTimeout(timer));
|
|
150
|
+
assets.set(url, request);
|
|
151
|
+
// A failure is not remembered: the next capture tries again (the network may be back).
|
|
152
|
+
void request.then((value) => {
|
|
153
|
+
if (value === null) assets.delete(url);
|
|
154
|
+
});
|
|
155
|
+
return request;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Replaces every `url()` in `cssText` with a data URL. Unfetchable ones are left as they are. */
|
|
159
|
+
export async function inlineCssUrls(cssText: string, baseHref: string): Promise<string> {
|
|
160
|
+
const found: Array<{ raw: string; quote: string; url: string }> = [];
|
|
161
|
+
cssText.replace(CSS_URL, (raw, quote: string, url: string) => {
|
|
162
|
+
// `#id` and `%23id` point inside the document (SVG masks, gradients), not at a file.
|
|
163
|
+
if (!url.startsWith("data:") && !url.startsWith("#") && !url.startsWith("%23"))
|
|
164
|
+
found.push({ raw, quote, url });
|
|
165
|
+
return raw;
|
|
166
|
+
});
|
|
167
|
+
if (!found.length) return cssText;
|
|
168
|
+
const resolved = await Promise.all(
|
|
169
|
+
found.map(async (item) => {
|
|
170
|
+
let absolute: string;
|
|
171
|
+
try {
|
|
172
|
+
absolute = new URL(item.url, baseHref).href;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const data = await assetDataUrl(absolute);
|
|
177
|
+
return data ? { raw: item.raw, data } : null;
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
let out = cssText;
|
|
181
|
+
for (const item of resolved) {
|
|
182
|
+
if (item) out = out.split(item.raw).join(`url("${item.data}")`);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------------------------------------------
|
|
188
|
+
* Styles: the page's stylesheets as one string, cached until the set of sheets changes.
|
|
189
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
190
|
+
|
|
191
|
+
type StyleSource = { key: string; ownHref: string; text: string | null; href: string | null };
|
|
192
|
+
|
|
193
|
+
/** The toolbar's own sheets: never part of the picture. */
|
|
194
|
+
function ownSheets(doc: Document) {
|
|
195
|
+
return new Set(
|
|
196
|
+
Array.from(
|
|
197
|
+
doc.querySelectorAll<HTMLStyleElement>('style[id^="cs-style-"]'),
|
|
198
|
+
(node) => node.sheet,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Text of one sheet's rules, following same-origin `@import`s. Null when the browser hides the rules. */
|
|
204
|
+
function sheetText(sheet: CSSStyleSheet, seen: Set<CSSStyleSheet>): string | null {
|
|
205
|
+
if (seen.has(sheet)) return "";
|
|
206
|
+
seen.add(sheet);
|
|
207
|
+
let rules: CSSRuleList;
|
|
208
|
+
try {
|
|
209
|
+
rules = sheet.cssRules;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
let text = "";
|
|
214
|
+
for (const rule of rules) {
|
|
215
|
+
if (rule instanceof CSSImportRule) {
|
|
216
|
+
const nested = rule.styleSheet ? sheetText(rule.styleSheet, seen) : null;
|
|
217
|
+
text += nested ?? "";
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
text += `${rule.cssText}\n`;
|
|
221
|
+
}
|
|
222
|
+
return text;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The sheets that style the page, read in document order. */
|
|
226
|
+
export function collectStyleSources(doc: Document): StyleSource[] {
|
|
227
|
+
const sources: StyleSource[] = [];
|
|
228
|
+
const seen = new Set<CSSStyleSheet>();
|
|
229
|
+
const ours = ownSheets(doc);
|
|
230
|
+
const list = [...Array.from(doc.styleSheets), ...(doc.adoptedStyleSheets ?? [])];
|
|
231
|
+
list.forEach((sheet, index) => {
|
|
232
|
+
if (!(sheet instanceof CSSStyleSheet) || sheet.disabled || ours.has(sheet)) return;
|
|
233
|
+
const text = sheetText(sheet, seen);
|
|
234
|
+
let length = 0;
|
|
235
|
+
try {
|
|
236
|
+
length = sheet.cssRules.length;
|
|
237
|
+
} catch {
|
|
238
|
+
length = -1;
|
|
239
|
+
}
|
|
240
|
+
sources.push({
|
|
241
|
+
key: `${sheet.href ?? `inline:${index}`}#${length}`,
|
|
242
|
+
ownHref: sheet.href ?? doc.baseURI,
|
|
243
|
+
text,
|
|
244
|
+
href: sheet.href,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
return sources;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let styleCache: { key: string; text: Promise<string> } | null = null;
|
|
251
|
+
|
|
252
|
+
/** Every page style as one block of CSS with assets embedded. Cached; refreshed when a sheet is added or grows. */
|
|
253
|
+
export function pageStyleText(doc: Document = document): Promise<string> {
|
|
254
|
+
const sources = collectStyleSources(doc);
|
|
255
|
+
const key = sources.map((source) => source.key).join("|");
|
|
256
|
+
if (styleCache?.key === key) return styleCache.text;
|
|
257
|
+
const text = (async () => {
|
|
258
|
+
const parts = await Promise.all(
|
|
259
|
+
sources.map(async (source) => {
|
|
260
|
+
let css = source.text;
|
|
261
|
+
if (css === null && source.href) {
|
|
262
|
+
// Cross-origin sheet: the browser hides its rules, the network has them.
|
|
263
|
+
const data = await assetDataUrl(source.href, ASSET_TIMEOUT_MS * 2);
|
|
264
|
+
if (!data) return "";
|
|
265
|
+
try {
|
|
266
|
+
css = await (await fetch(data)).text();
|
|
267
|
+
} catch {
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!css) return "";
|
|
272
|
+
return inlineCssUrls(css, source.ownHref);
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
return parts.join("\n");
|
|
276
|
+
})();
|
|
277
|
+
styleCache = { key, text };
|
|
278
|
+
return text;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Fetches fonts and CSS assets now, so the first capture pays nothing on the network. */
|
|
282
|
+
export function prewarmPageImage(doc: Document = document) {
|
|
283
|
+
void pageStyleText(doc).catch(() => {});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------------------------------------
|
|
287
|
+
* Clone: a native deep copy, then one pass that drops what the image doesn't need and copies live state.
|
|
288
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
289
|
+
|
|
290
|
+
function intersects(rect: DOMRect, viewport: Viewport) {
|
|
291
|
+
return (
|
|
292
|
+
rect.bottom > 0 && rect.top < viewport.height && rect.right > 0 && rect.left < viewport.width
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function underExcluded(element: Element, exclude: (element: Element) => boolean): boolean {
|
|
297
|
+
for (let node: Element | null = element; node; node = node.parentElement) {
|
|
298
|
+
if (exclude(node)) return true;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The elements that paint inside the viewport, plus every ancestor of one. Elements without a box
|
|
305
|
+
* (`display: none`, `<option>`, SVG `<defs>`, `display: contents` wrappers) are not listed.
|
|
306
|
+
*/
|
|
307
|
+
export function visibleSet(
|
|
308
|
+
root: Element,
|
|
309
|
+
viewport: Viewport,
|
|
310
|
+
exclude: (element: Element) => boolean = () => false,
|
|
311
|
+
): Set<Element> {
|
|
312
|
+
const keep = new Set<Element>([root]);
|
|
313
|
+
const all = root.querySelectorAll("*");
|
|
314
|
+
for (let index = 0; index < all.length; index += 1) {
|
|
315
|
+
const element = all[index]!;
|
|
316
|
+
// The toolbar is excluded from the picture. Measuring it forces layout on every open comment.
|
|
317
|
+
if (underExcluded(element, exclude)) continue;
|
|
318
|
+
if (
|
|
319
|
+
element.getClientRects().length === 0 ||
|
|
320
|
+
!intersects(element.getBoundingClientRect(), viewport)
|
|
321
|
+
)
|
|
322
|
+
continue;
|
|
323
|
+
for (let node: Element | null = element; node && !keep.has(node); node = node.parentElement)
|
|
324
|
+
keep.add(node);
|
|
325
|
+
}
|
|
326
|
+
return keep;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* True for a `display: none` subtree (a closed menu, a dialog): it takes no space, so dropping it moves nothing.
|
|
331
|
+
* Offscreen elements with a box stay. Removing the rows above a scrolled-to spot pulls the content below them up,
|
|
332
|
+
* and the scroll offset applied afterwards then shifts that content out of the picture.
|
|
333
|
+
*/
|
|
334
|
+
function prunable(element: Element, view: Window): boolean {
|
|
335
|
+
if (element.getClientRects().length > 0) return false;
|
|
336
|
+
return element.firstElementChild !== null && view.getComputedStyle(element).display === "none";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Gives `cloned` the box `source` has on screen, whatever its pixels do inside the image. */
|
|
340
|
+
function sizeLike(source: Element, cloned: Element) {
|
|
341
|
+
if (!(source instanceof HTMLElement) || !(cloned instanceof HTMLElement)) return;
|
|
342
|
+
cloned.style.setProperty("box-sizing", "border-box", "important");
|
|
343
|
+
cloned.style.setProperty("width", `${source.offsetWidth}px`, "important");
|
|
344
|
+
cloned.style.setProperty("height", `${source.offsetHeight}px`, "important");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Holds an element's place with a transparent pixel of the same size instead of fetching anything. */
|
|
348
|
+
function placeholdImage(source: HTMLElement, cloned: HTMLElement) {
|
|
349
|
+
cloned.removeAttribute("srcset");
|
|
350
|
+
cloned.removeAttribute("sizes");
|
|
351
|
+
cloned.setAttribute("src", TRANSPARENT_PIXEL);
|
|
352
|
+
sizeLike(source, cloned);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function px(value: string) {
|
|
356
|
+
const number = parseFloat(value);
|
|
357
|
+
return Number.isFinite(number) ? number : 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function none(value: string | undefined) {
|
|
361
|
+
return !value || value === "none" || value === "normal";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** `element`'s scroll offset; the root's is the window's, which `html.scrollTop` does not always report. */
|
|
365
|
+
function scrollOf(element: Element, root: Element, rootScroll: Scroll): Scroll {
|
|
366
|
+
return element === root ? rootScroll : { left: element.scrollLeft, top: element.scrollTop };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Moves `cloned` by its parent's scroll offset, in front of whatever transform it already has: a scrolled
|
|
371
|
+
* element's children are translated rather than the element scrolled, which an image can't be. `own` is the
|
|
372
|
+
* element's own transform (its computed one, so one that comes from a class survives the inline override).
|
|
373
|
+
*/
|
|
374
|
+
function shiftByScroll(
|
|
375
|
+
cloned: Element,
|
|
376
|
+
scroll: Scroll,
|
|
377
|
+
own: { transform: string; origin: string },
|
|
378
|
+
shifted?: WeakSet<Element>,
|
|
379
|
+
) {
|
|
380
|
+
if (!scroll.top && !scroll.left) return;
|
|
381
|
+
if (!(cloned instanceof HTMLElement) && !(cloned instanceof SVGElement)) return;
|
|
382
|
+
const keep = none(own.transform) ? "" : ` ${own.transform}`;
|
|
383
|
+
cloned.style.transform = `translate(${-scroll.left}px, ${-scroll.top}px)${keep}`;
|
|
384
|
+
if (keep) cloned.style.transformOrigin = own.origin;
|
|
385
|
+
shifted?.add(cloned);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The transform `source` has on screen, read for `shiftByScroll`. */
|
|
389
|
+
function ownTransform(source: Element, view: Window) {
|
|
390
|
+
const style = view.getComputedStyle(source);
|
|
391
|
+
return { transform: style.transform, origin: style.transformOrigin };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function controlIsSensitive(
|
|
395
|
+
source: { readonly id: string; getAttribute(name: string): string | null },
|
|
396
|
+
type: string,
|
|
397
|
+
) {
|
|
398
|
+
return fieldIsSensitive({
|
|
399
|
+
type,
|
|
400
|
+
name: source.getAttribute("name") ?? "",
|
|
401
|
+
id: source.id,
|
|
402
|
+
autocomplete: source.getAttribute("autocomplete") ?? "",
|
|
403
|
+
label: source.getAttribute("aria-label") ?? source.getAttribute("placeholder") ?? "",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function copyFormState(source: Element, cloned: Element) {
|
|
408
|
+
if (source instanceof HTMLInputElement && cloned instanceof HTMLInputElement) {
|
|
409
|
+
if (source.type === "checkbox" || source.type === "radio") {
|
|
410
|
+
if (source.checked) cloned.setAttribute("checked", "");
|
|
411
|
+
else cloned.removeAttribute("checked");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (source.type === "file" || controlIsSensitive(source, source.type)) {
|
|
415
|
+
cloned.removeAttribute("value");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
cloned.setAttribute("value", source.value);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (source instanceof HTMLTextAreaElement && cloned instanceof HTMLTextAreaElement) {
|
|
422
|
+
cloned.textContent = controlIsSensitive(source, "textarea") ? "" : source.value;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (source instanceof HTMLSelectElement && cloned instanceof HTMLSelectElement) {
|
|
426
|
+
if (controlIsSensitive(source, "select")) {
|
|
427
|
+
for (const option of cloned.options) option.removeAttribute("selected");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
Array.from(cloned.options).forEach((option, index) => {
|
|
431
|
+
if (index === source.selectedIndex) option.setAttribute("selected", "");
|
|
432
|
+
else option.removeAttribute("selected");
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function scrubAttributes(cloned: Element) {
|
|
438
|
+
for (const name of cloned.getAttributeNames()) {
|
|
439
|
+
if (!XML_ATTRIBUTE.test(name)) cloned.removeAttribute(name);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Inherited colours that follow `color` unless a page says otherwise. Their computed value is a plain colour,
|
|
445
|
+
* and written inline that colour would be inherited as it stands: a link inside the element would be filled
|
|
446
|
+
* with the element's colour instead of its own.
|
|
447
|
+
*/
|
|
448
|
+
const FOLLOWS_COLOR = new Set([
|
|
449
|
+
"-webkit-text-fill-color",
|
|
450
|
+
"-webkit-text-stroke-color",
|
|
451
|
+
"text-emphasis-color",
|
|
452
|
+
"caret-color",
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
/** `element`'s computed style as an inline declaration list. Custom properties are already resolved into it. */
|
|
456
|
+
function computedStyleText(element: Element, view: Window, pseudo?: string) {
|
|
457
|
+
const computed = view.getComputedStyle(element, pseudo);
|
|
458
|
+
const colour = computed.getPropertyValue("color");
|
|
459
|
+
let text = "";
|
|
460
|
+
for (let index = 0; index < computed.length; index += 1) {
|
|
461
|
+
const name = computed[index]!;
|
|
462
|
+
if (name.startsWith("--")) continue;
|
|
463
|
+
const value = computed.getPropertyValue(name);
|
|
464
|
+
text += `${name}:${FOLLOWS_COLOR.has(name) && value === colour ? "currentcolor" : value};`;
|
|
465
|
+
}
|
|
466
|
+
return text;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Gives `cloned` the look `source` has on screen as inline style, under whatever inline style it already
|
|
471
|
+
* carries. For an element whose rules can no longer reach it: one that stands in under another tag, one whose
|
|
472
|
+
* rules live in a shadow tree, one moved to another place in the tree.
|
|
473
|
+
*/
|
|
474
|
+
function carryComputedStyle(source: Element, cloned: Element, view: Window) {
|
|
475
|
+
const own = cloned.getAttribute("style");
|
|
476
|
+
cloned.setAttribute("style", `${computedStyleText(source, view)}${own ?? ""}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function embedImage(source: HTMLImageElement, cloned: HTMLImageElement, waits: Promise<unknown>[]) {
|
|
480
|
+
cloned.removeAttribute("srcset");
|
|
481
|
+
cloned.removeAttribute("sizes");
|
|
482
|
+
cloned.removeAttribute("loading");
|
|
483
|
+
cloned.removeAttribute("decoding");
|
|
484
|
+
// The box is fixed before the pixels arrive: an image that can't be fetched in time, or at all, must not
|
|
485
|
+
// collapse to the transparent pixel and pull everything below it up.
|
|
486
|
+
sizeLike(source, cloned);
|
|
487
|
+
const url = source.currentSrc || source.src;
|
|
488
|
+
if (!url || url.startsWith("data:")) return;
|
|
489
|
+
cloned.src = TRANSPARENT_PIXEL;
|
|
490
|
+
waits.push(
|
|
491
|
+
assetDataUrl(url).then((data) => {
|
|
492
|
+
if (data) {
|
|
493
|
+
cloned.src = data;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// The page's own copy failed to load too: drawn as the page shows it, broken, with its alt text.
|
|
497
|
+
if (source.complete && !source.naturalWidth && source.alt) {
|
|
498
|
+
cloned.src = BROKEN_IMAGE;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Not fetchable (cross-origin without CORS): the decoded pixels may still be readable.
|
|
502
|
+
try {
|
|
503
|
+
if (!source.complete || !source.naturalWidth) return;
|
|
504
|
+
const canvas = document.createElement("canvas");
|
|
505
|
+
canvas.width = source.naturalWidth;
|
|
506
|
+
canvas.height = source.naturalHeight;
|
|
507
|
+
canvas.getContext("2d")?.drawImage(source, 0, 0);
|
|
508
|
+
cloned.src = canvas.toDataURL("image/png");
|
|
509
|
+
} catch {
|
|
510
|
+
// Tainted canvas: the image stays transparent.
|
|
511
|
+
}
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** `style="background-image: url(...)"` and friends: the image can't load those, so they're embedded too. */
|
|
517
|
+
function embedInlineStyleUrls(cloned: Element, waits: Promise<unknown>[]) {
|
|
518
|
+
const inline = cloned.getAttribute("style");
|
|
519
|
+
if (!inline || !inline.includes("url(")) return;
|
|
520
|
+
waits.push(
|
|
521
|
+
inlineCssUrls(inline, cloned.ownerDocument.baseURI).then((next) => {
|
|
522
|
+
if (next !== inline) cloned.setAttribute("style", next);
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Puts an `<img>` carrying `cloned`'s attributes in its place. The page's rules for `source`'s own tag
|
|
529
|
+
* (`canvas { display: block }`, `iframe { border: 0 }`, a video's `object-fit: contain`) do not match an
|
|
530
|
+
* `<img>`, so the stand-in carries the look `source` has on screen.
|
|
531
|
+
*/
|
|
532
|
+
function swapForImage(source: Element, cloned: Element, view: Window): HTMLImageElement {
|
|
533
|
+
const image = cloned.ownerDocument.createElement("img");
|
|
534
|
+
for (const name of cloned.getAttributeNames()) {
|
|
535
|
+
const value = cloned.getAttribute(name);
|
|
536
|
+
if (value !== null) image.setAttribute(name, value);
|
|
537
|
+
}
|
|
538
|
+
carryComputedStyle(source, image, view);
|
|
539
|
+
cloned.replaceWith(image);
|
|
540
|
+
return image;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function embedCanvas(source: HTMLCanvasElement, cloned: HTMLCanvasElement, view: Window) {
|
|
544
|
+
const image = swapForImage(source, cloned, view);
|
|
545
|
+
try {
|
|
546
|
+
image.src = source.toDataURL("image/png");
|
|
547
|
+
} catch {
|
|
548
|
+
image.src = TRANSPARENT_PIXEL;
|
|
549
|
+
}
|
|
550
|
+
sizeLike(source, image);
|
|
551
|
+
return image;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The frame a video is showing, when its pixels may be read; otherwise a same-size blank. */
|
|
555
|
+
function embedVideo(source: HTMLVideoElement, cloned: HTMLVideoElement, view: Window) {
|
|
556
|
+
const image = swapForImage(source, cloned, view);
|
|
557
|
+
image.removeAttribute("src");
|
|
558
|
+
image.removeAttribute("autoplay");
|
|
559
|
+
image.src = TRANSPARENT_PIXEL;
|
|
560
|
+
try {
|
|
561
|
+
if (source.readyState >= 2 && source.videoWidth) {
|
|
562
|
+
const canvas = document.createElement("canvas");
|
|
563
|
+
canvas.width = source.videoWidth;
|
|
564
|
+
canvas.height = source.videoHeight;
|
|
565
|
+
canvas.getContext("2d")?.drawImage(source, 0, 0);
|
|
566
|
+
image.src = canvas.toDataURL("image/png");
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
// A cross-origin stream taints the canvas: the box stays blank.
|
|
570
|
+
}
|
|
571
|
+
sizeLike(source, image);
|
|
572
|
+
return image;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isEmbed(element: Element): element is HTMLElement {
|
|
576
|
+
return (
|
|
577
|
+
element instanceof HTMLIFrameElement ||
|
|
578
|
+
element instanceof HTMLObjectElement ||
|
|
579
|
+
element instanceof HTMLEmbedElement
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Form values and pixels the markup alone doesn't carry. `cloned` must already be in its tree. Every replaced
|
|
585
|
+
* element keeps the box it has on screen, so what its pixels do inside the image never moves anything else.
|
|
586
|
+
* Returns the element that stands for `source` in the copy from here on: `cloned`, or the `<img>` put in its
|
|
587
|
+
* place.
|
|
588
|
+
*/
|
|
589
|
+
function carryLiveState(
|
|
590
|
+
source: Element,
|
|
591
|
+
cloned: Element,
|
|
592
|
+
visible: boolean,
|
|
593
|
+
waits: Promise<unknown>[],
|
|
594
|
+
view: Window,
|
|
595
|
+
): Element {
|
|
596
|
+
copyFormState(source, cloned);
|
|
597
|
+
if (!visible && (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement)) {
|
|
598
|
+
// Fetching or encoding pixels nobody sees would only slow the capture down.
|
|
599
|
+
const image = cloned instanceof HTMLImageElement ? cloned : swapForImage(source, cloned, view);
|
|
600
|
+
placeholdImage(source, image);
|
|
601
|
+
return image;
|
|
602
|
+
}
|
|
603
|
+
if (source instanceof HTMLImageElement && cloned instanceof HTMLImageElement) {
|
|
604
|
+
embedImage(source, cloned, waits);
|
|
605
|
+
return cloned;
|
|
606
|
+
}
|
|
607
|
+
if (source instanceof HTMLCanvasElement && cloned instanceof HTMLCanvasElement)
|
|
608
|
+
return embedCanvas(source, cloned, view);
|
|
609
|
+
if (source instanceof HTMLVideoElement && cloned instanceof HTMLVideoElement)
|
|
610
|
+
return embedVideo(source, cloned, view);
|
|
611
|
+
if (isEmbed(source)) {
|
|
612
|
+
// Nothing of a frame or plugin can be drawn; its place is kept so the page around it stays put.
|
|
613
|
+
const image = swapForImage(source, cloned, view);
|
|
614
|
+
image.removeAttribute("srcdoc");
|
|
615
|
+
placeholdImage(source, image);
|
|
616
|
+
return image;
|
|
617
|
+
}
|
|
618
|
+
return cloned;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* A custom element that shows something this page cannot read: it has a box, no children of its own and no
|
|
623
|
+
* shadow root it will hand over (a closed one). Its content can't be drawn, so its place is kept: the rules
|
|
624
|
+
* that size it (`:host`) are in the closed tree too.
|
|
625
|
+
*/
|
|
626
|
+
function holdOpaqueHost(source: Element, cloned: Element, view: Window) {
|
|
627
|
+
if (
|
|
628
|
+
source.shadowRoot ||
|
|
629
|
+
!source.localName.includes("-") ||
|
|
630
|
+
source.childNodes.length > 0 ||
|
|
631
|
+
source.getClientRects().length === 0
|
|
632
|
+
)
|
|
633
|
+
return;
|
|
634
|
+
carryComputedStyle(source, cloned, view);
|
|
635
|
+
sizeLike(source, cloned);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* A classic scrollbar takes room inside the element's box that an image has no scrollbar to fill. The gutter
|
|
640
|
+
* becomes padding, and overflow is clipped, so the content beside it is laid out at the width it has on screen.
|
|
641
|
+
* Overlay scrollbars (macOS by default) take no room and change nothing.
|
|
642
|
+
*/
|
|
643
|
+
function reserveScrollbarGutter(source: Element, cloned: Element, style: CSSStyleDeclaration) {
|
|
644
|
+
if (!(source instanceof HTMLElement) || !(cloned instanceof HTMLElement)) return;
|
|
645
|
+
const { offsetWidth, offsetHeight, clientWidth, clientHeight } = source;
|
|
646
|
+
// Inline elements report no client box; there is nothing to measure.
|
|
647
|
+
if (!clientWidth && !clientHeight) return;
|
|
648
|
+
const gutterX =
|
|
649
|
+
offsetWidth - clientWidth - px(style.borderLeftWidth) - px(style.borderRightWidth);
|
|
650
|
+
const gutterY =
|
|
651
|
+
offsetHeight - clientHeight - px(style.borderTopWidth) - px(style.borderBottomWidth);
|
|
652
|
+
if (gutterX < MIN_GUTTER && gutterY < MIN_GUTTER) return;
|
|
653
|
+
const set = (name: string, value: string) => cloned.style.setProperty(name, value, "important");
|
|
654
|
+
set("overflow", "hidden");
|
|
655
|
+
set("box-sizing", "border-box");
|
|
656
|
+
set("width", `${offsetWidth}px`);
|
|
657
|
+
set("height", `${offsetHeight}px`);
|
|
658
|
+
if (gutterX >= MIN_GUTTER) set("padding-right", `${px(style.paddingRight) + gutterX}px`);
|
|
659
|
+
if (gutterY >= MIN_GUTTER) set("padding-bottom", `${px(style.paddingBottom) + gutterY}px`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* ------------------------------------------------------------------------------------------------
|
|
663
|
+
* Motion: what is animating when the page is copied is drawn at the frame it was on.
|
|
664
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
665
|
+
|
|
666
|
+
/** The properties being animated on one element, and on its pseudo-elements (`::before` → names). */
|
|
667
|
+
type Moving = { own: Set<string>; pseudo: Map<string, Set<string>> };
|
|
668
|
+
|
|
669
|
+
/** Keys of a computed keyframe that are not properties. */
|
|
670
|
+
const KEYFRAME_KEYS = new Set(["offset", "computedOffset", "easing", "composite"]);
|
|
671
|
+
|
|
672
|
+
/** A keyframe's property key (`backgroundColor`, `cssFloat`, `--token`) as a CSS property name. */
|
|
673
|
+
function cssPropertyName(key: string) {
|
|
674
|
+
if (key.startsWith("--")) return key;
|
|
675
|
+
if (key === "cssFloat") return "float";
|
|
676
|
+
if (key === "cssOffset") return "offset";
|
|
677
|
+
const name = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
678
|
+
return /^(webkit|moz|ms)-/.test(name) ? `-${name}` : name;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Everything animating in `root`'s document right now, by the element it animates: CSS animations and
|
|
683
|
+
* transitions, script animations, and animations driven by a scroll position. Finished animations that still
|
|
684
|
+
* hold their last frame are in the list too; they read the same either way.
|
|
685
|
+
*/
|
|
686
|
+
function movingElements(root: Element): Map<Element, Moving> {
|
|
687
|
+
const found = new Map<Element, Moving>();
|
|
688
|
+
const doc = root.ownerDocument;
|
|
689
|
+
if (typeof doc.getAnimations !== "function") return found;
|
|
690
|
+
for (const animation of doc.getAnimations()) {
|
|
691
|
+
const effect = animation.effect as KeyframeEffect | null;
|
|
692
|
+
const target = effect?.target;
|
|
693
|
+
if (!effect || !target || typeof effect.getKeyframes !== "function") continue;
|
|
694
|
+
let moving = found.get(target);
|
|
695
|
+
if (!moving) {
|
|
696
|
+
moving = { own: new Set(), pseudo: new Map() };
|
|
697
|
+
found.set(target, moving);
|
|
698
|
+
}
|
|
699
|
+
let names = moving.own;
|
|
700
|
+
if (effect.pseudoElement) {
|
|
701
|
+
names = moving.pseudo.get(effect.pseudoElement) ?? new Set();
|
|
702
|
+
moving.pseudo.set(effect.pseudoElement, names);
|
|
703
|
+
}
|
|
704
|
+
for (const frame of effect.getKeyframes()) {
|
|
705
|
+
for (const key of Object.keys(frame)) {
|
|
706
|
+
if (!KEYFRAME_KEYS.has(key)) names.add(cssPropertyName(key));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return found;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Holds `cloned` at the frame `source` is on. Inside the image an animation is settled at its end (see
|
|
715
|
+
* `SETTLED_MOTION`), a transition is gone and a script animation was never there, so each animated property is
|
|
716
|
+
* written into the copy with the value it has at this instant. `!important`, because a declaration from an
|
|
717
|
+
* animation outranks an ordinary one. Returns the rules that do the same for its pseudo-elements.
|
|
718
|
+
*/
|
|
719
|
+
function holdMotion(
|
|
720
|
+
source: Element,
|
|
721
|
+
cloned: Element,
|
|
722
|
+
moving: Moving | undefined,
|
|
723
|
+
view: Window,
|
|
724
|
+
shifted: boolean,
|
|
725
|
+
tag: () => string,
|
|
726
|
+
): string {
|
|
727
|
+
if (!moving) return "";
|
|
728
|
+
if (!(cloned instanceof HTMLElement) && !(cloned instanceof SVGElement)) return "";
|
|
729
|
+
if (moving.own.size) {
|
|
730
|
+
const computed = view.getComputedStyle(source);
|
|
731
|
+
for (const name of moving.own) {
|
|
732
|
+
// The transform written for a scroll offset holds the element's own transform at this instant too.
|
|
733
|
+
const value =
|
|
734
|
+
name === "transform" && shifted ? cloned.style.transform : computed.getPropertyValue(name);
|
|
735
|
+
if (value) cloned.style.setProperty(name, value, "important");
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
let rules = "";
|
|
739
|
+
for (const [pseudo, names] of moving.pseudo) {
|
|
740
|
+
const computed = view.getComputedStyle(source, pseudo);
|
|
741
|
+
let declarations = "";
|
|
742
|
+
for (const name of names) {
|
|
743
|
+
const value = computed.getPropertyValue(name);
|
|
744
|
+
if (value) declarations += `${name}:${value}!important;`;
|
|
745
|
+
}
|
|
746
|
+
if (!declarations) continue;
|
|
747
|
+
let id = cloned.getAttribute("data-cs-held");
|
|
748
|
+
if (!id) {
|
|
749
|
+
id = tag();
|
|
750
|
+
cloned.setAttribute("data-cs-held", id);
|
|
751
|
+
}
|
|
752
|
+
rules += `[data-cs-held="${id}"]${pseudo}{${declarations}}\n`;
|
|
753
|
+
}
|
|
754
|
+
return rules;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* ------------------------------------------------------------------------------------------------
|
|
758
|
+
* Pinning: fixed, sticky and top-layer elements are placed where they are on screen.
|
|
759
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Whether `style` makes its element the containing block of fixed descendants (and so does the clone's copy):
|
|
763
|
+
* a transform, perspective, filter or backdrop-filter, `will-change` naming one of those, or layout/paint
|
|
764
|
+
* containment. `container-type` is not one: it stopped implying layout containment, so a `@container`
|
|
765
|
+
* wrapper leaves fixed descendants anchored to the viewport.
|
|
766
|
+
*/
|
|
767
|
+
function formsContainingBlock(style: CSSStyleDeclaration) {
|
|
768
|
+
return (
|
|
769
|
+
!none(style.transform) ||
|
|
770
|
+
!none(style.translate) ||
|
|
771
|
+
!none(style.rotate) ||
|
|
772
|
+
!none(style.scale) ||
|
|
773
|
+
!none(style.filter) ||
|
|
774
|
+
!none(style.backdropFilter) ||
|
|
775
|
+
!none(style.perspective) ||
|
|
776
|
+
/transform|perspective|filter/.test(style.willChange ?? "") ||
|
|
777
|
+
/paint|layout|strict|content/.test(style.contain ?? "")
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** How much of each side of a box is cut off, in CSS pixels. */
|
|
782
|
+
type Inset = { top: number; right: number; bottom: number; left: number };
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* How a sticky element's pinned copy is built:
|
|
786
|
+
* - `box`: a copy beside it.
|
|
787
|
+
* - `cell`: a table cell; its copy goes inside it (see `pinInPlace`).
|
|
788
|
+
* - `rows`: a table row or row group; its copy is a table of its own with the columns' widths.
|
|
789
|
+
*/
|
|
790
|
+
type PinLayout = "box" | "cell" | "rows";
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* One element to pin, with everything measured on the live page while it was copied. Placing it later (once
|
|
794
|
+
* the pictures in it have their pixels) reads nothing from the page: by then the page may have scrolled on.
|
|
795
|
+
*/
|
|
796
|
+
type Pinned = {
|
|
797
|
+
cloned: Element;
|
|
798
|
+
kind: "fixed" | "sticky";
|
|
799
|
+
layout: PinLayout;
|
|
800
|
+
/** Its box in the viewport, and its layout size. */
|
|
801
|
+
top: number;
|
|
802
|
+
left: number;
|
|
803
|
+
width: number;
|
|
804
|
+
height: number;
|
|
805
|
+
/** The corner of the box it is positioned from inside the clone, in the viewport. */
|
|
806
|
+
originTop: number;
|
|
807
|
+
originLeft: number;
|
|
808
|
+
/** Its computed `display`. */
|
|
809
|
+
display: string;
|
|
810
|
+
/** `cell` and `rows`: its whole computed style, for a copy its own rules no longer reach. */
|
|
811
|
+
styleText: string | null;
|
|
812
|
+
/** `cell`: where its content sits in its height (`vertical-align`, as `align-content`). */
|
|
813
|
+
alignContent: string | null;
|
|
814
|
+
/** `rows`: the table's border model, and the on-screen size of each cell. */
|
|
815
|
+
table: { collapse: string; spacing: string } | null;
|
|
816
|
+
cells: Map<Element, { width: number; height: number }> | null;
|
|
817
|
+
/** Sticky only: the part hidden by a box it scrolls in, or `hidden` when none of it shows. */
|
|
818
|
+
clip: Inset | "hidden" | null;
|
|
819
|
+
/** True when it is the containing block of the fixed boxes inside it. */
|
|
820
|
+
holdsFixed: boolean;
|
|
821
|
+
/** In the browser's top layer (a modal dialog, an open popover): drawn over everything else. */
|
|
822
|
+
topLayer: boolean;
|
|
823
|
+
/** Top layer only: the computed style of its `::backdrop`, when that paints something. */
|
|
824
|
+
backdrop: string | null;
|
|
825
|
+
/** True when nothing follows it in its parent. */
|
|
826
|
+
last: boolean;
|
|
827
|
+
/**
|
|
828
|
+
* True when it paints over its siblings only because it is positioned (no z-index of its own) and those
|
|
829
|
+
* siblings are translated in the clone for their parent's scroll offset. A translated box paints with the
|
|
830
|
+
* positioned ones, in document order, so the rows after a sticky heading would be drawn on top of it.
|
|
831
|
+
*/
|
|
832
|
+
raised: boolean;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/** Highest z-index there is: what stands in for the top layer inside the image. */
|
|
836
|
+
const TOP_LAYER_Z = "2147483647";
|
|
837
|
+
/**
|
|
838
|
+
* Hidden, empty siblings put after a sticky element's pinned copy, so the two of them count as a multiple of
|
|
839
|
+
* 2, 3, 4, 5 and 6: rows after them keep their place in `:nth-child` patterns (stripes, every third card).
|
|
840
|
+
*/
|
|
841
|
+
const SIBLING_PADDING = 59;
|
|
842
|
+
|
|
843
|
+
function clipsOverflow(value: string) {
|
|
844
|
+
return (
|
|
845
|
+
value === "hidden" ||
|
|
846
|
+
value === "auto" ||
|
|
847
|
+
value === "scroll" ||
|
|
848
|
+
value === "clip" ||
|
|
849
|
+
value === "overlay"
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function inTopLayer(element: Element) {
|
|
854
|
+
return matchesState(element, ":modal") || matchesState(element, ":popover-open");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The children of scrolled elements that must be moved for the scroll offset without a transform, because a box
|
|
859
|
+
* fixed to the window sits somewhere inside them. A translated box is what the fixed boxes inside it are
|
|
860
|
+
* positioned from, and whatever clips that box clips them: a banner, a toast or a modal written inside a
|
|
861
|
+
* column that scrolls would be cut off at the column's edges. The window's own scroll is left as it is: the
|
|
862
|
+
* viewport clips everything anyway.
|
|
863
|
+
*/
|
|
864
|
+
function unanchoredChildren(
|
|
865
|
+
keep: ReadonlySet<Element>,
|
|
866
|
+
root: Element,
|
|
867
|
+
rootScroll: Scroll,
|
|
868
|
+
view: Window,
|
|
869
|
+
): Set<Element> {
|
|
870
|
+
const found = new Set<Element>();
|
|
871
|
+
for (const element of keep) {
|
|
872
|
+
if (element === root || !(element instanceof HTMLElement)) continue;
|
|
873
|
+
const top = inTopLayer(element);
|
|
874
|
+
if (!top && view.getComputedStyle(element).position !== "fixed") continue;
|
|
875
|
+
const path: Element[] = [];
|
|
876
|
+
let anchored = false;
|
|
877
|
+
for (
|
|
878
|
+
let node = element.parentElement;
|
|
879
|
+
node && node !== root && node.parentElement !== root;
|
|
880
|
+
node = node.parentElement
|
|
881
|
+
) {
|
|
882
|
+
// A transformed ancestor is what it is fixed to on the page too. The top layer answers to no ancestor.
|
|
883
|
+
if (!top && formsContainingBlock(view.getComputedStyle(node))) {
|
|
884
|
+
anchored = true;
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
const scroll = scrollOf(node.parentElement!, root, rootScroll);
|
|
888
|
+
if (scroll.top || scroll.left) path.push(node);
|
|
889
|
+
}
|
|
890
|
+
if (!anchored) for (const node of path) found.add(node);
|
|
891
|
+
}
|
|
892
|
+
return found;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Moves `cloned` by its parent's scroll offset as a relatively positioned box. True when that could be done:
|
|
897
|
+
* a box that is already absolutely positioned, fixed or sticky can only be moved with a transform.
|
|
898
|
+
*/
|
|
899
|
+
function shiftInFlow(source: Element, cloned: Element, scroll: Scroll, view: Window) {
|
|
900
|
+
if (!(cloned instanceof HTMLElement)) return false;
|
|
901
|
+
const style = view.getComputedStyle(source);
|
|
902
|
+
const position = style.position || "static";
|
|
903
|
+
if (position !== "static" && position !== "relative") return false;
|
|
904
|
+
const relative = position === "relative";
|
|
905
|
+
const set = (name: string, value: string) => cloned.style.setProperty(name, value, "important");
|
|
906
|
+
set("position", "relative");
|
|
907
|
+
set("top", `${(relative ? px(style.top) : 0) - scroll.top}px`);
|
|
908
|
+
set("left", `${(relative ? px(style.left) : 0) - scroll.left}px`);
|
|
909
|
+
set("right", "auto");
|
|
910
|
+
set("bottom", "auto");
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function matchesState(element: Element, selector: string) {
|
|
915
|
+
try {
|
|
916
|
+
return element.matches(selector);
|
|
917
|
+
} catch {
|
|
918
|
+
// A browser that does not know the pseudo-class has nothing in that state.
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Where a `position: fixed` box inside the clone is measured from: the nearest ancestor the clone translates (a
|
|
925
|
+
* scrolled element's child) or that forms a containing block on the live page too, else the viewport (null).
|
|
926
|
+
* `from` is the live counterpart of the box's parent in the clone, the first ancestor considered.
|
|
927
|
+
* `unanchored` are the scrolled elements' children the clone moves without a transform (`unanchoredChildren`).
|
|
928
|
+
*/
|
|
929
|
+
function containingBlockInClone(
|
|
930
|
+
from: Element | null,
|
|
931
|
+
root: Element,
|
|
932
|
+
rootScroll: Scroll,
|
|
933
|
+
view: Window,
|
|
934
|
+
unanchored: ReadonlySet<Element>,
|
|
935
|
+
): Element | null {
|
|
936
|
+
for (let node = from; node && node !== root; node = node.parentElement) {
|
|
937
|
+
const parent = node.parentElement;
|
|
938
|
+
if (parent && !unanchored.has(node)) {
|
|
939
|
+
const scroll = scrollOf(parent, root, rootScroll);
|
|
940
|
+
if (scroll.top || scroll.left) return node;
|
|
941
|
+
}
|
|
942
|
+
if (formsContainingBlock(view.getComputedStyle(node))) return node;
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The part of a sticky element that the boxes it scrolls in hide. On the page those boxes clip it; its pinned
|
|
949
|
+
* copy is positioned from `block` (or the viewport), which no box between the two clips any more, so a heading
|
|
950
|
+
* scrolled out of an inner panel would be drawn on top of whatever is beside the panel.
|
|
951
|
+
*/
|
|
952
|
+
function hiddenPart(
|
|
953
|
+
source: Element,
|
|
954
|
+
rect: DOMRect,
|
|
955
|
+
block: Element | null,
|
|
956
|
+
root: Element,
|
|
957
|
+
view: Window,
|
|
958
|
+
): Inset | "hidden" | null {
|
|
959
|
+
let top = -Infinity;
|
|
960
|
+
let left = -Infinity;
|
|
961
|
+
let right = Infinity;
|
|
962
|
+
let bottom = Infinity;
|
|
963
|
+
for (
|
|
964
|
+
let node = source.parentElement;
|
|
965
|
+
node && node !== block && node !== root;
|
|
966
|
+
node = node.parentElement
|
|
967
|
+
) {
|
|
968
|
+
const style = view.getComputedStyle(node);
|
|
969
|
+
const clipsX = clipsOverflow(style.overflowX);
|
|
970
|
+
const clipsY = clipsOverflow(style.overflowY);
|
|
971
|
+
if (!clipsX && !clipsY) continue;
|
|
972
|
+
const box = node.getBoundingClientRect();
|
|
973
|
+
const edgeLeft = box.left + node.clientLeft;
|
|
974
|
+
const edgeTop = box.top + node.clientTop;
|
|
975
|
+
if (clipsX) {
|
|
976
|
+
left = Math.max(left, edgeLeft);
|
|
977
|
+
right = Math.min(right, edgeLeft + node.clientWidth);
|
|
978
|
+
}
|
|
979
|
+
if (clipsY) {
|
|
980
|
+
top = Math.max(top, edgeTop);
|
|
981
|
+
bottom = Math.min(bottom, edgeTop + node.clientHeight);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const inset = {
|
|
985
|
+
top: Math.max(0, top - rect.top),
|
|
986
|
+
right: Math.max(0, rect.right - right),
|
|
987
|
+
bottom: Math.max(0, rect.bottom - bottom),
|
|
988
|
+
left: Math.max(0, left - rect.left),
|
|
989
|
+
};
|
|
990
|
+
if (!inset.top && !inset.right && !inset.bottom && !inset.left) return null;
|
|
991
|
+
if (inset.top + inset.bottom >= rect.height || inset.left + inset.right >= rect.width)
|
|
992
|
+
return "hidden";
|
|
993
|
+
return inset;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function parentIsScrolled(source: Element, root: Element, rootScroll: Scroll) {
|
|
997
|
+
const parent = source.parentElement;
|
|
998
|
+
if (!parent) return false;
|
|
999
|
+
const scroll = scrollOf(parent, root, rootScroll);
|
|
1000
|
+
return Boolean(scroll.top || scroll.left);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** A table cell's `vertical-align` as the `align-content` that places a block's content the same way. */
|
|
1004
|
+
function alignContentFor(verticalAlign: string) {
|
|
1005
|
+
if (verticalAlign === "middle") return "center";
|
|
1006
|
+
if (verticalAlign === "bottom") return "end";
|
|
1007
|
+
return "start";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Whether a `::backdrop` with this computed style paints anything. */
|
|
1011
|
+
function paints(style: CSSStyleDeclaration) {
|
|
1012
|
+
if (style.display === "none") return false;
|
|
1013
|
+
const colour = style.backgroundColor;
|
|
1014
|
+
const clear = !colour || colour === "transparent" || /,\s*0\)$/.test(colour);
|
|
1015
|
+
return !clear || !none(style.backgroundImage) || !none(style.backdropFilter);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Measures `source` for pinning. Called while the page is copied; reads the live page, changes nothing. */
|
|
1019
|
+
function measurePinned(
|
|
1020
|
+
source: HTMLElement,
|
|
1021
|
+
cloned: Element,
|
|
1022
|
+
style: CSSStyleDeclaration,
|
|
1023
|
+
topLayer: boolean,
|
|
1024
|
+
root: Element,
|
|
1025
|
+
rootScroll: Scroll,
|
|
1026
|
+
view: Window,
|
|
1027
|
+
unanchored: ReadonlySet<Element>,
|
|
1028
|
+
): Pinned {
|
|
1029
|
+
const kind = style.position === "sticky" && !topLayer ? "sticky" : "fixed";
|
|
1030
|
+
const display = style.display;
|
|
1031
|
+
const layout: PinLayout =
|
|
1032
|
+
kind !== "sticky"
|
|
1033
|
+
? "box"
|
|
1034
|
+
: display === "table-cell"
|
|
1035
|
+
? "cell"
|
|
1036
|
+
: /^table-(row|row-group|header-group|footer-group)$/.test(display)
|
|
1037
|
+
? "rows"
|
|
1038
|
+
: "box";
|
|
1039
|
+
const rect = source.getBoundingClientRect();
|
|
1040
|
+
// A cell's copy goes inside the cell, so the cell is the first box it could be positioned from.
|
|
1041
|
+
const block = containingBlockInClone(
|
|
1042
|
+
layout === "cell" ? source : source.parentElement,
|
|
1043
|
+
root,
|
|
1044
|
+
rootScroll,
|
|
1045
|
+
view,
|
|
1046
|
+
unanchored,
|
|
1047
|
+
);
|
|
1048
|
+
let originTop = 0;
|
|
1049
|
+
let originLeft = 0;
|
|
1050
|
+
if (block) {
|
|
1051
|
+
const box = block.getBoundingClientRect();
|
|
1052
|
+
const blockStyle = view.getComputedStyle(block);
|
|
1053
|
+
originTop = box.top + px(blockStyle.borderTopWidth);
|
|
1054
|
+
originLeft = box.left + px(blockStyle.borderLeftWidth);
|
|
1055
|
+
}
|
|
1056
|
+
let table: Pinned["table"] = null;
|
|
1057
|
+
let cells: Pinned["cells"] = null;
|
|
1058
|
+
if (layout === "rows") {
|
|
1059
|
+
const owner = source.closest("table");
|
|
1060
|
+
const tableStyle = owner ? view.getComputedStyle(owner) : null;
|
|
1061
|
+
table = {
|
|
1062
|
+
collapse: tableStyle?.borderCollapse || "separate",
|
|
1063
|
+
spacing: tableStyle?.borderSpacing || "0px",
|
|
1064
|
+
};
|
|
1065
|
+
cells = new Map();
|
|
1066
|
+
for (const cell of source.querySelectorAll("td, th")) {
|
|
1067
|
+
if (cell.closest("table") !== owner) continue;
|
|
1068
|
+
const box = cell.getBoundingClientRect();
|
|
1069
|
+
cells.set(cell, { width: box.width, height: box.height });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
let backdrop: string | null = null;
|
|
1073
|
+
if (topLayer && paints(view.getComputedStyle(source, "::backdrop")))
|
|
1074
|
+
backdrop = computedStyleText(source, view, "::backdrop");
|
|
1075
|
+
return {
|
|
1076
|
+
cloned,
|
|
1077
|
+
kind,
|
|
1078
|
+
layout,
|
|
1079
|
+
top: rect.top,
|
|
1080
|
+
left: rect.left,
|
|
1081
|
+
width: source.offsetWidth,
|
|
1082
|
+
height: source.offsetHeight,
|
|
1083
|
+
originTop,
|
|
1084
|
+
originLeft,
|
|
1085
|
+
display,
|
|
1086
|
+
styleText: layout === "box" ? null : computedStyleText(source, view),
|
|
1087
|
+
alignContent: layout === "cell" ? alignContentFor(style.verticalAlign) : null,
|
|
1088
|
+
table,
|
|
1089
|
+
cells,
|
|
1090
|
+
clip: kind === "sticky" ? hiddenPart(source, rect, block, root, view) : null,
|
|
1091
|
+
holdsFixed: formsContainingBlock(style),
|
|
1092
|
+
topLayer,
|
|
1093
|
+
backdrop,
|
|
1094
|
+
last: source.nextElementSibling === null,
|
|
1095
|
+
raised:
|
|
1096
|
+
style.zIndex === "auto" && layout !== "cell" && parentIsScrolled(source, root, rootScroll),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Pins `cloned` at the box its element had on screen. A fixed element's clone sits inside whatever the clone
|
|
1102
|
+
* translated for scrolling, which would carry it along; a sticky element's clone sits where it would be unstuck.
|
|
1103
|
+
* Both are placed from their viewport box, measured against the box they're positioned from in the clone.
|
|
1104
|
+
*/
|
|
1105
|
+
function pinAtViewportBox(item: Pinned, cloned: Element) {
|
|
1106
|
+
if (!(cloned instanceof HTMLElement)) return;
|
|
1107
|
+
const set = (name: string, value: string) => cloned.style.setProperty(name, value, "important");
|
|
1108
|
+
set("position", "fixed");
|
|
1109
|
+
set("top", `${item.top - item.originTop}px`);
|
|
1110
|
+
set("left", `${item.left - item.originLeft}px`);
|
|
1111
|
+
set("right", "auto");
|
|
1112
|
+
set("bottom", "auto");
|
|
1113
|
+
set("margin", "0");
|
|
1114
|
+
set("transform", "none");
|
|
1115
|
+
set("translate", "none");
|
|
1116
|
+
set("box-sizing", "border-box");
|
|
1117
|
+
set("width", `${item.width}px`);
|
|
1118
|
+
set("height", `${item.height}px`);
|
|
1119
|
+
set("max-width", "none");
|
|
1120
|
+
set("max-height", "none");
|
|
1121
|
+
set("min-width", "0");
|
|
1122
|
+
set("min-height", "0");
|
|
1123
|
+
// Without its transform it would stop being what the fixed boxes inside it are positioned from.
|
|
1124
|
+
if (item.holdsFixed) set("will-change", "transform");
|
|
1125
|
+
if (item.clip && item.clip !== "hidden") {
|
|
1126
|
+
const { top, right, bottom, left } = item.clip;
|
|
1127
|
+
set("clip-path", `inset(${top}px ${right}px ${bottom}px ${left}px)`);
|
|
1128
|
+
}
|
|
1129
|
+
if (item.raised) set("z-index", "1");
|
|
1130
|
+
if (item.topLayer) {
|
|
1131
|
+
// An open popover is only displayed while it is in the top layer, which the image does not have.
|
|
1132
|
+
set("display", item.display);
|
|
1133
|
+
set("z-index", TOP_LAYER_Z);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** The backdrop of a top-layer element, as a box of its own under it: the whole viewport, behind `cloned`. */
|
|
1138
|
+
function drawBackdrop(item: Pinned, cloned: Element, viewport: Viewport) {
|
|
1139
|
+
if (!item.backdrop || !cloned.parentNode) return;
|
|
1140
|
+
const backdrop = cloned.ownerDocument.createElement("div");
|
|
1141
|
+
backdrop.setAttribute(
|
|
1142
|
+
"style",
|
|
1143
|
+
`${item.backdrop}display:block!important;position:fixed!important;` +
|
|
1144
|
+
`top:${-item.originTop}px!important;left:${-item.originLeft}px!important;` +
|
|
1145
|
+
`right:auto!important;bottom:auto!important;margin:0!important;transform:none!important;` +
|
|
1146
|
+
`width:${viewport.width}px!important;height:${viewport.height}px!important;` +
|
|
1147
|
+
`z-index:${TOP_LAYER_Z}!important;`,
|
|
1148
|
+
);
|
|
1149
|
+
cloned.parentNode.insertBefore(backdrop, cloned);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The sticky copy of a table row or row group. On its own, out of the table, its cells would shrink to their
|
|
1154
|
+
* text: it is made a table itself, and every cell is given the width and height it has on screen.
|
|
1155
|
+
*/
|
|
1156
|
+
function shapeRows(item: Pinned, copy: HTMLElement, sources: Map<Element, Element>) {
|
|
1157
|
+
const set = (target: HTMLElement, name: string, value: string) =>
|
|
1158
|
+
target.style.setProperty(name, value, "important");
|
|
1159
|
+
set(copy, "display", "table");
|
|
1160
|
+
set(copy, "table-layout", "fixed");
|
|
1161
|
+
set(copy, "border-collapse", item.table?.collapse ?? "separate");
|
|
1162
|
+
set(copy, "border-spacing", item.table?.spacing ?? "0px");
|
|
1163
|
+
// The copy has the shape of the clone it was made from, which has the shape `sources` knows.
|
|
1164
|
+
const copies = copy.querySelectorAll("*");
|
|
1165
|
+
const originals = item.cloned.querySelectorAll("*");
|
|
1166
|
+
for (let index = 0; index < copies.length; index += 1) {
|
|
1167
|
+
const cell = copies[index];
|
|
1168
|
+
const source = sources.get(originals[index]!);
|
|
1169
|
+
const size = source ? item.cells?.get(source) : undefined;
|
|
1170
|
+
if (!size || !(cell instanceof HTMLElement)) continue;
|
|
1171
|
+
set(cell, "box-sizing", "border-box");
|
|
1172
|
+
set(cell, "width", `${size.width}px`);
|
|
1173
|
+
set(cell, "height", `${size.height}px`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Places every pinned element. A sticky element keeps its in-flow copy (made invisible, so siblings keep their
|
|
1179
|
+
* layout) and gets a copy pinned where it is stuck. Run once the clone's pixels are in, so the copy has them.
|
|
1180
|
+
* Innermost first: a sticky bar inside a sticky panel is pinned before the panel is copied, so the panel's copy
|
|
1181
|
+
* holds the bar where it is seen, not where it would be unstuck.
|
|
1182
|
+
*
|
|
1183
|
+
* A sticky table cell's copy goes inside the in-flow cell, not beside it: Chrome gives a positioned `<th>` or
|
|
1184
|
+
* `<td>` that sits in a row a column slot of its own, which shifts every column of the table under it. Nested,
|
|
1185
|
+
* the copy no longer matches selectors such as `tr > th` or `th:first-child`, so it carries the cell's computed
|
|
1186
|
+
* style inline, and the in-flow cell is hidden with `visibility`, which the copy can turn back on (`opacity`
|
|
1187
|
+
* would fade it too).
|
|
1188
|
+
*/
|
|
1189
|
+
function pinInPlace(pinned: Pinned[], viewport: Viewport, sources: Map<Element, Element>) {
|
|
1190
|
+
for (let index = pinned.length - 1; index >= 0; index -= 1) {
|
|
1191
|
+
const item = pinned[index]!;
|
|
1192
|
+
const { cloned } = item;
|
|
1193
|
+
if (!(cloned instanceof HTMLElement)) continue;
|
|
1194
|
+
if (item.kind === "fixed") {
|
|
1195
|
+
pinAtViewportBox(item, cloned);
|
|
1196
|
+
if (item.topLayer) drawBackdrop(item, cloned, viewport);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (item.clip === "hidden") {
|
|
1200
|
+
// Stuck out of sight: left where it would be unstuck, it could show there instead.
|
|
1201
|
+
cloned.style.setProperty("visibility", "hidden", "important");
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const copy = cloned.cloneNode(true) as HTMLElement;
|
|
1205
|
+
if (item.layout === "cell") {
|
|
1206
|
+
copy.setAttribute("style", item.styleText ?? "");
|
|
1207
|
+
cloned.style.setProperty("visibility", "hidden", "important");
|
|
1208
|
+
cloned.appendChild(copy);
|
|
1209
|
+
pinAtViewportBox(item, copy);
|
|
1210
|
+
copy.style.setProperty("visibility", "visible", "important");
|
|
1211
|
+
// Out of its row the copy is a block, whose content starts at the top whatever `vertical-align` says.
|
|
1212
|
+
copy.style.setProperty("align-content", item.alignContent ?? "start", "important");
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
cloned.style.setProperty("opacity", "0", "important");
|
|
1216
|
+
if (item.layout === "rows") {
|
|
1217
|
+
copy.setAttribute("style", `${item.styleText ?? ""}${copy.getAttribute("style") ?? ""}`);
|
|
1218
|
+
shapeRows(item, copy, sources);
|
|
1219
|
+
}
|
|
1220
|
+
const parent = cloned.parentNode;
|
|
1221
|
+
if (!parent) continue;
|
|
1222
|
+
// After the element, unless it is the last one: there it goes before, so the element stays `:last-child`.
|
|
1223
|
+
const before = item.last ? cloned : cloned.nextSibling;
|
|
1224
|
+
// `insertBefore`, not `after`: the workspace type-checks this file with the Workers types, whose Element differs.
|
|
1225
|
+
parent.insertBefore(copy, before);
|
|
1226
|
+
for (let count = 0; count < SIBLING_PADDING; count += 1) {
|
|
1227
|
+
const padding = cloned.cloneNode(false) as HTMLElement;
|
|
1228
|
+
padding.removeAttribute("id");
|
|
1229
|
+
padding.setAttribute("style", "display:none!important");
|
|
1230
|
+
parent.insertBefore(padding, before);
|
|
1231
|
+
}
|
|
1232
|
+
pinAtViewportBox(item, copy);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/* ------------------------------------------------------------------------------------------------
|
|
1237
|
+
* The walk: the copy and the live page side by side, once.
|
|
1238
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
1239
|
+
|
|
1240
|
+
/** What one copy of the page carries through its walk. */
|
|
1241
|
+
type CloneWork = {
|
|
1242
|
+
exclude: (element: Element) => boolean;
|
|
1243
|
+
view: Window;
|
|
1244
|
+
waits: Promise<unknown>[];
|
|
1245
|
+
moving: Map<Element, Moving>;
|
|
1246
|
+
/** Rules for what inline style can't say (a held pseudo-element), added after the page's own. */
|
|
1247
|
+
rules: string[];
|
|
1248
|
+
held: number;
|
|
1249
|
+
/** Copies whose inline transform is the scroll offset of their parent, in front of their own. */
|
|
1250
|
+
shifted: WeakSet<Element>;
|
|
1251
|
+
/** Children of scrolled elements moved without a transform (see `unanchoredChildren`). */
|
|
1252
|
+
unanchored: Set<Element>;
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
function hold(source: Element, cloned: Element, work: CloneWork) {
|
|
1256
|
+
const moving = work.moving.get(source);
|
|
1257
|
+
if (!moving) return;
|
|
1258
|
+
const rules = holdMotion(source, cloned, moving, work.view, work.shifted.has(cloned), () => {
|
|
1259
|
+
work.held += 1;
|
|
1260
|
+
return String(work.held);
|
|
1261
|
+
});
|
|
1262
|
+
if (rules) work.rules.push(rules);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Copies the flattened shadow tree under `parent` (slots replaced by what is assigned to them) into `into`.
|
|
1267
|
+
*
|
|
1268
|
+
* The image is one flat document, so a shadow root's own sheets can't stay scoped to it: a widget's `<style>`
|
|
1269
|
+
* copied out of its shadow root restyles the whole page (React Grab's Tailwind build overrode a host app's
|
|
1270
|
+
* `md:` utilities and theme colors). The sheets are left behind and each element carries its computed style
|
|
1271
|
+
* inline instead. Shadow trees are small widgets, so reading every computed style here stays cheap.
|
|
1272
|
+
* `::before`/`::after` content inside a shadow tree is not drawn.
|
|
1273
|
+
*/
|
|
1274
|
+
function cloneShadowTree(parent: Node, into: Element, work: CloneWork): number {
|
|
1275
|
+
const { exclude, view, waits } = work;
|
|
1276
|
+
let elements = 0;
|
|
1277
|
+
const assigned = parent instanceof HTMLSlotElement ? parent.assignedNodes({ flatten: true }) : [];
|
|
1278
|
+
// A slot with nothing assigned shows its fallback content.
|
|
1279
|
+
const nodes = assigned.length ? assigned : Array.from(parent.childNodes);
|
|
1280
|
+
const inSvg = into.namespaceURI === SVG_NS;
|
|
1281
|
+
for (const node of nodes) {
|
|
1282
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1283
|
+
into.appendChild(node.cloneNode(false));
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (!(node instanceof Element)) continue;
|
|
1287
|
+
if (node instanceof HTMLSlotElement) {
|
|
1288
|
+
elements += cloneShadowTree(node, into, work);
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
// Upper-cased so an SVG `<style>` (tagName "style") inside the tree is left behind too.
|
|
1292
|
+
if (
|
|
1293
|
+
DROPPED.has(node.tagName.toUpperCase()) ||
|
|
1294
|
+
exclude(node) ||
|
|
1295
|
+
(!inSvg && prunable(node, view))
|
|
1296
|
+
)
|
|
1297
|
+
continue;
|
|
1298
|
+
const cloned = node.cloneNode(false) as Element;
|
|
1299
|
+
scrubAttributes(cloned);
|
|
1300
|
+
cloned.setAttribute("style", computedStyleText(node, view));
|
|
1301
|
+
embedInlineStyleUrls(cloned, waits);
|
|
1302
|
+
into.appendChild(cloned);
|
|
1303
|
+
const scroll = { left: node.scrollLeft, top: node.scrollTop };
|
|
1304
|
+
elements += 1 + cloneShadowTree(node.shadowRoot ?? node, cloned, work);
|
|
1305
|
+
for (const child of Array.from(cloned.children)) {
|
|
1306
|
+
if (child instanceof HTMLElement || child instanceof SVGElement)
|
|
1307
|
+
shiftByScroll(
|
|
1308
|
+
child,
|
|
1309
|
+
scroll,
|
|
1310
|
+
{ transform: child.style.transform, origin: child.style.transformOrigin },
|
|
1311
|
+
work.shifted,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
hold(node, cloned, work);
|
|
1315
|
+
carryLiveState(node, cloned, node.getClientRects().length > 0, waits, view);
|
|
1316
|
+
}
|
|
1317
|
+
return elements;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export type ClonedPage = {
|
|
1321
|
+
root: Element;
|
|
1322
|
+
waits: Promise<unknown>[];
|
|
1323
|
+
elements: number;
|
|
1324
|
+
/** Rules that belong after the page's own styles (see `renderSnapshot`). Empty for most pages. */
|
|
1325
|
+
css: string;
|
|
1326
|
+
/** Pins fixed and sticky elements where they are on screen. Call once `waits` have settled; runs once. */
|
|
1327
|
+
finish: () => void;
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* A copy of `root` ready to serialize: `display: none` subtrees removed, offscreen images held by same-size
|
|
1332
|
+
* placeholders, live form values, scroll offsets, canvases and visible images carried over. `waits` resolve
|
|
1333
|
+
* when every visible image has its pixels. `scroll` is the window's offset, applied to the root's children.
|
|
1334
|
+
*
|
|
1335
|
+
* Everything the copy needs from the live page is read here, in one go. `finish` only rearranges the copy.
|
|
1336
|
+
*/
|
|
1337
|
+
export function clonePage(
|
|
1338
|
+
root: Element,
|
|
1339
|
+
viewport: Viewport,
|
|
1340
|
+
exclude: (element: Element) => boolean,
|
|
1341
|
+
view: Window = window,
|
|
1342
|
+
scroll: Scroll = { left: view.scrollX, top: view.scrollY },
|
|
1343
|
+
): ClonedPage {
|
|
1344
|
+
// The live page is never mutated, not even to hide the toolbar: detaching the subtree that holds the focused
|
|
1345
|
+
// composer blurs it for good, and the next keystroke then lands on <body>, where a letter is a toolbar shortcut.
|
|
1346
|
+
// Excluded subtrees are still never measured or walked; only their copies are dropped, below.
|
|
1347
|
+
const copy = root.cloneNode(true) as Element;
|
|
1348
|
+
const keep = visibleSet(root, viewport, exclude);
|
|
1349
|
+
const waits: Promise<unknown>[] = [];
|
|
1350
|
+
const pinned: Pinned[] = [];
|
|
1351
|
+
/** Each copied element's original, for a pinned copy that needs its cells' sizes. */
|
|
1352
|
+
const sources = new Map<Element, Element>();
|
|
1353
|
+
const work: CloneWork = {
|
|
1354
|
+
exclude,
|
|
1355
|
+
view,
|
|
1356
|
+
waits,
|
|
1357
|
+
moving: movingElements(root),
|
|
1358
|
+
rules: [],
|
|
1359
|
+
held: 0,
|
|
1360
|
+
shifted: new WeakSet(),
|
|
1361
|
+
unanchored: unanchoredChildren(keep, root, scroll, view),
|
|
1362
|
+
};
|
|
1363
|
+
let elements = 0;
|
|
1364
|
+
// Both trees have the same shape, so one stack of (source, clone) pairs walks them in step.
|
|
1365
|
+
const stack: Array<[Element, Element]> = [[root, copy]];
|
|
1366
|
+
while (stack.length) {
|
|
1367
|
+
const [source, cloned] = stack.pop()!;
|
|
1368
|
+
elements += 1;
|
|
1369
|
+
const scrolled = scrollOf(source, root, scroll);
|
|
1370
|
+
if (source.shadowRoot) {
|
|
1371
|
+
// What paints is the shadow tree, not the light children (they show only through its slots). The host's
|
|
1372
|
+
// own `:host` rules live in the sheets left behind, so it carries its computed style too.
|
|
1373
|
+
cloned.replaceChildren();
|
|
1374
|
+
cloned.setAttribute("style", computedStyleText(source, view));
|
|
1375
|
+
elements += cloneShadowTree(source.shadowRoot, cloned, work);
|
|
1376
|
+
for (const child of Array.from(cloned.children)) {
|
|
1377
|
+
if (child instanceof HTMLElement || child instanceof SVGElement)
|
|
1378
|
+
shiftByScroll(
|
|
1379
|
+
child,
|
|
1380
|
+
scrolled,
|
|
1381
|
+
{ transform: child.style.transform, origin: child.style.transformOrigin },
|
|
1382
|
+
work.shifted,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
} else {
|
|
1386
|
+
const children = source.children;
|
|
1387
|
+
const clonedChildren = cloned.children;
|
|
1388
|
+
// Inside an <svg> everything stays: defs, symbols and gradients have no box but draw what references them.
|
|
1389
|
+
const inSvg = source.namespaceURI === SVG_NS;
|
|
1390
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
1391
|
+
const child = children[index]!;
|
|
1392
|
+
const childClone = clonedChildren[index]!;
|
|
1393
|
+
if (DROPPED.has(child.tagName) || exclude(child) || (!inSvg && prunable(child, view))) {
|
|
1394
|
+
childClone.remove();
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (scrolled.top || scrolled.left) {
|
|
1398
|
+
if (!work.unanchored.has(child) || !shiftInFlow(child, childClone, scrolled, view)) {
|
|
1399
|
+
work.unanchored.delete(child);
|
|
1400
|
+
shiftByScroll(childClone, scrolled, ownTransform(child, view), work.shifted);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
stack.push([child, childClone]);
|
|
1404
|
+
}
|
|
1405
|
+
holdOpaqueHost(source, cloned, view);
|
|
1406
|
+
}
|
|
1407
|
+
scrubAttributes(cloned);
|
|
1408
|
+
embedInlineStyleUrls(cloned, waits);
|
|
1409
|
+
hold(source, cloned, work);
|
|
1410
|
+
const visible = keep.has(source);
|
|
1411
|
+
// From here on `stand` is what the copy has for `source`: its clone, or the picture put in its place.
|
|
1412
|
+
const stand = carryLiveState(source, cloned, visible, waits, view);
|
|
1413
|
+
sources.set(stand, source);
|
|
1414
|
+
if (visible && source !== root && source instanceof HTMLElement) {
|
|
1415
|
+
const style = view.getComputedStyle(source);
|
|
1416
|
+
const topLayer = inTopLayer(source);
|
|
1417
|
+
if (style.position === "fixed" || style.position === "sticky" || topLayer)
|
|
1418
|
+
pinned.push(
|
|
1419
|
+
measurePinned(source, stand, style, topLayer, root, scroll, view, work.unanchored),
|
|
1420
|
+
);
|
|
1421
|
+
reserveScrollbarGutter(source, stand, style);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
let finished = false;
|
|
1425
|
+
return {
|
|
1426
|
+
root: copy,
|
|
1427
|
+
waits,
|
|
1428
|
+
elements,
|
|
1429
|
+
css: work.rules.join(""),
|
|
1430
|
+
finish: () => {
|
|
1431
|
+
if (finished) return;
|
|
1432
|
+
finished = true;
|
|
1433
|
+
pinInPlace(pinned, viewport, sources);
|
|
1434
|
+
},
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/* ------------------------------------------------------------------------------------------------
|
|
1439
|
+
* Assemble and rasterize.
|
|
1440
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* The SVG document that draws `clone` with `css` applied, `width`×`height` CSS pixels.
|
|
1444
|
+
*
|
|
1445
|
+
* Inside the image the `<svg>` is the root element, not the cloned `<html>`, so `rem` resolves against the
|
|
1446
|
+
* svg's font size. `rootFontSize` (the live root's computed size) is set on it: a page that scales itself by
|
|
1447
|
+
* a root font size set from an attribute or class on `<html>` would otherwise lay out at the browser default
|
|
1448
|
+
* while fixed and sticky boxes stay pinned at their live, differently scaled coordinates.
|
|
1449
|
+
*/
|
|
1450
|
+
export function pageSvg(
|
|
1451
|
+
clone: Element,
|
|
1452
|
+
css: string,
|
|
1453
|
+
width: number,
|
|
1454
|
+
height: number,
|
|
1455
|
+
rootFontSize?: string,
|
|
1456
|
+
): string {
|
|
1457
|
+
const markup = new XMLSerializer().serializeToString(clone).replace(CONTROL_CHARS, "");
|
|
1458
|
+
const style = `${css}\n${SETTLED_MOTION}\n${NO_SCROLLBARS}`
|
|
1459
|
+
.replace(CONTROL_CHARS, "")
|
|
1460
|
+
.split("]]>")
|
|
1461
|
+
.join("]]]]><![CDATA[>");
|
|
1462
|
+
return (
|
|
1463
|
+
`<svg xmlns="${SVG_NS}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"` +
|
|
1464
|
+
`${rootFontSize ? ` style="font-size:${rootFontSize.replace(/["<>&]/g, "")}"` : ""}>` +
|
|
1465
|
+
`<style><![CDATA[${style}]]></style>` +
|
|
1466
|
+
`<foreignObject x="0" y="0" width="100%" height="100%">${markup}</foreignObject></svg>`
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* The root is the viewport: its own box, clipped, never transformed. The window's scroll offset is on its
|
|
1472
|
+
* children (see `shiftByScroll`); a translated root would clip at its own height and lose the part of the page
|
|
1473
|
+
* that scrolled into view, and would re-anchor every fixed element to itself.
|
|
1474
|
+
*/
|
|
1475
|
+
function rootStyle(
|
|
1476
|
+
clone: Element,
|
|
1477
|
+
options: Required<Pick<PageImageOptions, "width" | "height">> &
|
|
1478
|
+
Pick<PageImageOptions, "backgroundColor"> & {
|
|
1479
|
+
/**
|
|
1480
|
+
* The live root's text colour. A page that leaves it to the browser gets its colour scheme's
|
|
1481
|
+
* (`CanvasText`), which the image resolves without the page's scheme: white text came out black.
|
|
1482
|
+
*/
|
|
1483
|
+
color: string;
|
|
1484
|
+
},
|
|
1485
|
+
) {
|
|
1486
|
+
if (!(clone instanceof HTMLElement)) return;
|
|
1487
|
+
const style = clone.style;
|
|
1488
|
+
style.setProperty("color", options.color);
|
|
1489
|
+
style.setProperty("width", `${options.width}px`, "important");
|
|
1490
|
+
style.setProperty("height", `${options.height}px`, "important");
|
|
1491
|
+
style.setProperty("overflow", "hidden", "important");
|
|
1492
|
+
style.setProperty("transform", "none", "important");
|
|
1493
|
+
if (options.backgroundColor)
|
|
1494
|
+
style.setProperty("background-color", options.backgroundColor, "important");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function rasterize(
|
|
1498
|
+
svg: string,
|
|
1499
|
+
width: number,
|
|
1500
|
+
height: number,
|
|
1501
|
+
scale: number,
|
|
1502
|
+
timeout: number,
|
|
1503
|
+
) {
|
|
1504
|
+
// A data URL, not a blob URL: Chrome and Safari taint the canvas for a foreignObject SVG drawn from a blob.
|
|
1505
|
+
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
1506
|
+
{
|
|
1507
|
+
const image = new Image();
|
|
1508
|
+
image.decoding = "async";
|
|
1509
|
+
const loaded = new Promise<void>((resolve, reject) => {
|
|
1510
|
+
image.onload = () => resolve();
|
|
1511
|
+
image.onerror = () => reject(new Error("page image failed to load"));
|
|
1512
|
+
});
|
|
1513
|
+
image.src = url;
|
|
1514
|
+
await withTimeout(loaded, timeout, "page image");
|
|
1515
|
+
try {
|
|
1516
|
+
await withTimeout(image.decode(), timeout, "page image decode");
|
|
1517
|
+
} catch {
|
|
1518
|
+
// Some browsers can't decode SVG off-thread; drawing still works once loaded.
|
|
1519
|
+
}
|
|
1520
|
+
const canvas = document.createElement("canvas");
|
|
1521
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
1522
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
1523
|
+
const ctx = canvas.getContext("2d");
|
|
1524
|
+
if (!ctx) throw new Error("no 2d context");
|
|
1525
|
+
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
1526
|
+
return canvas;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** The page copied at one instant, with everything a later render needs. */
|
|
1531
|
+
export type PageSnapshot = {
|
|
1532
|
+
page: ClonedPage;
|
|
1533
|
+
css: Promise<string>;
|
|
1534
|
+
width: number;
|
|
1535
|
+
height: number;
|
|
1536
|
+
/** The live root's computed font size, which `rem` resolves against inside the image too. */
|
|
1537
|
+
rootFontSize: string;
|
|
1538
|
+
backgroundColor?: string | undefined;
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Copies the visible page now, synchronously, so the picture shows the page exactly as it was at this moment
|
|
1543
|
+
* however long drawing it then takes. Rendering is `renderSnapshot`.
|
|
1544
|
+
*/
|
|
1545
|
+
export function snapshotPage(options: Omit<PageImageOptions, "scale" | "timeout">): PageSnapshot {
|
|
1546
|
+
const width = options.width ?? document.documentElement.clientWidth;
|
|
1547
|
+
const height = options.height ?? window.innerHeight;
|
|
1548
|
+
const scroll = {
|
|
1549
|
+
left: options.scrollX ?? window.scrollX,
|
|
1550
|
+
top: options.scrollY ?? window.scrollY,
|
|
1551
|
+
};
|
|
1552
|
+
const exclude = options.exclude ?? (() => false);
|
|
1553
|
+
const css = pageStyleText();
|
|
1554
|
+
const page = clonePage(document.documentElement, { width, height }, exclude, window, scroll);
|
|
1555
|
+
const live = window.getComputedStyle(document.documentElement);
|
|
1556
|
+
rootStyle(page.root, {
|
|
1557
|
+
width,
|
|
1558
|
+
height,
|
|
1559
|
+
backgroundColor: options.backgroundColor,
|
|
1560
|
+
color: live.color,
|
|
1561
|
+
});
|
|
1562
|
+
const rootFontSize = live.fontSize;
|
|
1563
|
+
return { page, css, width, height, rootFontSize, backgroundColor: options.backgroundColor };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** Draws a snapshot onto a canvas at `scale` device pixels per CSS pixel. Throws when it can't be done in time. */
|
|
1567
|
+
export async function renderSnapshot(
|
|
1568
|
+
snapshot: PageSnapshot,
|
|
1569
|
+
scale: number,
|
|
1570
|
+
timeout = 6000,
|
|
1571
|
+
): Promise<HTMLCanvasElement> {
|
|
1572
|
+
const started = performance.now();
|
|
1573
|
+
const { page, width, height, rootFontSize } = snapshot;
|
|
1574
|
+
const remaining = () => Math.max(250, timeout - (performance.now() - started));
|
|
1575
|
+
const css = await withTimeout(snapshot.css, remaining(), "page styles");
|
|
1576
|
+
await withTimeout(Promise.allSettled(page.waits), remaining(), "page images");
|
|
1577
|
+
page.finish();
|
|
1578
|
+
// The copy's own rules go after the page's, so that between equals they win.
|
|
1579
|
+
const svg = pageSvg(
|
|
1580
|
+
page.root,
|
|
1581
|
+
page.css ? `${css}\n${page.css}` : css,
|
|
1582
|
+
width,
|
|
1583
|
+
height,
|
|
1584
|
+
rootFontSize,
|
|
1585
|
+
);
|
|
1586
|
+
return rasterize(svg, width, height, scale, remaining());
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** The visible page as a canvas. Throws when the page can't be drawn in time. */
|
|
1590
|
+
export async function drawPage(options: PageImageOptions): Promise<HTMLCanvasElement> {
|
|
1591
|
+
return renderSnapshot(snapshotPage(options), options.scale, options.timeout);
|
|
1592
|
+
}
|