@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/README.md +18 -0
- package/comty-sdk.js +1231 -940
- package/package.json +8 -1
- package/src/browser.js +432 -262
- package/src/capture-engine.js +26 -0
- package/src/capture-resources.js +94 -0
- package/src/capture.js +249 -459
- package/src/comments.js +41 -24
- package/src/style.js +360 -165
package/src/capture.js
CHANGED
|
@@ -1,474 +1,258 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|