@arronqzy/vue-view 0.1.23 → 0.1.25

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.
@@ -0,0 +1,800 @@
1
+ import type { PanelElement } from "../types";
2
+
3
+ const STYLE_PROPS = [
4
+ "display",
5
+ "position",
6
+ "left",
7
+ "top",
8
+ "width",
9
+ "height",
10
+ "margin",
11
+ "padding",
12
+ "border",
13
+ "borderRadius",
14
+ "background",
15
+ "backgroundColor",
16
+ "backgroundImage",
17
+ "backgroundSize",
18
+ "backgroundPosition",
19
+ "backgroundRepeat",
20
+ "color",
21
+ "fontFamily",
22
+ "fontSize",
23
+ "fontWeight",
24
+ "fontStyle",
25
+ "lineHeight",
26
+ "textAlign",
27
+ "textDecoration",
28
+ "letterSpacing",
29
+ "wordBreak",
30
+ "overflow",
31
+ "opacity",
32
+ "boxSizing",
33
+ "flexDirection",
34
+ "alignItems",
35
+ "justifyContent",
36
+ "gap",
37
+ "gridTemplateColumns",
38
+ "gridTemplateRows",
39
+ "objectFit",
40
+ "filter",
41
+ "boxShadow",
42
+ "transform",
43
+ "transformOrigin",
44
+ "zIndex",
45
+ ] as const;
46
+
47
+ function loadImage(
48
+ src: string,
49
+ crossOrigin?: "anonymous" | "use-credentials"
50
+ ): Promise<HTMLImageElement> {
51
+ return new Promise((resolve, reject) => {
52
+ const img = new Image();
53
+ if (crossOrigin && !src.startsWith("data:") && !src.startsWith("blob:")) {
54
+ img.crossOrigin = crossOrigin;
55
+ }
56
+ img.onload = () => resolve(img);
57
+ img.onerror = () => reject(new Error("image-load-failed"));
58
+ img.src = src;
59
+ });
60
+ }
61
+
62
+ function canvasToDataUrl(
63
+ canvas: HTMLCanvasElement,
64
+ mimeType = "image/png",
65
+ quality?: number
66
+ ): string | null {
67
+ try {
68
+ return canvas.toDataURL(mimeType, quality);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function blobToDataUrl(blob: Blob): Promise<string> {
75
+ return new Promise((resolve, reject) => {
76
+ const reader = new FileReader();
77
+ reader.onload = () => resolve(String(reader.result ?? ""));
78
+ reader.onerror = () => reject(new Error("file-read-failed"));
79
+ reader.readAsDataURL(blob);
80
+ });
81
+ }
82
+
83
+ function absoluteUrl(src: string): string {
84
+ try {
85
+ return new URL(src, document.baseURI).href;
86
+ } catch {
87
+ return src;
88
+ }
89
+ }
90
+
91
+ async function urlToDataUrl(src: string): Promise<string | null> {
92
+ if (!src || src === "none") return null;
93
+ if (src.startsWith("data:")) return src;
94
+
95
+ const href = absoluteUrl(src);
96
+ const controller = new AbortController();
97
+ const timer = window.setTimeout(() => controller.abort(), 5000);
98
+ try {
99
+ const res = await fetch(href, { signal: controller.signal });
100
+ if (res.ok) {
101
+ return blobToDataUrl(await res.blob());
102
+ }
103
+ } catch {
104
+ // CORS / network: fall through to image + anonymous canvas
105
+ } finally {
106
+ window.clearTimeout(timer);
107
+ }
108
+
109
+ try {
110
+ const img = await loadImage(href, "anonymous");
111
+ const canvas = document.createElement("canvas");
112
+ canvas.width = Math.max(1, img.naturalWidth || img.width);
113
+ canvas.height = Math.max(1, img.naturalHeight || img.height);
114
+ const ctx = canvas.getContext("2d");
115
+ if (!ctx) return null;
116
+ ctx.drawImage(img, 0, 0);
117
+ return canvasToDataUrl(canvas);
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function parseCssUrl(value: string | undefined | null): string | null {
124
+ if (!value) return null;
125
+ const trimmed = value.trim();
126
+ if (!trimmed || trimmed === "none") return null;
127
+ const start = trimmed.indexOf("url(");
128
+ if (start < 0) return null;
129
+ let i = start + 4;
130
+ while (i < trimmed.length && /\s/.test(trimmed[i]!)) i += 1;
131
+ const quote = trimmed[i] === '"' || trimmed[i] === "'" ? trimmed[i]! : "";
132
+ if (quote) i += 1;
133
+ const end = quote ? trimmed.lastIndexOf(quote) : trimmed.lastIndexOf(")");
134
+ if (end <= i) return null;
135
+ const url = trimmed.slice(i, end).trim();
136
+ return url && url !== "none" ? url : null;
137
+ }
138
+
139
+ async function replaceCssUrls(value: string): Promise<string> {
140
+ const url = parseCssUrl(value);
141
+ if (!url || url.startsWith("data:")) return value;
142
+ const dataUrl = await urlToDataUrl(url);
143
+ if (!dataUrl) return value.replace(/url\(([\s\S]*?)\)/, "none");
144
+ return value.replace(/url\(([\s\S]*?)\)/, `url("${dataUrl}")`);
145
+ }
146
+
147
+ async function inlineStyleUrls(el: HTMLElement) {
148
+ const updates: Array<[string, string]> = [];
149
+ for (let i = 0; i < el.style.length; i += 1) {
150
+ const prop = el.style.item(i);
151
+ const value = el.style.getPropertyValue(prop);
152
+ if (value.includes("url(")) {
153
+ updates.push([prop, await replaceCssUrls(value)]);
154
+ }
155
+ }
156
+ for (const [prop, value] of updates) {
157
+ el.style.setProperty(prop, value);
158
+ }
159
+ }
160
+
161
+ async function inlineCloneResources(root: HTMLElement) {
162
+ const images = Array.from(root.querySelectorAll("img"));
163
+ await Promise.all(
164
+ images.map(async (img) => {
165
+ const src = img.currentSrc || img.getAttribute("src") || "";
166
+ const dataUrl = await urlToDataUrl(src);
167
+ if (dataUrl) {
168
+ img.setAttribute("src", dataUrl);
169
+ img.removeAttribute("srcset");
170
+ img.removeAttribute("crossorigin");
171
+ } else {
172
+ img.removeAttribute("src");
173
+ img.removeAttribute("srcset");
174
+ }
175
+ })
176
+ );
177
+
178
+ const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
179
+ await Promise.all(elements.map((el) => inlineStyleUrls(el)));
180
+ }
181
+
182
+ function fallbackNodeDataUrl(node: HTMLElement, width: number, height: number): string {
183
+ const emptyPng =
184
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
185
+ const canvas = document.createElement("canvas");
186
+ canvas.width = Math.max(1, width);
187
+ canvas.height = Math.max(1, height);
188
+ const ctx = canvas.getContext("2d");
189
+ if (!ctx) return emptyPng;
190
+ const bg = window.getComputedStyle(node).backgroundColor;
191
+ ctx.fillStyle = bg && bg !== "rgba(0, 0, 0, 0)" ? bg : "#ffffff";
192
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
193
+ return canvasToDataUrl(canvas) ?? emptyPng;
194
+ }
195
+
196
+ async function sourceToSafeImage(source: CanvasImageSource): Promise<HTMLImageElement | null> {
197
+ try {
198
+ if (source instanceof HTMLCanvasElement) {
199
+ const dataUrl = canvasToDataUrl(source);
200
+ return dataUrl ? loadImage(dataUrl) : null;
201
+ }
202
+ if (!(source instanceof HTMLImageElement)) return null;
203
+ const src = source.currentSrc || source.src;
204
+ if (!src) return null;
205
+ if (src.startsWith("data:")) {
206
+ return source.complete ? source : loadImage(src);
207
+ }
208
+ try {
209
+ const canvas = document.createElement("canvas");
210
+ canvas.width = Math.max(1, source.naturalWidth || source.width);
211
+ canvas.height = Math.max(1, source.naturalHeight || source.height);
212
+ const ctx = canvas.getContext("2d");
213
+ if (ctx) {
214
+ ctx.drawImage(source, 0, 0);
215
+ const dataUrl = canvasToDataUrl(canvas);
216
+ if (dataUrl) return loadImage(dataUrl);
217
+ }
218
+ } catch {
219
+ // image is cross-origin without CORS; refetch below
220
+ }
221
+ const fetched = await urlToDataUrl(src);
222
+ return fetched ? loadImage(fetched) : null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ function inlineComputedStyles(source: Element, target: Element) {
229
+ if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) return;
230
+ const computed = window.getComputedStyle(source);
231
+ for (const prop of STYLE_PROPS) {
232
+ const value = computed.getPropertyValue(
233
+ prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
234
+ );
235
+ if (value) {
236
+ target.style.setProperty(
237
+ prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
238
+ value
239
+ );
240
+ }
241
+ }
242
+ const sourceChildren = Array.from(source.children);
243
+ const targetChildren = Array.from(target.children);
244
+ sourceChildren.forEach((child, index) => {
245
+ const targetChild = targetChildren[index];
246
+ if (child && targetChild) inlineComputedStyles(child, targetChild);
247
+ });
248
+ }
249
+
250
+ function replaceCanvasesInClone(source: Element, clone: Element) {
251
+ const sourceCanvases = Array.from(source.querySelectorAll("canvas"));
252
+ const cloneCanvases = Array.from(clone.querySelectorAll("canvas"));
253
+ sourceCanvases.forEach((srcCanvas, index) => {
254
+ const clonedCanvas = cloneCanvases[index];
255
+ if (!clonedCanvas) return;
256
+ const dataUrl = canvasToDataUrl(srcCanvas);
257
+ if (!dataUrl) {
258
+ clonedCanvas.remove();
259
+ return;
260
+ }
261
+ const img = document.createElement("img");
262
+ img.src = dataUrl;
263
+ img.style.width = "100%";
264
+ img.style.height = "100%";
265
+ img.style.objectFit = "fill";
266
+ img.style.display = "block";
267
+ clonedCanvas.replaceWith(img);
268
+ });
269
+ }
270
+
271
+ function replaceVideosInClone(source: Element, clone: Element) {
272
+ const sourceVideos = Array.from(source.querySelectorAll("video"));
273
+ const cloneVideos = Array.from(clone.querySelectorAll("video"));
274
+ sourceVideos.forEach((video, index) => {
275
+ const clonedVideo = cloneVideos[index];
276
+ if (!clonedVideo) return;
277
+ try {
278
+ const canvas = document.createElement("canvas");
279
+ canvas.width = Math.max(1, video.videoWidth || video.clientWidth);
280
+ canvas.height = Math.max(1, video.videoHeight || video.clientHeight);
281
+ const ctx = canvas.getContext("2d");
282
+ if (!ctx) {
283
+ clonedVideo.remove();
284
+ return;
285
+ }
286
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
287
+ const dataUrl = canvasToDataUrl(canvas);
288
+ if (!dataUrl) {
289
+ clonedVideo.remove();
290
+ return;
291
+ }
292
+ const img = document.createElement("img");
293
+ img.src = dataUrl;
294
+ img.style.width = "100%";
295
+ img.style.height = "100%";
296
+ img.style.objectFit = "contain";
297
+ img.style.display = "block";
298
+ clonedVideo.replaceWith(img);
299
+ } catch {
300
+ clonedVideo.remove();
301
+ }
302
+ });
303
+ }
304
+
305
+ async function nodeToDataUrlViaSvg(node: HTMLElement, width: number, height: number): Promise<string> {
306
+ const clone = node.cloneNode(true) as HTMLElement;
307
+ inlineComputedStyles(node, clone);
308
+ replaceCanvasesInClone(node, clone);
309
+ replaceVideosInClone(node, clone);
310
+ await inlineCloneResources(clone);
311
+ clone.style.width = `${width}px`;
312
+ clone.style.height = `${height}px`;
313
+ clone.style.margin = "0";
314
+ clone.style.position = "relative";
315
+ clone.style.left = "0";
316
+ clone.style.top = "0";
317
+ clone.style.transform = "none";
318
+
319
+ const wrapper = document.createElement("div");
320
+ wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
321
+ wrapper.style.width = `${width}px`;
322
+ wrapper.style.height = `${height}px`;
323
+ wrapper.style.overflow = "hidden";
324
+ wrapper.style.background = "#ffffff";
325
+ wrapper.appendChild(clone);
326
+
327
+ const serialized = new XMLSerializer().serializeToString(wrapper);
328
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
329
+ <foreignObject width="100%" height="100%">
330
+ ${serialized}
331
+ </foreignObject>
332
+ </svg>`;
333
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
334
+ const url = URL.createObjectURL(blob);
335
+ try {
336
+ const img = await loadImage(url);
337
+ const canvas = document.createElement("canvas");
338
+ canvas.width = width;
339
+ canvas.height = height;
340
+ const ctx = canvas.getContext("2d");
341
+ if (!ctx) throw new Error("canvas-context-failed");
342
+ ctx.drawImage(img, 0, 0);
343
+ return canvasToDataUrl(canvas) ?? fallbackNodeDataUrl(node, width, height);
344
+ } catch {
345
+ return fallbackNodeDataUrl(node, width, height);
346
+ } finally {
347
+ URL.revokeObjectURL(url);
348
+ }
349
+ }
350
+
351
+ function drawFittedImage(
352
+ ctx: CanvasRenderingContext2D,
353
+ img: HTMLImageElement,
354
+ width: number,
355
+ height: number,
356
+ backgroundSize: string
357
+ ) {
358
+ const iw = Math.max(1, img.naturalWidth || img.width);
359
+ const ih = Math.max(1, img.naturalHeight || img.height);
360
+ if (backgroundSize === "cover" || backgroundSize === "contain") {
361
+ const scale =
362
+ backgroundSize === "cover"
363
+ ? Math.max(width / iw, height / ih)
364
+ : Math.min(width / iw, height / ih);
365
+ const dw = iw * scale;
366
+ const dh = ih * scale;
367
+ ctx.drawImage(img, (width - dw) / 2, (height - dh) / 2, dw, dh);
368
+ return;
369
+ }
370
+ ctx.drawImage(img, 0, 0, width, height);
371
+ }
372
+
373
+ async function paintCssBackgroundToImage(
374
+ node: HTMLElement,
375
+ width: number,
376
+ height: number
377
+ ): Promise<HTMLImageElement | null> {
378
+ const style = window.getComputedStyle(node);
379
+ const url =
380
+ parseCssUrl(node.style.backgroundImage) ??
381
+ parseCssUrl(style.backgroundImage);
382
+ if (!url) return null;
383
+
384
+ const canvas = document.createElement("canvas");
385
+ canvas.width = width;
386
+ canvas.height = height;
387
+ const ctx = canvas.getContext("2d");
388
+ if (!ctx) return null;
389
+ const bgColor = style.backgroundColor;
390
+ if (bgColor && bgColor !== "rgba(0, 0, 0, 0)") {
391
+ ctx.fillStyle = bgColor;
392
+ ctx.fillRect(0, 0, width, height);
393
+ }
394
+ const dataUrl = url.startsWith("data:") ? url : await urlToDataUrl(url);
395
+ if (!dataUrl) return null;
396
+ try {
397
+ const img = await loadImage(dataUrl);
398
+ drawFittedImage(ctx, img, width, height, style.backgroundSize || "");
399
+ } catch {
400
+ return null;
401
+ }
402
+ const out = canvasToDataUrl(canvas);
403
+ return out ? loadImage(out) : null;
404
+ }
405
+
406
+ async function drawNodeOntoCanvas(
407
+ ctx: CanvasRenderingContext2D,
408
+ node: HTMLElement,
409
+ x: number,
410
+ y: number,
411
+ width: number,
412
+ height: number,
413
+ rotateDeg: number
414
+ ) {
415
+ const w = Math.max(1, width);
416
+ const h = Math.max(1, height);
417
+ let image: HTMLImageElement | null = null;
418
+
419
+ const canvases = Array.from(node.querySelectorAll("canvas"));
420
+ if (canvases.length === 1) {
421
+ image = await sourceToSafeImage(canvases[0]!);
422
+ } else if (canvases.length > 1) {
423
+ const merged = document.createElement("canvas");
424
+ merged.width = w;
425
+ merged.height = h;
426
+ const mctx = merged.getContext("2d");
427
+ if (mctx) {
428
+ for (const canvas of canvases) {
429
+ const safe = await sourceToSafeImage(canvas);
430
+ if (!safe) continue;
431
+ mctx.drawImage(safe, 0, 0, w, h);
432
+ }
433
+ image = await sourceToSafeImage(merged);
434
+ }
435
+ } else {
436
+ const imgs = Array.from(node.querySelectorAll("img"));
437
+ if (imgs.length === 1) {
438
+ image = await sourceToSafeImage(imgs[0]!);
439
+ } else if (imgs.length > 1) {
440
+ const merged = document.createElement("canvas");
441
+ merged.width = w;
442
+ merged.height = h;
443
+ const mctx = merged.getContext("2d");
444
+ if (mctx) {
445
+ for (const img of imgs) {
446
+ const safe = await sourceToSafeImage(img);
447
+ if (!safe) continue;
448
+ mctx.drawImage(safe, 0, 0, w, h);
449
+ }
450
+ image = await sourceToSafeImage(merged);
451
+ }
452
+ }
453
+ }
454
+
455
+ if (!image) {
456
+ image = await paintCssBackgroundToImage(node, w, h);
457
+ }
458
+
459
+ if (!image) {
460
+ const dataUrl = await nodeToDataUrlViaSvg(node, w, h);
461
+ image = await loadImage(dataUrl);
462
+ }
463
+
464
+ ctx.save();
465
+ ctx.translate(x + w / 2, y + h / 2);
466
+ ctx.rotate((rotateDeg * Math.PI) / 180);
467
+ ctx.drawImage(image, -w / 2, -h / 2, w, h);
468
+ ctx.restore();
469
+ }
470
+
471
+ async function loadUrlAsImage(url: string): Promise<HTMLImageElement | null> {
472
+ try {
473
+ if (url.startsWith("data:") || url.startsWith("blob:")) {
474
+ return await loadImage(url);
475
+ }
476
+ const dataUrl = await urlToDataUrl(url);
477
+ return dataUrl ? loadImage(dataUrl) : null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
483
+ function htmlToPlainText(html: string): string {
484
+ const box = document.createElement("div");
485
+ box.innerHTML = html;
486
+ return (box.innerText || box.textContent || "").trim();
487
+ }
488
+
489
+ function drawWrappedText(
490
+ ctx: CanvasRenderingContext2D,
491
+ text: string,
492
+ width: number,
493
+ height: number,
494
+ options: {
495
+ fontSize: number;
496
+ fontFamily: string;
497
+ fontWeight: string;
498
+ color: string;
499
+ align: CanvasTextAlign;
500
+ lineHeight: number;
501
+ }
502
+ ) {
503
+ const padding = 8;
504
+ const maxWidth = Math.max(1, width - padding * 2);
505
+ ctx.fillStyle = options.color;
506
+ ctx.font = `${options.fontWeight} ${options.fontSize}px ${options.fontFamily}`;
507
+ ctx.textAlign = options.align || "left";
508
+ ctx.textBaseline = "top";
509
+ const x =
510
+ options.align === "center" ? width / 2 : options.align === "right" ? width - padding : padding;
511
+ let y = padding;
512
+ for (const paragraph of text.split("\n")) {
513
+ const words = paragraph.length ? paragraph.split(/(\s+)/) : [""];
514
+ let line = "";
515
+ for (const word of words) {
516
+ const next = line + word;
517
+ if (ctx.measureText(next).width > maxWidth && line) {
518
+ ctx.fillText(line, x, y, maxWidth);
519
+ y += options.lineHeight;
520
+ if (y > height - padding) return;
521
+ line = word.trimStart();
522
+ } else {
523
+ line = next;
524
+ }
525
+ }
526
+ ctx.fillText(line, x, y, maxWidth);
527
+ y += options.lineHeight;
528
+ if (y > height - padding) return;
529
+ }
530
+ }
531
+
532
+ async function paintPanelElementOntoCanvas(
533
+ ctx: CanvasRenderingContext2D,
534
+ el: PanelElement,
535
+ liveNode: HTMLElement | null,
536
+ originX: number,
537
+ originY: number
538
+ ) {
539
+ const w = Math.max(1, el.width);
540
+ const h = Math.max(1, el.height);
541
+ const x = el.x - originX;
542
+ const y = el.y - originY;
543
+
544
+ ctx.save();
545
+ ctx.translate(x + w / 2, y + h / 2);
546
+ ctx.rotate(((el.rotate ?? 0) * Math.PI) / 180);
547
+ ctx.beginPath();
548
+ ctx.rect(-w / 2, -h / 2, w, h);
549
+ ctx.clip();
550
+ ctx.translate(-w / 2, -h / 2);
551
+
552
+ const computed = liveNode ? window.getComputedStyle(liveNode) : null;
553
+ const bgColor =
554
+ el.style?.backgroundColor ||
555
+ (computed && computed.backgroundColor !== "rgba(0, 0, 0, 0)"
556
+ ? computed.backgroundColor
557
+ : "");
558
+ if (bgColor) {
559
+ ctx.fillStyle = bgColor;
560
+ ctx.fillRect(0, 0, w, h);
561
+ }
562
+
563
+ const bgUrl =
564
+ parseCssUrl(el.style?.backgroundImage) ??
565
+ parseCssUrl(liveNode?.style.backgroundImage) ??
566
+ parseCssUrl(computed?.backgroundImage);
567
+ if (bgUrl) {
568
+ const img = await loadUrlAsImage(bgUrl);
569
+ if (img) {
570
+ drawFittedImage(
571
+ ctx,
572
+ img,
573
+ w,
574
+ h,
575
+ el.style?.backgroundSize || computed?.backgroundSize || ""
576
+ );
577
+ }
578
+ }
579
+
580
+ if (liveNode) {
581
+ for (const canvas of Array.from(liveNode.querySelectorAll("canvas"))) {
582
+ const safe = await sourceToSafeImage(canvas);
583
+ if (safe) ctx.drawImage(safe, 0, 0, w, h);
584
+ }
585
+ for (const img of Array.from(liveNode.querySelectorAll("img"))) {
586
+ const safe = await sourceToSafeImage(img);
587
+ if (safe) ctx.drawImage(safe, 0, 0, w, h);
588
+ }
589
+ const video = liveNode.querySelector("video");
590
+ if (video) {
591
+ try {
592
+ ctx.drawImage(video, 0, 0, w, h);
593
+ } catch {
594
+ // tainted video frame
595
+ }
596
+ }
597
+ }
598
+
599
+ if (el.materialType === "text") {
600
+ const textNode = liveNode?.querySelector<HTMLElement>("[data-panel-user-text]");
601
+ const text = htmlToPlainText(el.textHtml || textNode?.innerHTML || "");
602
+ if (text) {
603
+ const fontSize = el.textFontSize ?? 14;
604
+ drawWrappedText(ctx, text, w, h, {
605
+ fontSize,
606
+ fontFamily: el.textFontFamily || "sans-serif",
607
+ fontWeight: String(el.textFontWeight || "400"),
608
+ color: el.textColor || computed?.color || "#111827",
609
+ align:
610
+ el.textAlign === "center" || el.textAlign === "right" || el.textAlign === "left"
611
+ ? el.textAlign
612
+ : "left",
613
+ lineHeight:
614
+ typeof el.textLineHeight === "number" ? el.textLineHeight : fontSize * 1.4,
615
+ });
616
+ }
617
+ }
618
+
619
+ if (
620
+ liveNode &&
621
+ (el.materialType === "reference" ||
622
+ el.materialType === "table" ||
623
+ el.materialType === "grid" ||
624
+ el.materialType === "viewport")
625
+ ) {
626
+ try {
627
+ await drawNodeOntoCanvas(ctx, liveNode, 0, 0, w, h, 0);
628
+ } catch {
629
+ // keep painted background / children
630
+ }
631
+ }
632
+
633
+ ctx.restore();
634
+ }
635
+
636
+ export type CapturePreviewSceneOptions = {
637
+ backgroundColor?: string;
638
+ maxWidth?: number;
639
+ maxHeight?: number;
640
+ mimeType?: "image/png" | "image/jpeg";
641
+ quality?: number;
642
+ };
643
+
644
+ export type CapturePreviewLayerOptions = CapturePreviewSceneOptions & {
645
+ canvasRoot: HTMLElement | null;
646
+ elements: PanelElement[];
647
+ };
648
+
649
+ export async function capturePreviewLayerSnapshot(
650
+ options: CapturePreviewLayerOptions
651
+ ): Promise<string | null> {
652
+ const elements = options.elements;
653
+ if (!elements.length) return null;
654
+
655
+ let minX = Infinity;
656
+ let minY = Infinity;
657
+ let maxX = -Infinity;
658
+ let maxY = -Infinity;
659
+ for (const el of elements) {
660
+ const w = Math.max(1, el.width);
661
+ const h = Math.max(1, el.height);
662
+ const rad = ((el.rotate ?? 0) * Math.PI) / 180;
663
+ const absCos = Math.abs(Math.cos(rad));
664
+ const absSin = Math.abs(Math.sin(rad));
665
+ const bw = w * absCos + h * absSin;
666
+ const bh = w * absSin + h * absCos;
667
+ const cx = el.x + w / 2;
668
+ const cy = el.y + h / 2;
669
+ minX = Math.min(minX, cx - bw / 2);
670
+ minY = Math.min(minY, cy - bh / 2);
671
+ maxX = Math.max(maxX, cx + bw / 2);
672
+ maxY = Math.max(maxY, cy + bh / 2);
673
+ }
674
+ const width = Math.max(1, Math.round(maxX - minX));
675
+ const height = Math.max(1, Math.round(maxY - minY));
676
+
677
+ const canvas = document.createElement("canvas");
678
+ canvas.width = width;
679
+ canvas.height = height;
680
+ const ctx = canvas.getContext("2d");
681
+ if (!ctx) throw new Error("canvas-context-failed");
682
+ ctx.fillStyle = options.backgroundColor ?? "#ffffff";
683
+ ctx.fillRect(0, 0, width, height);
684
+
685
+ const sorted = [...elements]
686
+ .sort((a, b) => (a.zIndex ?? 1) - (b.zIndex ?? 1));
687
+ for (const el of sorted) {
688
+ const liveNode =
689
+ options.canvasRoot?.querySelector<HTMLElement>(
690
+ `[data-element-id="${el.id.replace(/"/g, '\\"')}"]`
691
+ ) ?? null;
692
+ try {
693
+ await paintPanelElementOntoCanvas(ctx, el, liveNode, minX, minY);
694
+ } catch {
695
+ // keep remaining nodes
696
+ }
697
+ }
698
+
699
+ const mimeType = options.mimeType ?? "image/png";
700
+ const quality = options.quality ?? 0.92;
701
+ let dataUrl = canvasToDataUrl(canvas, mimeType, quality);
702
+ if (!dataUrl) return null;
703
+ if (options.maxWidth || options.maxHeight) {
704
+ dataUrl = await scaleDataUrl(
705
+ dataUrl,
706
+ options.maxWidth,
707
+ options.maxHeight,
708
+ mimeType,
709
+ quality
710
+ );
711
+ }
712
+ return dataUrl;
713
+ }
714
+
715
+ export async function capturePreviewSceneElement(
716
+ scene: HTMLElement,
717
+ sceneWidth: number,
718
+ sceneHeight: number,
719
+ options?: CapturePreviewSceneOptions
720
+ ): Promise<string> {
721
+ const width = Math.max(1, Math.round(sceneWidth));
722
+ const height = Math.max(1, Math.round(sceneHeight));
723
+ const canvas = document.createElement("canvas");
724
+ canvas.width = width;
725
+ canvas.height = height;
726
+ const ctx = canvas.getContext("2d");
727
+ if (!ctx) throw new Error("canvas-context-failed");
728
+
729
+ ctx.fillStyle = options?.backgroundColor ?? "#ffffff";
730
+ ctx.fillRect(0, 0, width, height);
731
+
732
+ const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]")).filter(
733
+ (node) => !node.parentElement?.closest("[data-element-id]")
734
+ );
735
+ const sorted = nodes
736
+ .map((node) => {
737
+ const zIndex = Number.parseInt(window.getComputedStyle(node).zIndex || "1", 10);
738
+ return { node, zIndex: Number.isFinite(zIndex) ? zIndex : 1 };
739
+ })
740
+ .sort((a, b) => a.zIndex - b.zIndex);
741
+
742
+ for (const { node } of sorted) {
743
+ const left = Number.parseFloat(node.style.left || "0") || 0;
744
+ const top = Number.parseFloat(node.style.top || "0") || 0;
745
+ const nodeWidth = Number.parseFloat(node.style.width || "") || node.offsetWidth || 1;
746
+ const nodeHeight = Number.parseFloat(node.style.height || "") || node.offsetHeight || 1;
747
+ const transform = node.style.transform || "";
748
+ const rotateMatch = /rotate\(([-\d.]+)deg\)/.exec(transform);
749
+ const rotateDeg = rotateMatch ? Number.parseFloat(rotateMatch[1]!) : 0;
750
+ try {
751
+ await drawNodeOntoCanvas(ctx, node, left, top, nodeWidth, nodeHeight, rotateDeg);
752
+ } catch {
753
+ // skip nodes that cannot be snapshotted (tainted canvas / CORS)
754
+ }
755
+ }
756
+
757
+ const mimeType = options?.mimeType ?? "image/png";
758
+ const quality = options?.quality ?? 0.92;
759
+ let dataUrl = canvasToDataUrl(canvas, mimeType, quality);
760
+ if (!dataUrl) {
761
+ throw new Error("preview-snapshot-tainted");
762
+ }
763
+
764
+ const maxWidth = options?.maxWidth;
765
+ const maxHeight = options?.maxHeight;
766
+ if (maxWidth || maxHeight) {
767
+ dataUrl = await scaleDataUrl(dataUrl, maxWidth, maxHeight, mimeType, quality);
768
+ }
769
+ return dataUrl;
770
+ }
771
+
772
+ export async function scaleDataUrl(
773
+ dataUrl: string,
774
+ maxWidth?: number,
775
+ maxHeight?: number,
776
+ mimeType: "image/png" | "image/jpeg" = "image/png",
777
+ quality = 0.92
778
+ ): Promise<string> {
779
+ if (!maxWidth && !maxHeight) return dataUrl;
780
+ const img = await loadImage(dataUrl);
781
+ const ratio = img.width / img.height;
782
+ let targetWidth = img.width;
783
+ let targetHeight = img.height;
784
+ if (maxWidth && targetWidth > maxWidth) {
785
+ targetWidth = maxWidth;
786
+ targetHeight = Math.round(targetWidth / ratio);
787
+ }
788
+ if (maxHeight && targetHeight > maxHeight) {
789
+ targetHeight = maxHeight;
790
+ targetWidth = Math.round(targetHeight * ratio);
791
+ }
792
+ if (targetWidth === img.width && targetHeight === img.height) return dataUrl;
793
+ const canvas = document.createElement("canvas");
794
+ canvas.width = targetWidth;
795
+ canvas.height = targetHeight;
796
+ const ctx = canvas.getContext("2d");
797
+ if (!ctx) return dataUrl;
798
+ ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
799
+ return canvasToDataUrl(canvas, mimeType, quality) ?? dataUrl;
800
+ }