@arronqzy/react-view 1.0.18 → 1.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/react-view",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "files": [
5
5
  "src",
6
6
  "dist",
@@ -30,14 +30,14 @@
30
30
  "@codemirror/lang-json": "^6.0.1",
31
31
  "@codemirror/theme-one-dark": "^6.1.2",
32
32
  "@codemirror/view": "^6.36.2",
33
- "@arronqzy/ui": "1.0.6",
34
- "@arronqzy/blueprint-dsl": "1.0.9",
35
33
  "@arronqzy/i18n": "0.1.7",
34
+ "@arronqzy/blueprint-dsl": "1.0.9",
35
+ "@arronqzy/ui": "1.0.6",
36
36
  "@arronqzy/rx-store": "1.0.5",
37
+ "@arronqzy/react-blueprint": "1.0.11",
37
38
  "@arronqzy/view-scene3d": "0.1.2",
38
39
  "@arronqzy/view-table": "0.1.5",
39
- "@arronqzy/webllm-assistant": "0.1.9",
40
- "@arronqzy/react-blueprint": "1.0.11"
40
+ "@arronqzy/webllm-assistant": "0.1.9"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react": "^19.0.1",
@@ -0,0 +1,21 @@
1
+ import { I18nProvider } from "@arronqzy/i18n/react";
2
+ import type { Locale } from "@arronqzy/i18n";
3
+ import type { ReactNode } from "react";
4
+
5
+ export type I18nRootProps = {
6
+ children: ReactNode;
7
+ locale?: Locale | null;
8
+ onLocaleChange?: (locale: Locale) => void;
9
+ };
10
+
11
+ /** Ensures panel / preview subtree always has i18n context (host apps, duplicate package graphs). */
12
+ export function I18nRoot({ children, locale, onLocaleChange }: I18nRootProps) {
13
+ return (
14
+ <I18nProvider locale={locale} onLocaleChange={onLocaleChange}>
15
+ {children}
16
+ </I18nProvider>
17
+ );
18
+ }
19
+
20
+ export { I18nProvider } from "@arronqzy/i18n/react";
21
+ export type { Locale } from "@arronqzy/i18n";
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { useI18n } from "@arronqzy/i18n/react";
3
+ import type { Locale } from "@arronqzy/i18n";
3
4
  import {
4
5
  BlueprintGraph,
5
6
  documentToRunnableGraph,
@@ -41,6 +42,7 @@ import {
41
42
  import { applyPreviewSceneFill, readOutputScale } from "./utils/outputScale";
42
43
  import { capturePreviewSceneElement } from "./library/capture-preview-snapshot";
43
44
  import { registerPreviewSnapshotProvider } from "./library/preview-snapshot";
45
+ import { I18nRoot } from "./I18nRoot";
44
46
 
45
47
  const PREVIEW_BOOT_PHASES: PageLifecyclePhase[] = ["mounted"];
46
48
 
@@ -50,6 +52,8 @@ export type ReactViewOnlinePreviewProps = {
50
52
  previewInstanceId?: string;
51
53
  /** 直接传入完整工作区数据,用于外部持久化后预览 */
52
54
  workspace?: WorkspaceProjectRecord | null;
55
+ locale?: Locale | null;
56
+ onLocaleChange?: (locale: Locale) => void;
53
57
  };
54
58
 
55
59
  function applyTitleIcon(titleIconDataUrl?: string) {
@@ -71,6 +75,18 @@ async function loadWorkspaceRecord(projectId: string): Promise<WorkspaceProjectR
71
75
  }
72
76
 
73
77
  export function ReactViewOnlinePreview({
78
+ locale,
79
+ onLocaleChange,
80
+ ...rest
81
+ }: ReactViewOnlinePreviewProps) {
82
+ return (
83
+ <I18nRoot locale={locale} onLocaleChange={onLocaleChange}>
84
+ <ReactViewOnlinePreviewInner {...rest} />
85
+ </I18nRoot>
86
+ );
87
+ }
88
+
89
+ function ReactViewOnlinePreviewInner({
74
90
  projectId,
75
91
  previewInstanceId,
76
92
  workspace = null,
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useI18n } from "@arronqzy/i18n/react";
3
+ import type { Locale } from "@arronqzy/i18n";
3
4
  import type { State } from "@arronqzy/rx-store";
4
5
  import type { PanelElement } from "./types";
5
6
 
@@ -78,6 +79,7 @@ import {
78
79
  useViewScopeStoreVersion,
79
80
  } from "./scope/view-scope-store";
80
81
  import { resolvePanelElementScope } from "./utils/scope-template";
82
+ import { I18nRoot } from "./I18nRoot";
81
83
  import { getPanelMessages } from "./constants/messages";
82
84
  import { PANEL_Z_INDEX } from "./constants/zIndex";
83
85
  import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
@@ -362,9 +364,12 @@ export type ReactViewPanelProps = {
362
364
  className?: string;
363
365
  /** 外部传入的完整工作区数据,挂载后自动渲染 */
364
366
  initialWorkspace?: WorkspaceProjectRecord | null;
367
+ /** 界面语言;省略时按 localStorage / 浏览器语言解析 */
368
+ locale?: Locale | null;
369
+ onLocaleChange?: (locale: Locale) => void;
365
370
  };
366
371
 
367
- export function ReactViewPanel({
372
+ function ReactViewPanelInner({
368
373
  initialZoom = 1,
369
374
  className,
370
375
  initialWorkspace = null,
@@ -3578,3 +3583,15 @@ export function ReactViewPanel({
3578
3583
  </div>
3579
3584
  );
3580
3585
  }
3586
+
3587
+ export function ReactViewPanel({
3588
+ locale,
3589
+ onLocaleChange,
3590
+ ...rest
3591
+ }: ReactViewPanelProps) {
3592
+ return (
3593
+ <I18nRoot locale={locale} onLocaleChange={onLocaleChange}>
3594
+ <ReactViewPanelInner {...rest} />
3595
+ </I18nRoot>
3596
+ );
3597
+ }
@@ -1,3 +1,5 @@
1
1
  export * from "./ReactViewPanel";
2
2
  export * from "./ReactViewOnlinePreview";
3
+ export { I18nRoot, I18nProvider } from "./I18nRoot";
4
+ export type { I18nRootProps, Locale } from "./I18nRoot";
3
5
  export type * from "./types";
@@ -1,3 +1,5 @@
1
+ import type { PanelElement } from "../types";
2
+
1
3
  const STYLE_PROPS = [
2
4
  "display",
3
5
  "position",
@@ -42,15 +44,187 @@ const STYLE_PROPS = [
42
44
  "zIndex",
43
45
  ] as const;
44
46
 
45
- function loadImage(src: string): Promise<HTMLImageElement> {
47
+ function loadImage(
48
+ src: string,
49
+ crossOrigin?: "anonymous" | "use-credentials"
50
+ ): Promise<HTMLImageElement> {
46
51
  return new Promise((resolve, reject) => {
47
52
  const img = new Image();
53
+ if (crossOrigin && !src.startsWith("data:") && !src.startsWith("blob:")) {
54
+ img.crossOrigin = crossOrigin;
55
+ }
48
56
  img.onload = () => resolve(img);
49
57
  img.onerror = () => reject(new Error("image-load-failed"));
50
58
  img.src = src;
51
59
  });
52
60
  }
53
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
+
54
228
  function inlineComputedStyles(source: Element, target: Element) {
55
229
  if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) return;
56
230
  const computed = window.getComputedStyle(source);
@@ -79,17 +253,51 @@ function replaceCanvasesInClone(source: Element, clone: Element) {
79
253
  sourceCanvases.forEach((srcCanvas, index) => {
80
254
  const clonedCanvas = cloneCanvases[index];
81
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;
82
277
  try {
83
- const dataUrl = srcCanvas.toDataURL("image/png");
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
+ }
84
292
  const img = document.createElement("img");
85
293
  img.src = dataUrl;
86
294
  img.style.width = "100%";
87
295
  img.style.height = "100%";
88
- img.style.objectFit = "fill";
296
+ img.style.objectFit = "contain";
89
297
  img.style.display = "block";
90
- clonedCanvas.replaceWith(img);
298
+ clonedVideo.replaceWith(img);
91
299
  } catch {
92
- clonedCanvas.remove();
300
+ clonedVideo.remove();
93
301
  }
94
302
  });
95
303
  }
@@ -98,6 +306,8 @@ async function nodeToDataUrlViaSvg(node: HTMLElement, width: number, height: num
98
306
  const clone = node.cloneNode(true) as HTMLElement;
99
307
  inlineComputedStyles(node, clone);
100
308
  replaceCanvasesInClone(node, clone);
309
+ replaceVideosInClone(node, clone);
310
+ await inlineCloneResources(clone);
101
311
  clone.style.width = `${width}px`;
102
312
  clone.style.height = `${height}px`;
103
313
  clone.style.margin = "0";
@@ -130,12 +340,69 @@ async function nodeToDataUrlViaSvg(node: HTMLElement, width: number, height: num
130
340
  const ctx = canvas.getContext("2d");
131
341
  if (!ctx) throw new Error("canvas-context-failed");
132
342
  ctx.drawImage(img, 0, 0);
133
- return canvas.toDataURL("image/png");
343
+ return canvasToDataUrl(canvas) ?? fallbackNodeDataUrl(node, width, height);
344
+ } catch {
345
+ return fallbackNodeDataUrl(node, width, height);
134
346
  } finally {
135
347
  URL.revokeObjectURL(url);
136
348
  }
137
349
  }
138
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
+
139
406
  async function drawNodeOntoCanvas(
140
407
  ctx: CanvasRenderingContext2D,
141
408
  node: HTMLElement,
@@ -147,11 +414,11 @@ async function drawNodeOntoCanvas(
147
414
  ) {
148
415
  const w = Math.max(1, width);
149
416
  const h = Math.max(1, height);
150
- let source: CanvasImageSource | null = null;
417
+ let image: HTMLImageElement | null = null;
151
418
 
152
419
  const canvases = Array.from(node.querySelectorAll("canvas"));
153
420
  if (canvases.length === 1) {
154
- source = canvases[0]!;
421
+ image = await sourceToSafeImage(canvases[0]!);
155
422
  } else if (canvases.length > 1) {
156
423
  const merged = document.createElement("canvas");
157
424
  merged.width = w;
@@ -159,18 +426,16 @@ async function drawNodeOntoCanvas(
159
426
  const mctx = merged.getContext("2d");
160
427
  if (mctx) {
161
428
  for (const canvas of canvases) {
162
- try {
163
- mctx.drawImage(canvas, 0, 0, w, h);
164
- } catch {
165
- // ignore tainted canvas
166
- }
429
+ const safe = await sourceToSafeImage(canvas);
430
+ if (!safe) continue;
431
+ mctx.drawImage(safe, 0, 0, w, h);
167
432
  }
168
- source = merged;
433
+ image = await sourceToSafeImage(merged);
169
434
  }
170
435
  } else {
171
436
  const imgs = Array.from(node.querySelectorAll("img"));
172
437
  if (imgs.length === 1) {
173
- source = imgs[0]!;
438
+ image = await sourceToSafeImage(imgs[0]!);
174
439
  } else if (imgs.length > 1) {
175
440
  const merged = document.createElement("canvas");
176
441
  merged.width = w;
@@ -178,30 +443,20 @@ async function drawNodeOntoCanvas(
178
443
  const mctx = merged.getContext("2d");
179
444
  if (mctx) {
180
445
  for (const img of imgs) {
181
- try {
182
- mctx.drawImage(img, 0, 0, w, h);
183
- } catch {
184
- // ignore
185
- }
446
+ const safe = await sourceToSafeImage(img);
447
+ if (!safe) continue;
448
+ mctx.drawImage(safe, 0, 0, w, h);
186
449
  }
187
- source = merged;
450
+ image = await sourceToSafeImage(merged);
188
451
  }
189
452
  }
190
453
  }
191
454
 
192
- let image: HTMLImageElement;
193
- if (source) {
194
- if (source instanceof HTMLImageElement) {
195
- image = source;
196
- if (!image.complete) await loadImage(image.src);
197
- } else {
198
- const dataUrl =
199
- source instanceof HTMLCanvasElement
200
- ? source.toDataURL("image/png")
201
- : "";
202
- image = await loadImage(dataUrl);
203
- }
204
- } else {
455
+ if (!image) {
456
+ image = await paintCssBackgroundToImage(node, w, h);
457
+ }
458
+
459
+ if (!image) {
205
460
  const dataUrl = await nodeToDataUrlViaSvg(node, w, h);
206
461
  image = await loadImage(dataUrl);
207
462
  }
@@ -213,6 +468,170 @@ async function drawNodeOntoCanvas(
213
468
  ctx.restore();
214
469
  }
215
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
+ ) {
625
+ try {
626
+ await drawNodeOntoCanvas(ctx, liveNode, 0, 0, w, h, 0);
627
+ } catch {
628
+ // keep painted background / children
629
+ }
630
+ }
631
+
632
+ ctx.restore();
633
+ }
634
+
216
635
  export type CapturePreviewSceneOptions = {
217
636
  backgroundColor?: string;
218
637
  maxWidth?: number;
@@ -221,6 +640,76 @@ export type CapturePreviewSceneOptions = {
221
640
  quality?: number;
222
641
  };
223
642
 
643
+ export type CapturePreviewLayerOptions = CapturePreviewSceneOptions & {
644
+ canvasRoot: HTMLElement | null;
645
+ elements: PanelElement[];
646
+ };
647
+
648
+ export async function capturePreviewLayerSnapshot(
649
+ options: CapturePreviewLayerOptions
650
+ ): Promise<string | null> {
651
+ const elements = options.elements;
652
+ if (!elements.length) return null;
653
+
654
+ let minX = Infinity;
655
+ let minY = Infinity;
656
+ let maxX = -Infinity;
657
+ let maxY = -Infinity;
658
+ for (const el of elements) {
659
+ const w = Math.max(1, el.width);
660
+ const h = Math.max(1, el.height);
661
+ const rad = ((el.rotate ?? 0) * Math.PI) / 180;
662
+ const absCos = Math.abs(Math.cos(rad));
663
+ const absSin = Math.abs(Math.sin(rad));
664
+ const bw = w * absCos + h * absSin;
665
+ const bh = w * absSin + h * absCos;
666
+ const cx = el.x + w / 2;
667
+ const cy = el.y + h / 2;
668
+ minX = Math.min(minX, cx - bw / 2);
669
+ minY = Math.min(minY, cy - bh / 2);
670
+ maxX = Math.max(maxX, cx + bw / 2);
671
+ maxY = Math.max(maxY, cy + bh / 2);
672
+ }
673
+ const width = Math.max(1, Math.round(maxX - minX));
674
+ const height = Math.max(1, Math.round(maxY - minY));
675
+
676
+ const canvas = document.createElement("canvas");
677
+ canvas.width = width;
678
+ canvas.height = height;
679
+ const ctx = canvas.getContext("2d");
680
+ if (!ctx) throw new Error("canvas-context-failed");
681
+ ctx.fillStyle = options.backgroundColor ?? "#ffffff";
682
+ ctx.fillRect(0, 0, width, height);
683
+
684
+ const sorted = [...elements].sort((a, b) => (a.zIndex ?? 1) - (b.zIndex ?? 1));
685
+ for (const el of sorted) {
686
+ const liveNode =
687
+ options.canvasRoot?.querySelector<HTMLElement>(
688
+ `[data-element-id="${el.id.replace(/"/g, '\\"')}"]`
689
+ ) ?? null;
690
+ try {
691
+ await paintPanelElementOntoCanvas(ctx, el, liveNode, minX, minY);
692
+ } catch {
693
+ // keep remaining nodes
694
+ }
695
+ }
696
+
697
+ const mimeType = options.mimeType ?? "image/png";
698
+ const quality = options.quality ?? 0.92;
699
+ let dataUrl = canvasToDataUrl(canvas, mimeType, quality);
700
+ if (!dataUrl) return null;
701
+ if (options.maxWidth || options.maxHeight) {
702
+ dataUrl = await scaleDataUrl(
703
+ dataUrl,
704
+ options.maxWidth,
705
+ options.maxHeight,
706
+ mimeType,
707
+ quality
708
+ );
709
+ }
710
+ return dataUrl;
711
+ }
712
+
224
713
  export async function capturePreviewSceneElement(
225
714
  scene: HTMLElement,
226
715
  sceneWidth: number,
@@ -238,7 +727,9 @@ export async function capturePreviewSceneElement(
238
727
  ctx.fillStyle = options?.backgroundColor ?? "#ffffff";
239
728
  ctx.fillRect(0, 0, width, height);
240
729
 
241
- const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]"));
730
+ const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]")).filter(
731
+ (node) => !node.parentElement?.closest("[data-element-id]")
732
+ );
242
733
  const sorted = nodes
243
734
  .map((node) => {
244
735
  const zIndex = Number.parseInt(window.getComputedStyle(node).zIndex || "1", 10);
@@ -254,12 +745,19 @@ export async function capturePreviewSceneElement(
254
745
  const transform = node.style.transform || "";
255
746
  const rotateMatch = /rotate\(([-\d.]+)deg\)/.exec(transform);
256
747
  const rotateDeg = rotateMatch ? Number.parseFloat(rotateMatch[1]!) : 0;
257
- await drawNodeOntoCanvas(ctx, node, left, top, nodeWidth, nodeHeight, rotateDeg);
748
+ try {
749
+ await drawNodeOntoCanvas(ctx, node, left, top, nodeWidth, nodeHeight, rotateDeg);
750
+ } catch {
751
+ // skip nodes that cannot be snapshotted (tainted canvas / CORS)
752
+ }
258
753
  }
259
754
 
260
755
  const mimeType = options?.mimeType ?? "image/png";
261
756
  const quality = options?.quality ?? 0.92;
262
- let dataUrl = canvas.toDataURL(mimeType, quality);
757
+ let dataUrl = canvasToDataUrl(canvas, mimeType, quality);
758
+ if (!dataUrl) {
759
+ throw new Error("preview-snapshot-tainted");
760
+ }
263
761
 
264
762
  const maxWidth = options?.maxWidth;
265
763
  const maxHeight = options?.maxHeight;
@@ -296,5 +794,5 @@ export async function scaleDataUrl(
296
794
  const ctx = canvas.getContext("2d");
297
795
  if (!ctx) return dataUrl;
298
796
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
299
- return canvas.toDataURL(mimeType, quality);
797
+ return canvasToDataUrl(canvas, mimeType, quality) ?? dataUrl;
300
798
  }
@@ -5,6 +5,7 @@ import {
5
5
  resolvePreviewLayerElements,
6
6
  } from "../utils/panelStateIO";
7
7
  import {
8
+ capturePreviewLayerSnapshot,
8
9
  capturePreviewSceneElement,
9
10
  scaleDataUrl,
10
11
  type CapturePreviewSceneOptions,
@@ -223,16 +224,29 @@ export async function getPreviewSnapshot(
223
224
  export async function captureEditorPreviewSnapshot(
224
225
  options: BuildEditorPreviewSnapshotSceneOptions & GetPreviewSnapshotOptions
225
226
  ): Promise<string | null> {
226
- const built = await buildEditorPreviewSnapshotScene(options);
227
- if (!built) return null;
228
- try {
229
- return await capturePreviewSceneElement(
230
- built.scene,
231
- built.width,
232
- built.height,
233
- options
234
- );
235
- } finally {
236
- disposePreviewSnapshotScene(built.scene);
227
+ const retryCount = options.retryCount ?? 0;
228
+ const layerElements = resolvePreviewLayerElements(
229
+ options.allElements,
230
+ options.layers,
231
+ options.activeLayerId
232
+ );
233
+ if (!layerElements.length) return null;
234
+
235
+ const mountedCount = layerElements.filter((el) =>
236
+ Boolean(options.canvasRoot?.querySelector(`[data-element-id="${el.id}"]`))
237
+ ).length;
238
+ if (mountedCount < layerElements.length && retryCount < 20) {
239
+ await new Promise((resolve) => window.setTimeout(resolve, 80));
240
+ return captureEditorPreviewSnapshot({ ...options, retryCount: retryCount + 1 });
237
241
  }
242
+
243
+ return capturePreviewLayerSnapshot({
244
+ canvasRoot: options.canvasRoot,
245
+ elements: layerElements,
246
+ backgroundColor: options.backgroundColor,
247
+ maxWidth: options.maxWidth,
248
+ maxHeight: options.maxHeight,
249
+ mimeType: options.mimeType,
250
+ quality: options.quality,
251
+ });
238
252
  }