@arronqzy/react-view 1.0.19 → 1.0.21
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/dist/styles.css +45 -0
- package/package.json +8 -8
- package/src/panel/ReactViewPanel.tsx +20 -18
- package/src/panel/ai/applyAssistantAction.ts +1 -0
- package/src/panel/components/ElementsLayer.tsx +67 -16
- package/src/panel/components/MaterialSidebar.tsx +18 -11
- package/src/panel/components/PanelConfigSidebar.tsx +139 -1
- package/src/panel/components/WorkspaceConfigSidebar.tsx +1 -0
- package/src/panel/hooks/usePanelElements.ts +10 -4
- package/src/panel/library/capture-preview-snapshot.ts +538 -38
- package/src/panel/library/preview-snapshot.ts +25 -11
- package/src/panel/types.ts +4 -0
- package/src/panel/utils/panelElementDefaults.ts +10 -0
- package/src/panel/utils/panelElementNodes.ts +5 -3
- package/src/panel/utils/viewportPlacement.ts +73 -0
|
@@ -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(
|
|
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
|
|
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 = "
|
|
296
|
+
img.style.objectFit = "contain";
|
|
89
297
|
img.style.display = "block";
|
|
90
|
-
|
|
298
|
+
clonedVideo.replaceWith(img);
|
|
91
299
|
} catch {
|
|
92
|
-
|
|
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
|
|
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
|
|
417
|
+
let image: HTMLImageElement | null = null;
|
|
151
418
|
|
|
152
419
|
const canvases = Array.from(node.querySelectorAll("canvas"));
|
|
153
420
|
if (canvases.length === 1) {
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// ignore
|
|
185
|
-
}
|
|
446
|
+
const safe = await sourceToSafeImage(img);
|
|
447
|
+
if (!safe) continue;
|
|
448
|
+
mctx.drawImage(safe, 0, 0, w, h);
|
|
186
449
|
}
|
|
187
|
-
|
|
450
|
+
image = await sourceToSafeImage(merged);
|
|
188
451
|
}
|
|
189
452
|
}
|
|
190
453
|
}
|
|
191
454
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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,171 @@ 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
|
+
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
|
+
|
|
216
636
|
export type CapturePreviewSceneOptions = {
|
|
217
637
|
backgroundColor?: string;
|
|
218
638
|
maxWidth?: number;
|
|
@@ -221,6 +641,77 @@ export type CapturePreviewSceneOptions = {
|
|
|
221
641
|
quality?: number;
|
|
222
642
|
};
|
|
223
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
|
+
|
|
224
715
|
export async function capturePreviewSceneElement(
|
|
225
716
|
scene: HTMLElement,
|
|
226
717
|
sceneWidth: number,
|
|
@@ -238,7 +729,9 @@ export async function capturePreviewSceneElement(
|
|
|
238
729
|
ctx.fillStyle = options?.backgroundColor ?? "#ffffff";
|
|
239
730
|
ctx.fillRect(0, 0, width, height);
|
|
240
731
|
|
|
241
|
-
const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]"))
|
|
732
|
+
const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]")).filter(
|
|
733
|
+
(node) => !node.parentElement?.closest("[data-element-id]")
|
|
734
|
+
);
|
|
242
735
|
const sorted = nodes
|
|
243
736
|
.map((node) => {
|
|
244
737
|
const zIndex = Number.parseInt(window.getComputedStyle(node).zIndex || "1", 10);
|
|
@@ -254,12 +747,19 @@ export async function capturePreviewSceneElement(
|
|
|
254
747
|
const transform = node.style.transform || "";
|
|
255
748
|
const rotateMatch = /rotate\(([-\d.]+)deg\)/.exec(transform);
|
|
256
749
|
const rotateDeg = rotateMatch ? Number.parseFloat(rotateMatch[1]!) : 0;
|
|
257
|
-
|
|
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
|
+
}
|
|
258
755
|
}
|
|
259
756
|
|
|
260
757
|
const mimeType = options?.mimeType ?? "image/png";
|
|
261
758
|
const quality = options?.quality ?? 0.92;
|
|
262
|
-
let dataUrl = canvas
|
|
759
|
+
let dataUrl = canvasToDataUrl(canvas, mimeType, quality);
|
|
760
|
+
if (!dataUrl) {
|
|
761
|
+
throw new Error("preview-snapshot-tainted");
|
|
762
|
+
}
|
|
263
763
|
|
|
264
764
|
const maxWidth = options?.maxWidth;
|
|
265
765
|
const maxHeight = options?.maxHeight;
|
|
@@ -296,5 +796,5 @@ export async function scaleDataUrl(
|
|
|
296
796
|
const ctx = canvas.getContext("2d");
|
|
297
797
|
if (!ctx) return dataUrl;
|
|
298
798
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
299
|
-
return canvas
|
|
799
|
+
return canvasToDataUrl(canvas, mimeType, quality) ?? dataUrl;
|
|
300
800
|
}
|