@comty.io/core 0.1.3 → 0.1.4

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/src/capture.js CHANGED
@@ -1,474 +1,258 @@
1
- export function createScreenshotCapture() {
2
- function nextAnimationFrame() {
3
- return new Promise((resolve) => {
4
- requestAnimationFrame(() => resolve());
5
- });
6
- }
7
-
8
- async function waitForPaint() {
9
- await nextAnimationFrame();
10
- await nextAnimationFrame();
11
- }
12
-
13
- async function waitForCustomElements(el, timeout = 1600) {
14
- if (
15
- !(el instanceof Element) ||
16
- !window.customElements ||
17
- typeof window.customElements.whenDefined !== "function"
18
- ) {
19
- return;
20
- }
21
-
22
- const names = new Set();
23
- let current = el;
24
- while (current && current instanceof Element) {
25
- const tagName = current.tagName.toLowerCase();
26
- if (tagName.includes("-")) {
27
- names.add(tagName);
28
- }
29
- current = current.parentElement;
30
- }
31
-
32
- if (names.size === 0) return;
33
-
34
- const definitions = [...names].map((name) =>
35
- window.customElements.whenDefined(name).catch(() => undefined),
36
- );
37
- await Promise.race([
38
- Promise.all(definitions),
39
- new Promise((resolve) => setTimeout(resolve, timeout)),
40
- ]);
41
- }
1
+ import * as captureEngine from "./capture-engine.js";
2
+ import { createCaptureResources, captureFontCSS } from "./capture-resources.js";
42
3
 
43
- function syncFormValues(sourceRoot, cloneRoot) {
44
- const sourceFields = sourceRoot.querySelectorAll(
45
- "input, textarea, select",
46
- );
47
- const clonedFields = cloneRoot.querySelectorAll(
48
- "input, textarea, select",
49
- );
50
-
51
- sourceFields.forEach((sourceField, index) => {
52
- const clonedField = clonedFields[index];
53
- if (!clonedField) return;
54
-
55
- if (sourceField instanceof HTMLTextAreaElement) {
56
- clonedField.textContent = sourceField.value;
57
- clonedField.setAttribute("value", sourceField.value);
58
- return;
59
- }
4
+ export function captureContext() {
5
+ return { url: location.href, width: innerWidth, height: innerHeight, scrollX, scrollY };
6
+ }
60
7
 
61
- if (sourceField instanceof HTMLInputElement) {
62
- clonedField.setAttribute("value", sourceField.value);
63
- if (sourceField.checked) {
64
- clonedField.setAttribute("checked", "");
65
- } else {
66
- clonedField.removeAttribute("checked");
67
- }
68
- return;
69
- }
8
+ export async function yieldCapture() {
9
+ if (globalThis.scheduler?.yield) await globalThis.scheduler.yield();
10
+ else await new Promise(resolve => setTimeout(resolve, 0));
11
+ }
70
12
 
71
- if (sourceField instanceof HTMLSelectElement) {
72
- Array.from(sourceField.options).forEach((option, optionIndex) => {
73
- const clonedOption = clonedField.options[optionIndex];
74
- if (!clonedOption) return;
75
- if (option.selected) {
76
- clonedOption.setAttribute("selected", "");
77
- } else {
78
- clonedOption.removeAttribute("selected");
79
- }
80
- });
81
- }
82
- });
83
- }
13
+ const captureExcluded = "#comty-root, .comty-sidepanel, .comty-pin, .comty-bubble, .comty-create-bubble, .comty-popover, .comty-temp-pin, [data-comty-capture-exclude]";
14
+ const transparentPixel = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
84
15
 
85
- function toAbsoluteUrl(value) {
86
- if (!value || value.startsWith("data:") || value.startsWith("blob:")) {
87
- return value;
88
- }
16
+ export function createScreenshotCapture({ apiUrl, projectKey, getHeaders = () => ({}) } = {}) {
17
+ async function bounded(promise, ms) {
18
+ let timer;
19
+ try { return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(resolve, ms); })]); }
20
+ finally { clearTimeout(timer); }
21
+ }
89
22
 
90
- try {
91
- return new URL(value, window.location.href).toString();
92
- } catch {
93
- return value;
23
+ async function captureViewportScreenshot({ context = captureContext(), annotation } = {}) {
24
+ if (document.visibilityState === "hidden") return null;
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(), 15000);
27
+ const { signal } = controller;
28
+ const untilAborted = promise => new Promise((resolve, reject) => {
29
+ const aborted = () => reject(new Error("Screenshot timed out"));
30
+ signal.addEventListener("abort", aborted, { once: true });
31
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
32
+ if (signal.aborted) aborted();
33
+ });
34
+ let frozen = false;
35
+ let lastYield = performance.now();
36
+ const checkContext = () => {
37
+ signal.throwIfAborted();
38
+ if (!frozen && (context.url !== location.href || context.width !== innerWidth || context.height !== innerHeight || context.scrollX !== scrollX || context.scrollY !== scrollY)) {
39
+ throw new Error("Page moved before its screenshot was ready");
94
40
  }
95
- }
96
-
97
- function isExportSafeUrl(value) {
98
- if (!value || value.startsWith("data:")) return true;
99
-
100
- try {
101
- const url = new URL(value, window.location.href);
102
- return url.origin === window.location.origin;
103
- } catch {
104
- return false;
41
+ };
42
+ const cooperate = async () => {
43
+ checkContext();
44
+ if (performance.now() - lastYield >= 6) {
45
+ await yieldCapture();
46
+ lastYield = performance.now();
47
+ checkContext();
105
48
  }
106
- }
107
-
108
- function blobToDataUrl(blob) {
109
- return new Promise((resolve, reject) => {
110
- const reader = new FileReader();
111
- reader.onload = () => resolve(String(reader.result ?? ""));
112
- reader.onerror = reject;
113
- reader.readAsDataURL(blob);
114
- });
115
- }
116
-
117
- async function urlToDataUrl(value) {
118
- const absoluteUrl = toAbsoluteUrl(value);
119
- if (!absoluteUrl || absoluteUrl.startsWith("data:")) return absoluteUrl;
120
- if (absoluteUrl.startsWith("blob:")) return "";
121
-
122
- try {
123
- const response = await fetch(absoluteUrl, {
124
- credentials: isExportSafeUrl(absoluteUrl) ? "same-origin" : "omit",
125
- mode: "cors",
126
- });
127
- if (!response.ok) return "";
128
- return await blobToDataUrl(await response.blob());
129
- } catch {
130
- return "";
131
- }
132
- }
133
-
134
- async function inlineImageResources(clone) {
135
- const images = Array.from(clone.querySelectorAll("img"));
136
- await Promise.all(
137
- images.map(async (image) => {
138
- const dataUrl = await urlToDataUrl(image.getAttribute("src") ?? "");
139
- if (!dataUrl) {
140
- image.removeAttribute("src");
141
- image.removeAttribute("srcset");
142
- image.style.visibility = "hidden";
143
- return;
49
+ };
50
+ try {
51
+ await yieldCapture();
52
+ await bounded(document.fonts?.ready || Promise.resolve(), 1200);
53
+ checkContext();
54
+ const resources = createCaptureResources({ apiUrl, projectKey, getHeaders, signal });
55
+ const fontFamilies = new Set();
56
+ const sheets = new Set([...document.styleSheets, ...document.adoptedStyleSheets]);
57
+ const addFonts = value => value.split(",").forEach(family => fontFamilies.add(family.trim().replace(/^['"]|['"]$/g, "").toLowerCase()));
58
+ const positioned = [];
59
+ let cloningAnnotation = false;
60
+ const options = {
61
+ resourceCache: Object.create(null),
62
+ includeQueryParams: true,
63
+ imagePlaceholder: transparentPixel,
64
+ signal,
65
+ fetchRequestInit: { captureFetch: resources },
66
+ beforeClone: cooperate,
67
+ placeholder(source) {
68
+ if (cloningAnnotation) return null;
69
+ // Offscreen leaf boxes still occupy their exact layout space, but need no
70
+ // text, fonts or hundreds of computed declarations in the exported SVG.
71
+ if (!(source instanceof HTMLElement)) return null;
72
+ if (/^(IFRAME|OBJECT|EMBED)$/.test(source.tagName)) {
73
+ const spacer = document.createElement("div");
74
+ const style = getComputedStyle(source);
75
+ for (const property of style) spacer.style.setProperty(property, style.getPropertyValue(property));
76
+ return spacer;
144
77
  }
145
- image.setAttribute("src", dataUrl);
146
- image.removeAttribute("srcset");
147
- }),
148
- );
149
-
150
- clone.querySelectorAll("source").forEach((source) => {
151
- source.removeAttribute("src");
152
- source.removeAttribute("srcset");
153
- });
154
- }
155
-
156
- function convertViewportUnits(value, viewportWidth, viewportHeight) {
157
- return value.replace(
158
- /(-?\d*\.?\d+)(dvh|svh|lvh|vh|dvw|svw|lvw|vw|vmin|vmax)\b/gi,
159
- (_, amount, unit) => {
160
- const number = Number(amount);
161
- if (!Number.isFinite(number)) return `${amount}${unit}`;
162
- const normalizedUnit = String(unit).toLowerCase();
163
- const base =
164
- normalizedUnit === "vw" ||
165
- normalizedUnit === "dvw" ||
166
- normalizedUnit === "svw" ||
167
- normalizedUnit === "lvw"
168
- ? viewportWidth
169
- : normalizedUnit === "vmin"
170
- ? Math.min(viewportWidth, viewportHeight)
171
- : normalizedUnit === "vmax"
172
- ? Math.max(viewportWidth, viewportHeight)
173
- : viewportHeight;
174
- return `${(number / 100) * base}px`;
78
+ if (source.children.length || source.shadowRoot) return null;
79
+ const rect = source.getBoundingClientRect();
80
+ if (!rect.width || !rect.height || (rect.bottom > 0 && rect.top < context.height && rect.right > 0 && rect.left < context.width)) return null;
81
+ const style = getComputedStyle(source);
82
+ if (!["static", "relative"].includes(style.position) || !["block", "flex", "grid"].includes(style.display) || style.transform !== "none") return null;
83
+ const spacer = document.createElement("div");
84
+ for (const property of ["display", "margin", "flex", "align-self", "grid-area", "order"]) spacer.style.setProperty(property, style.getPropertyValue(property));
85
+ Object.assign(spacer.style, { width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", visibility: "hidden" });
86
+ return spacer;
175
87
  },
176
- );
177
- }
178
-
179
- function sanitizeCssForExport(css, viewportWidth, viewportHeight) {
180
- return convertViewportUnits(css, viewportWidth, viewportHeight)
181
- .replace(/@font-face\s*{[^}]*}/gi, "")
182
- .replace(/url\((?!['"]?data:)[^)]+\)/gi, "none");
183
- }
184
-
185
- function sanitizeInlineStyles(clone, viewportWidth, viewportHeight) {
186
- clone.querySelectorAll("[style]").forEach((node) => {
187
- node.setAttribute(
188
- "style",
189
- sanitizeCssForExport(
190
- node.getAttribute("style") ?? "",
191
- viewportWidth,
192
- viewportHeight,
193
- ),
194
- );
195
- });
196
- }
197
-
198
- function removeCaptureLockStyles(clone) {
199
- clone.querySelectorAll("[style]").forEach((node) => {
200
- const style = node.getAttribute("style") ?? "";
201
- const hasCaptureLock =
202
- /max-width:\s*none/i.test(style) &&
203
- /max-height:\s*none/i.test(style) &&
204
- /transform:\s*none/i.test(style) &&
205
- /margin:\s*0px/i.test(style);
206
-
207
- if (!hasCaptureLock) return;
208
-
209
- node.style.removeProperty("width");
210
- node.style.removeProperty("height");
211
- node.style.removeProperty("max-width");
212
- node.style.removeProperty("max-height");
213
- node.style.removeProperty("margin");
214
- node.style.removeProperty("transform");
215
- node.style.removeProperty("inset");
216
- });
217
- }
218
-
219
- function removeExportUnsafeNodes(clone) {
220
- clone
221
- .querySelectorAll("iframe, canvas, video, audio, object, embed")
222
- .forEach((node) => node.remove());
223
- clone
224
- .querySelectorAll("link[rel~='stylesheet'], link[rel~='preload']")
225
- .forEach((node) => node.remove());
226
- clone
227
- .querySelectorAll(
228
- "#comty-root, .comty-sidepanel, .comty-pin:not([data-comty-capture-keep]), .comty-bubble:not([data-comty-capture-keep]), .comty-create-bubble:not([data-comty-capture-keep]), .comty-popover",
229
- )
230
- .forEach((node) => node.remove());
231
- }
232
-
233
- function makeCloneUrlsAbsolute(clone) {
234
- clone.querySelectorAll("[src]").forEach((node) => {
235
- node.setAttribute("src", toAbsoluteUrl(node.getAttribute("src")));
236
- });
237
- clone.querySelectorAll("[href]").forEach((node) => {
238
- node.setAttribute("href", toAbsoluteUrl(node.getAttribute("href")));
239
- });
240
- clone.querySelectorAll("[srcset]").forEach((node) => {
241
- const srcset = node
242
- .getAttribute("srcset")
243
- .split(",")
244
- .map((entry) => {
245
- const parts = entry.trim().split(/\s+/);
246
- if (!parts[0]) return "";
247
- parts[0] = toAbsoluteUrl(parts[0]);
248
- return parts.join(" ");
249
- })
250
- .filter(Boolean)
251
- .join(", ");
252
- node.setAttribute("srcset", srcset);
253
- });
254
- }
255
-
256
- function inlineAccessibleStyles(clone, viewportWidth, viewportHeight) {
257
- const style = clone.ownerDocument.createElement("style");
258
- let css = "";
259
-
260
- Array.from(document.styleSheets).forEach((styleSheet) => {
261
- try {
262
- css += Array.from(styleSheet.cssRules)
263
- .map((rule) => rule.cssText)
264
- .join("\n");
265
- } catch {
266
- // Cross-origin stylesheets cannot be exported safely into canvas.
267
- }
268
- });
269
-
270
- if (!css) return;
271
- style.textContent = sanitizeCssForExport(
272
- css,
273
- viewportWidth,
274
- viewportHeight,
275
- );
276
- clone.querySelector("head")?.appendChild(style);
277
- }
278
-
279
- function markCaptureElements(root, attributeName) {
280
- return [root, ...Array.from(root.querySelectorAll("*"))].map(
281
- (element, index) => {
282
- const previousValue = element.getAttribute(attributeName);
283
- element.setAttribute(attributeName, String(index));
284
- return { element, index, previousValue };
88
+ filter(node) {
89
+ if (!(node instanceof Element)) return true;
90
+ if ((!cloningAnnotation && node.matches(captureExcluded)) || /^(SCRIPT|STYLE|LINK|HEAD|NOSCRIPT|AUDIO)$/.test(node.tagName)) return false;
91
+ return getComputedStyle(node).display !== "none";
285
92
  },
286
- );
287
- }
288
-
289
- function restoreCaptureElementMarks(markedElements, attributeName) {
290
- markedElements.forEach(({ element, previousValue }) => {
291
- if (previousValue === null) {
292
- element.removeAttribute(attributeName);
293
- return;
294
- }
295
- element.setAttribute(attributeName, previousValue);
296
- });
297
- }
298
-
299
- function removeCaptureElementMarks(clone, attributeName) {
300
- clone.removeAttribute(attributeName);
301
- clone
302
- .querySelectorAll(`[${attributeName}]`)
303
- .forEach((element) => element.removeAttribute(attributeName));
304
- }
305
-
306
- function pinFixedElementsToViewport(
307
- clone,
308
- markedElements,
309
- attributeName,
310
- scrollX,
311
- scrollY,
312
- viewportWidth,
313
- viewportHeight,
314
- ) {
315
- markedElements.forEach(({ element, index }) => {
316
- if (window.getComputedStyle(element).position !== "fixed") return;
317
-
318
- const clonedElement = clone.querySelector(
319
- `[${attributeName}="${index}"]`,
320
- );
321
- if (!(clonedElement instanceof HTMLElement)) return;
322
-
323
- const rect = element.getBoundingClientRect();
324
- clonedElement.style.position = "absolute";
325
- clonedElement.style.left = `${rect.left + scrollX}px`;
326
- clonedElement.style.top = `${rect.top + scrollY}px`;
327
- clonedElement.style.right = "auto";
328
- clonedElement.style.bottom = "auto";
329
- clonedElement.style.boxSizing = "border-box";
330
- clonedElement.style.width = `${rect.width || viewportWidth}px`;
331
- clonedElement.style.height = `${rect.height || viewportHeight}px`;
332
- clonedElement.style.transform = "none";
333
- });
334
- }
335
-
336
- function syncScrollableElementOffsets(
337
- clone,
338
- markedElements,
339
- attributeName,
340
- ) {
341
- markedElements.forEach(({ element, index }) => {
342
- if (element === document.documentElement || element === document.body) {
343
- return;
344
- }
345
-
346
- const scrollLeft = element.scrollLeft;
347
- const scrollTop = element.scrollTop;
348
- if (!scrollLeft && !scrollTop) return;
349
-
350
- const clonedElement = clone.querySelector(
351
- `[${attributeName}="${index}"]`,
352
- );
353
- if (!(clonedElement instanceof HTMLElement)) return;
354
-
355
- const children = Array.from(clonedElement.childNodes);
356
- if (!children.length) return;
357
-
358
- const scrollWrapper = clone.ownerDocument.createElement("div");
359
- const computedStyle = window.getComputedStyle(element);
360
- scrollWrapper.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`;
361
- scrollWrapper.style.transformOrigin = "top left";
362
- scrollWrapper.style.width = `${Math.max(
363
- element.scrollWidth,
364
- element.clientWidth,
365
- )}px`;
366
- scrollWrapper.style.minHeight = `${Math.max(
367
- element.scrollHeight,
368
- element.clientHeight,
369
- )}px`;
370
-
371
- if (computedStyle.display.includes("flex")) {
372
- scrollWrapper.style.display = computedStyle.display;
373
- scrollWrapper.style.flexDirection = computedStyle.flexDirection;
374
- scrollWrapper.style.flexWrap = computedStyle.flexWrap;
375
- scrollWrapper.style.alignItems = computedStyle.alignItems;
376
- scrollWrapper.style.justifyContent = computedStyle.justifyContent;
377
- scrollWrapper.style.gap = computedStyle.gap;
378
- } else if (computedStyle.display.includes("grid")) {
379
- scrollWrapper.style.display = computedStyle.display;
380
- scrollWrapper.style.gridTemplateColumns =
381
- computedStyle.gridTemplateColumns;
382
- scrollWrapper.style.gridTemplateRows = computedStyle.gridTemplateRows;
383
- scrollWrapper.style.gap = computedStyle.gap;
93
+ afterClone(source, clone) {
94
+ if (!(source instanceof Element) || !clone.style) return clone;
95
+ const style = getComputedStyle(source);
96
+ addFonts(style.fontFamily);
97
+ for (const pseudo of ["::before", "::after"]) {
98
+ const pseudoStyle = getComputedStyle(source, pseudo);
99
+ if (pseudoStyle.content && !["none", "normal"].includes(pseudoStyle.content)) addFonts(pseudoStyle.fontFamily);
100
+ }
101
+ if (source.shadowRoot) {
102
+ for (const sheet of [...source.shadowRoot.styleSheets, ...source.shadowRoot.adoptedStyleSheets]) sheets.add(sheet);
103
+ }
104
+ clone.style.setProperty("animation", "none", "important");
105
+ clone.style.setProperty("transition", "none", "important");
106
+ clone.style.setProperty("caret-color", "transparent", "important");
107
+ // Chromium 140 exposes these values but omits them from style enumeration.
108
+ for (const property of ["text-underline-offset", "text-decoration-thickness"]) {
109
+ clone.style.setProperty(property, style.getPropertyValue(property));
110
+ }
111
+ // WebKit scrollbar pseudo-styles are not part of the computed element style.
112
+ if ([style.overflowX, style.overflowY].some(value => value === "auto" || value === "scroll") && getComputedStyle(source, "::-webkit-scrollbar").display === "none") {
113
+ clone.style.setProperty("scrollbar-width", "none", "important");
114
+ }
115
+ // A cloned document must not re-run handlers or select a different responsive image.
116
+ for (const attribute of [...clone.attributes]) {
117
+ if (attribute.name.startsWith("on")) clone.removeAttribute(attribute.name);
118
+ }
119
+ if (source instanceof HTMLImageElement) {
120
+ clone.removeAttribute("srcset");
121
+ clone.removeAttribute("sizes");
122
+ clone.src = source.currentSrc || source.src || transparentPixel;
123
+ clone.loading = "eager";
124
+ }
125
+ if (source instanceof HTMLSourceElement) {
126
+ clone.removeAttribute("srcset");
127
+ clone.removeAttribute("src");
128
+ }
129
+ if (source instanceof HTMLInputElement) {
130
+ if (source.type === "password") clone.setAttribute("value", "•".repeat(source.value.length));
131
+ clone.toggleAttribute("checked", source.checked);
132
+ }
133
+ if (source instanceof HTMLSelectElement) {
134
+ [...clone.options].forEach((option, index) => option.toggleAttribute("selected", source.options[index]?.selected));
135
+ }
136
+ const rect = source.getBoundingClientRect();
137
+ if (!cloningAnnotation && ["fixed", "sticky"].includes(style.position)) {
138
+ // Composite viewport-bound elements independently of the document's scroll.
139
+ // Keep a spacer for sticky flow; the real source is never moved or restyled.
140
+ positioned.push({ clone, rect, zIndex: Number.parseInt(style.zIndex, 10) || 0 });
141
+ const spacer = document.createElement("div");
142
+ if (style.position === "sticky") {
143
+ spacer.style.cssText = clone.style.cssText;
144
+ spacer.style.position = "static";
145
+ spacer.style.visibility = "hidden";
146
+ spacer.style.width = `${source.offsetWidth}px`;
147
+ spacer.style.height = `${source.offsetHeight}px`;
148
+ } else spacer.style.display = "none";
149
+ return spacer;
150
+ }
151
+ if (source !== document.documentElement && source !== document.body && (source.scrollTop || source.scrollLeft)) {
152
+ const wrapper = document.createElement("div");
153
+ for (const property of ["display", "flex-direction", "flex-wrap", "align-items", "justify-content", "gap", "grid-template-columns", "grid-template-rows"]) {
154
+ wrapper.style.setProperty(property, style.getPropertyValue(property));
155
+ }
156
+ const horizontalPadding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
157
+ wrapper.style.width = `${Math.max(source.scrollWidth, source.clientWidth) - horizontalPadding}px`;
158
+ wrapper.style.transform = `translate(${-source.scrollLeft}px, ${-source.scrollTop}px)`;
159
+ wrapper.style.transformOrigin = "top left";
160
+ wrapper.append(...clone.childNodes);
161
+ clone.append(wrapper);
162
+ clone.style.display = "block";
163
+ }
164
+ return clone;
165
+ },
166
+ };
167
+ const clone = await captureEngine.cloneNode(document.documentElement, options, true);
168
+ checkContext();
169
+ frozen = true;
170
+ const stage = document.createElement("div");
171
+ stage.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
172
+ stage.style.position = "relative";
173
+ stage.style.width = `${context.width}px`;
174
+ stage.style.height = `${context.height}px`;
175
+ stage.style.overflow = "hidden";
176
+ // The browser propagates the root (or body's) background to the viewport;
177
+ // an HTML clone inside SVG does not, especially with collapsed margins.
178
+ const rootStyle = getComputedStyle(document.documentElement);
179
+ stage.style.backgroundColor = rootStyle.backgroundColor === "rgba(0, 0, 0, 0)" && rootStyle.backgroundImage === "none"
180
+ ? getComputedStyle(document.body).backgroundColor : rootStyle.backgroundColor;
181
+ const documentLayer = document.createElement("div");
182
+ documentLayer.style.transform = `translate(${-context.scrollX}px, ${-context.scrollY}px)`;
183
+ documentLayer.style.transformOrigin = "top left";
184
+ clone.style.width = `${context.width}px`;
185
+ clone.style.height = `${Math.max(document.documentElement.scrollHeight, context.height)}px`;
186
+ clone.style.overflow = "visible";
187
+ clone.style.margin = "0";
188
+ documentLayer.append(clone);
189
+ stage.append(documentLayer);
190
+ for (const item of positioned.sort((a, b) => a.zIndex - b.zIndex)) {
191
+ const { clone: node, rect } = item;
192
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= context.height || rect.left >= context.width) continue;
193
+ Object.assign(node.style, { position: "absolute", left: `${rect.left}px`, top: `${rect.top}px`, right: "auto", bottom: "auto", width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", margin: "0", transform: "none" });
194
+ stage.append(node);
195
+ }
196
+ if (annotation) {
197
+ // Measure the real Comty components in an invisible host, then include
198
+ // their styled clones in the same font/image embedding as the page.
199
+ const mounted = annotation();
200
+ try {
201
+ cloningAnnotation = true;
202
+ for (const source of mounted.elements) {
203
+ const rect = source.getBoundingClientRect();
204
+ const node = await captureEngine.cloneNode(source, options, true);
205
+ Object.assign(node.style, { position: "absolute", left: `${rect.left}px`, top: `${rect.top}px`, right: "auto", bottom: "auto", width: `${rect.width}px`, height: `${rect.height}px`, boxSizing: "border-box", margin: "0", transform: "none" });
206
+ stage.append(node);
207
+ }
208
+ } finally {
209
+ cloningAnnotation = false;
210
+ mounted.dispose();
384
211
  }
385
-
386
- children.forEach((child) => scrollWrapper.appendChild(child));
387
- clonedElement.appendChild(scrollWrapper);
388
- });
389
- }
390
-
391
- async function captureViewportScreenshot() {
392
- await waitForPaint();
393
-
394
- const width = window.innerWidth;
395
- const height = window.innerHeight;
396
- const scrollX = window.scrollX;
397
- const scrollY = window.scrollY;
398
- const captureWidth = Math.max(width + scrollX, width);
399
- const documentHeight = Math.max(
400
- document.documentElement.scrollHeight,
401
- document.body?.scrollHeight ?? 0,
402
- height,
403
- );
404
- const captureIdAttribute = "data-comty-capture-id";
405
- const markedElements = markCaptureElements(
406
- document.documentElement,
407
- captureIdAttribute,
408
- );
409
- let clone;
410
-
411
- try {
412
- clone = document.documentElement.cloneNode(true);
413
- pinFixedElementsToViewport(
414
- clone,
415
- markedElements,
416
- captureIdAttribute,
417
- scrollX,
418
- scrollY,
419
- width,
420
- height,
421
- );
422
- syncScrollableElementOffsets(clone, markedElements, captureIdAttribute);
423
- } finally {
424
- restoreCaptureElementMarks(markedElements, captureIdAttribute);
425
212
  }
426
-
427
- clone.querySelectorAll("script").forEach((node) => node.remove());
428
- syncFormValues(document, clone);
429
- makeCloneUrlsAbsolute(clone);
430
- await inlineImageResources(clone);
431
- inlineAccessibleStyles(clone, width, height);
432
- sanitizeInlineStyles(clone, width, height);
433
- removeCaptureLockStyles(clone);
434
- removeExportUnsafeNodes(clone);
435
- removeCaptureElementMarks(clone, captureIdAttribute);
436
- clone.style.width = `${captureWidth}px`;
437
- clone.style.height = `${documentHeight}px`;
438
- clone.style.margin = "0";
439
- clone.style.overflow = "visible";
440
-
441
- const clonedBody = clone.querySelector("body");
442
- if (clonedBody instanceof HTMLElement) {
443
- clonedBody.style.width = getComputedStyle(document.body).width;
444
- clonedBody.style.minHeight = `${documentHeight}px`;
445
- clonedBody.style.margin = clonedBody.style.margin || "0";
213
+ const fonts = await untilAborted(captureFontCSS(sheets, fontFamilies, resources, captureEngine.embedResources, options));
214
+ if (fonts) {
215
+ const style = document.createElement("style");
216
+ style.textContent = fonts;
217
+ stage.prepend(style);
446
218
  }
447
-
448
- const wrapper = document.createElement("div");
449
- wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
450
- wrapper.style.position = "relative";
451
- wrapper.style.width = `${captureWidth}px`;
452
- wrapper.style.height = `${documentHeight}px`;
453
- wrapper.style.overflow = "hidden";
454
- wrapper.style.transform = `translate(${-scrollX}px, ${-scrollY}px)`;
455
- wrapper.style.transformOrigin = "top left";
456
- wrapper.appendChild(clone);
457
-
458
- const markup = new XMLSerializer().serializeToString(wrapper);
459
- const svg = `
460
- <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
461
- <foreignObject width="100%" height="100%">${markup}</foreignObject>
462
- </svg>
463
- `;
464
- return {
465
- blob: new Blob([svg], { type: "image/svg+xml" }),
466
- width,
467
- height,
468
- };
219
+ // Pseudo-element styles are generated by the library and need their URLs embedded too.
220
+ for (const style of stage.querySelectorAll("style")) {
221
+ await cooperate();
222
+ style.textContent = await captureEngine.embedResources(style.textContent, document.baseURI, options);
223
+ }
224
+ await untilAborted(captureEngine.embedImages(stage, options));
225
+ checkContext();
226
+ await yieldCapture();
227
+ const svg = await captureEngine.nodeToDataURL(stage, context.width, context.height);
228
+ const image = new Image();
229
+ image.src = svg;
230
+ await untilAborted(image.decode());
231
+ checkContext();
232
+ const scale = Math.min(2, devicePixelRatio || 1, 2560 / context.width, 2560 / context.height);
233
+ const canvas = document.createElement("canvas");
234
+ canvas.width = Math.round(context.width * scale);
235
+ canvas.height = Math.round(context.height * scale);
236
+ const ctx = canvas.getContext("2d");
237
+ ctx.scale(scale, scale);
238
+ ctx.drawImage(image, 0, 0);
239
+ let blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/png")));
240
+ if (!blob) throw new Error("Screenshot encoding failed");
241
+ if (blob.size > 2800000) {
242
+ ctx.globalCompositeOperation = "destination-over";
243
+ ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("background-color");
244
+ ctx.fillRect(0, 0, context.width, context.height);
245
+ blob = await untilAborted(new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", 0.9)));
246
+ if (!blob) throw new Error("Screenshot encoding failed");
247
+ }
248
+ // CSS dimensions keep the eyedropper's coordinate system independent of DPR.
249
+ return { blob, width: canvas.width, height: canvas.height, cssWidth: context.width, cssHeight: context.height };
250
+ } finally {
251
+ clearTimeout(timeout);
252
+ controller.abort();
469
253
  }
470
-
471
- return { waitForPaint, waitForCustomElements, captureViewportScreenshot };
254
+ }
255
+ return { captureViewportScreenshot };
472
256
  }
473
257
 
474
258
  // Page-local counterpart of the native eyedropper; never captures outside the page.
@@ -516,7 +300,9 @@ export function createColorMagnifier({ root, isComtyElement, capture }) {
516
300
  sampledHex = null;
517
301
  if (!enabled) return;
518
302
  try {
519
- const { blob } = await capture();
303
+ const screenshot = await capture();
304
+ if (!screenshot) return;
305
+ const { blob, cssWidth, cssHeight } = screenshot;
520
306
  if (!enabled || version !== revision) return;
521
307
  const dataUrl = await new Promise((resolve, reject) => {
522
308
  const reader = new FileReader();
@@ -532,7 +318,11 @@ export function createColorMagnifier({ root, isComtyElement, capture }) {
532
318
  const probe = document.createElement('canvas').getContext('2d');
533
319
  probe.drawImage(next, 0, 0);
534
320
  probe.getImageData(0, 0, 1, 1);
535
- image = next;
321
+ const scaled = document.createElement('canvas');
322
+ scaled.width = cssWidth || next.width;
323
+ scaled.height = cssHeight || next.height;
324
+ scaled.getContext('2d').drawImage(next, 0, 0, scaled.width, scaled.height);
325
+ image = scaled;
536
326
  paint();
537
327
  } catch { /* The CSS-colour fallback remains available without screen permission. */ }
538
328
  }