@arronqzy/react-view 0.0.1

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.
Files changed (112) hide show
  1. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/aix-ppc64.json +1 -0
  2. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm.json +1 -0
  3. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm64.json +1 -0
  4. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-x64.json +1 -0
  5. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-arm64.json +1 -0
  6. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-x64.json +1 -0
  7. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-arm64.json +1 -0
  8. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-x64.json +1 -0
  9. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm.json +1 -0
  10. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm64.json +1 -0
  11. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ia32.json +1 -0
  12. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-loong64.json +1 -0
  13. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-mips64el.json +1 -0
  14. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ppc64.json +1 -0
  15. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-riscv64.json +1 -0
  16. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-s390x.json +1 -0
  17. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-x64.json +1 -0
  18. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/netbsd-x64.json +1 -0
  19. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/openbsd-x64.json +1 -0
  20. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/sunos-x64.json +1 -0
  21. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-arm64.json +1 -0
  22. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-ia32.json +1 -0
  23. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-x64.json +1 -0
  24. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm-eabi.json +1 -0
  25. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm64.json +1 -0
  26. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-arm64.json +1 -0
  27. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-x64.json +1 -0
  28. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-arm64.json +1 -0
  29. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-x64.json +1 -0
  30. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf.json +1 -0
  31. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf.json +1 -0
  32. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-gnu.json +1 -0
  33. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-musl.json +1 -0
  34. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu.json +1 -0
  35. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu.json +1 -0
  36. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu.json +1 -0
  37. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-musl.json +1 -0
  38. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-s390x-gnu.json +1 -0
  39. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-gnu.json +1 -0
  40. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-musl.json +1 -0
  41. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-arm64-msvc.json +1 -0
  42. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-ia32-msvc.json +1 -0
  43. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-x64-msvc.json +1 -0
  44. package/.pnpm-cache/metadata/registry.npmjs.org/eslint.json +1 -0
  45. package/.pnpm-cache/metadata/registry.npmjs.org/fsevents.json +1 -0
  46. package/.pnpm-cache/metadata/registry.npmjs.org/pnpm.json +1 -0
  47. package/.pnpm-cache/metadata/registry.npmjs.org/react-dom.json +1 -0
  48. package/.pnpm-cache/metadata/registry.npmjs.org/react-is.json +1 -0
  49. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-64.json +1 -0
  50. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-arm64.json +1 -0
  51. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-64.json +1 -0
  52. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-arm64.json +1 -0
  53. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-64.json +1 -0
  54. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-arm64.json +1 -0
  55. package/.pnpm-cache/metadata/registry.npmjs.org/typescript.json +1 -0
  56. package/.turbo/turbo-lint.log +4 -0
  57. package/dist/styles.css +2624 -0
  58. package/env.d.ts +11 -0
  59. package/operation-guide.md +275 -0
  60. package/package.json +43 -0
  61. package/postcss.config.cjs +7 -0
  62. package/readme.md +21 -0
  63. package/src/core/createAction.ts +5 -0
  64. package/src/core/createView.ts +28 -0
  65. package/src/core/instructions.tsx +57 -0
  66. package/src/core/nodePool.ts +127 -0
  67. package/src/core/signal.ts +130 -0
  68. package/src/core/template.tsx +25 -0
  69. package/src/index.ts +7 -0
  70. package/src/panel/ReactViewOnlinePreview.tsx +331 -0
  71. package/src/panel/ReactViewPanel.tsx +3221 -0
  72. package/src/panel/components/ElementsLayer.tsx +891 -0
  73. package/src/panel/components/MaterialSidebar.tsx +1142 -0
  74. package/src/panel/components/MoveableLayer.tsx +803 -0
  75. package/src/panel/components/PanelCanvas.tsx +660 -0
  76. package/src/panel/components/PanelConfigSidebar.tsx +3703 -0
  77. package/src/panel/components/PanelRulers.tsx +89 -0
  78. package/src/panel/components/SelectLayer.tsx +106 -0
  79. package/src/panel/components/ViewElementScopePanel.tsx +79 -0
  80. package/src/panel/components/WorkspaceConfigSidebar.tsx +158 -0
  81. package/src/panel/components/WorkspaceProjectNav.tsx +220 -0
  82. package/src/panel/components/WorkspaceStageSplit.tsx +179 -0
  83. package/src/panel/components/scope-config/ScopeConfigContext.tsx +170 -0
  84. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +250 -0
  85. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +59 -0
  86. package/src/panel/constants/messages.ts +34 -0
  87. package/src/panel/constants/zIndex.ts +6 -0
  88. package/src/panel/hooks/usePanelElements.ts +1108 -0
  89. package/src/panel/hooks/useWorkspaceProjects.ts +278 -0
  90. package/src/panel/index.tsx +3 -0
  91. package/src/panel/library/workspace-project-cache.ts +23 -0
  92. package/src/panel/library/workspace-project-db.ts +112 -0
  93. package/src/panel/library/workspace-project-sync.ts +41 -0
  94. package/src/panel/library/workspace-snapshot.ts +27 -0
  95. package/src/panel/scope/view-scope-store.ts +55 -0
  96. package/src/panel/types.ts +125 -0
  97. package/src/panel/utils/chartOptionBuilder.ts +316 -0
  98. package/src/panel/utils/gridPlacement.ts +189 -0
  99. package/src/panel/utils/mappingLayerOps.ts +142 -0
  100. package/src/panel/utils/panelElementDefaults.ts +161 -0
  101. package/src/panel/utils/panelElementNodes.ts +35 -0
  102. package/src/panel/utils/panelStateIO.ts +124 -0
  103. package/src/panel/utils/scope-autocomplete.ts +114 -0
  104. package/src/panel/utils/scope-field-labels.ts +45 -0
  105. package/src/panel/utils/scope-template-warnings.ts +135 -0
  106. package/src/panel/utils/scope-template.ts +75 -0
  107. package/src/panel/utils/updateElementDraft.ts +221 -0
  108. package/src/panel/viewportZoom.ts +26 -0
  109. package/src/tailwind.css +58 -0
  110. package/src/types/react-infinite-viewer.d.ts +39 -0
  111. package/tailwind.config.ts +7 -0
  112. package/tsconfig.json +14 -0
@@ -0,0 +1,891 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import * as echarts from "echarts";
3
+ import type { PanelElement } from "../types";
4
+ import { buildChartOption, CHART_TYPES } from "../utils/chartOptionBuilder";
5
+ import { PREVIEW_LAYOUT_EVENT } from "../utils/panelStateIO";
6
+
7
+ export type ElementsLayerProps = {
8
+ elements: PanelElement[];
9
+ allElements: PanelElement[];
10
+ selectedIds: string[];
11
+ onSelectIds: (ids: string[]) => void;
12
+ updateElement: (
13
+ id: string,
14
+ patch: Partial<PanelElement>,
15
+ options?: { batchId?: string; meta?: Record<string, unknown> }
16
+ ) => void;
17
+ layerLocked?: boolean;
18
+ /** 在线预览模式:隐藏网格编辑占位等辅助 UI */
19
+ previewMode?: boolean;
20
+ /** 预览布局变更时递增,用于触发图表/画布重绘 */
21
+ previewLayoutKey?: number;
22
+ };
23
+
24
+ function TextNodeContent({
25
+ element,
26
+ editable,
27
+ onChange,
28
+ }: {
29
+ element: PanelElement;
30
+ editable: boolean;
31
+ onChange: (nextHtml: string) => void;
32
+ }) {
33
+ const ref = useRef<HTMLDivElement | null>(null);
34
+ const html = element.textHtml ?? "<p>双击输入文本</p>";
35
+ const textStyle: React.CSSProperties = {
36
+ fontFamily: element.textFontFamily || undefined,
37
+ fontSize: element.textFontSize ? `${element.textFontSize}px` : undefined,
38
+ fontWeight: element.textFontWeight || undefined,
39
+ color: element.textColor || undefined,
40
+ lineHeight: element.textLineHeight ? String(element.textLineHeight) : undefined,
41
+ textAlign: element.textAlign ?? "left",
42
+ };
43
+
44
+ useEffect(() => {
45
+ const el = ref.current;
46
+ if (!el) return;
47
+ if (el.innerHTML !== html) {
48
+ el.innerHTML = html;
49
+ }
50
+ }, [html]);
51
+
52
+ return (
53
+ <div
54
+ ref={ref}
55
+ className="h-full w-full overflow-auto break-words p-2 text-sm leading-relaxed outline-none"
56
+ style={textStyle}
57
+ contentEditable={editable}
58
+ suppressContentEditableWarning
59
+ onInput={(e) => onChange((e.currentTarget as HTMLDivElement).innerHTML)}
60
+ onBlur={(e) => onChange((e.currentTarget as HTMLDivElement).innerHTML)}
61
+ />
62
+ );
63
+ }
64
+
65
+ function GridNodeContent({
66
+ element,
67
+ allElements,
68
+ previewMode = false,
69
+ }: {
70
+ element: PanelElement;
71
+ allElements: PanelElement[];
72
+ previewMode?: boolean;
73
+ }) {
74
+ const rows = Math.max(1, Math.floor(element.gridRows ?? 2));
75
+ const cols = Math.max(1, Math.floor(element.gridCols ?? 3));
76
+ const gap = Math.max(0, element.gridGap ?? 8);
77
+ const padding = Math.max(0, element.gridPadding ?? 10);
78
+ const occupied = useMemo(() => {
79
+ const set = new Set<number>();
80
+ allElements.forEach((el) => {
81
+ if (el.parentGridId !== element.id) return;
82
+ if (el.layerId !== element.layerId) return;
83
+ if (el.gridSlotIndex === undefined) return;
84
+ const start = Math.max(0, Math.floor(el.gridSlotIndex));
85
+ const baseRow = Math.floor(start / cols);
86
+ const baseCol = start % cols;
87
+ const rowSpan = Math.max(1, Math.min(rows - baseRow, Math.floor(el.gridRowSpan ?? 1)));
88
+ const colSpan = Math.max(1, Math.min(cols - baseCol, Math.floor(el.gridColSpan ?? 1)));
89
+ for (let r = baseRow; r < baseRow + rowSpan; r++) {
90
+ for (let c = baseCol; c < baseCol + colSpan; c++) {
91
+ set.add(r * cols + c);
92
+ }
93
+ }
94
+ });
95
+ return set;
96
+ }, [allElements, cols, element.id, element.layerId, rows]);
97
+ const occupiedBlocks = useMemo(() => {
98
+ return allElements
99
+ .filter(
100
+ (el) =>
101
+ el.parentGridId === element.id &&
102
+ el.layerId === element.layerId &&
103
+ el.gridSlotIndex !== undefined
104
+ )
105
+ .map((el) => {
106
+ const start = Math.max(0, Math.floor(el.gridSlotIndex ?? 0));
107
+ const baseRow = Math.floor(start / cols);
108
+ const baseCol = start % cols;
109
+ const rowSpan = Math.max(1, Math.min(rows - baseRow, Math.floor(el.gridRowSpan ?? 1)));
110
+ const colSpan = Math.max(1, Math.min(cols - baseCol, Math.floor(el.gridColSpan ?? 1)));
111
+ return {
112
+ id: el.id,
113
+ rowStart: baseRow + 1,
114
+ rowEnd: baseRow + rowSpan + 1,
115
+ colStart: baseCol + 1,
116
+ colEnd: baseCol + colSpan + 1,
117
+ };
118
+ });
119
+ }, [allElements, cols, element.id, element.layerId, rows]);
120
+ if (previewMode) {
121
+ return <div className="relative h-full w-full" />;
122
+ }
123
+ return (
124
+ <div className="relative h-full w-full">
125
+ <div
126
+ className="h-full w-full"
127
+ style={{
128
+ display: "grid",
129
+ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
130
+ gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
131
+ gap: `${gap}px`,
132
+ padding: `${padding}px`,
133
+ boxSizing: "border-box",
134
+ }}
135
+ >
136
+ {Array.from({ length: rows * cols }).map((_, idx) => (
137
+ <div
138
+ key={idx}
139
+ className={[
140
+ "rounded border border-dashed",
141
+ occupied.has(idx)
142
+ ? "border-primary/70 bg-primary/10"
143
+ : "border-border/60 bg-muted/20",
144
+ ].join(" ")}
145
+ title={`槽位 ${idx + 1}${occupied.has(idx) ? "(已占用)" : "(空)"}`}
146
+ />
147
+ ))}
148
+ </div>
149
+ <div
150
+ className="pointer-events-none absolute inset-0"
151
+ style={{
152
+ display: "grid",
153
+ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
154
+ gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
155
+ gap: `${gap}px`,
156
+ padding: `${padding}px`,
157
+ boxSizing: "border-box",
158
+ }}
159
+ >
160
+ {occupiedBlocks.map((block) => (
161
+ <div
162
+ key={block.id}
163
+ className="rounded border border-primary/70 bg-primary/20"
164
+ style={{
165
+ gridColumn: `${block.colStart} / ${block.colEnd}`,
166
+ gridRow: `${block.rowStart} / ${block.rowEnd}`,
167
+ }}
168
+ />
169
+ ))}
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ function AudioNodeContent({
176
+ element,
177
+ selected = false,
178
+ }: {
179
+ element: PanelElement;
180
+ selected?: boolean;
181
+ }) {
182
+ const src = element.audioSrc || element.audioRemoteUrl || "";
183
+ const audioRef = useRef<HTMLAudioElement | null>(null);
184
+ const [playing, setPlaying] = useState(false);
185
+ const poster = element.audioPosterImage;
186
+ const iconPreset = element.audioIconPreset;
187
+ const iconMode = Boolean(poster || iconPreset);
188
+ const visualEffect = element.audioVisualEffect ?? "pulse";
189
+ const visualSpeed = element.audioVisualSpeed ?? "normal";
190
+ const speedMs = visualSpeed === "fast" ? 900 : visualSpeed === "slow" ? 1800 : 1300;
191
+ const shouldAnimate = Boolean(src) && playing && visualEffect !== "none";
192
+ useEffect(() => {
193
+ const el = audioRef.current;
194
+ if (!el) return;
195
+ const onPlay = () => setPlaying(true);
196
+ const onPause = () => setPlaying(false);
197
+ const onEnded = () => setPlaying(false);
198
+ el.addEventListener("play", onPlay);
199
+ el.addEventListener("pause", onPause);
200
+ el.addEventListener("ended", onEnded);
201
+ return () => {
202
+ el.removeEventListener("play", onPlay);
203
+ el.removeEventListener("pause", onPause);
204
+ el.removeEventListener("ended", onEnded);
205
+ };
206
+ }, []);
207
+ useEffect(() => {
208
+ if (!selected || element.mediaAutoPauseOnEdit === false) return;
209
+ const el = audioRef.current;
210
+ if (!el) return;
211
+ if (!el.paused) el.pause();
212
+ }, [selected, element.mediaAutoPauseOnEdit]);
213
+ const renderIcon = () => {
214
+ const common = "h-8 w-8 text-foreground/90";
215
+ switch (iconPreset) {
216
+ case "music":
217
+ return (
218
+ <svg viewBox="0 0 24 24" className={common} fill="none" stroke="currentColor" strokeWidth="1.8">
219
+ <path d="M9 18a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm11-2a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
220
+ <path d="M9 18V7l11-2v11" />
221
+ </svg>
222
+ );
223
+ case "headphone":
224
+ return (
225
+ <svg viewBox="0 0 24 24" className={common} fill="none" stroke="currentColor" strokeWidth="1.8">
226
+ <path d="M4 12a8 8 0 1 1 16 0" />
227
+ <rect x="3" y="12" width="4" height="7" rx="2" />
228
+ <rect x="17" y="12" width="4" height="7" rx="2" />
229
+ </svg>
230
+ );
231
+ case "wave":
232
+ return (
233
+ <svg viewBox="0 0 24 24" className={common} fill="none" stroke="currentColor" strokeWidth="1.8">
234
+ <path d="M4 14v-4M8 17V7M12 20V4M16 17V7M20 14v-4" />
235
+ </svg>
236
+ );
237
+ default:
238
+ return (
239
+ <svg viewBox="0 0 24 24" className={common} fill="none" stroke="currentColor" strokeWidth="1.8">
240
+ <path d="M11 5 6 9H3v6h3l5 4V5Z" />
241
+ <path d="M15 9a4 4 0 0 1 0 6" />
242
+ <path d="M17.5 6.5a7 7 0 0 1 0 11" />
243
+ </svg>
244
+ );
245
+ }
246
+ };
247
+ if (iconMode) {
248
+ return (
249
+ <div
250
+ className={[
251
+ "relative flex h-full w-full items-center justify-center overflow-hidden rounded border border-border/60 bg-muted/10",
252
+ shouldAnimate && visualEffect === "pulse" ? "animate-pulse ring-2 ring-primary/50" : "",
253
+ ].join(" ")}
254
+ style={shouldAnimate && visualEffect === "pulse" ? { animationDuration: `${speedMs}ms` } : undefined}
255
+ >
256
+ {poster ? (
257
+ <button
258
+ type="button"
259
+ className="h-full w-full"
260
+ onMouseDown={(e) => e.stopPropagation()}
261
+ onPointerDown={(e) => e.stopPropagation()}
262
+ onClick={() => {
263
+ if (!src) return;
264
+ const el = audioRef.current;
265
+ if (!el) return;
266
+ if (el.paused) {
267
+ void el.play();
268
+ } else {
269
+ el.pause();
270
+ }
271
+ }}
272
+ title={!src ? "请先配置音频源" : playing ? "点击暂停" : "点击播放"}
273
+ >
274
+ <img
275
+ src={poster}
276
+ alt="音频占位图"
277
+ className="h-full w-full object-cover"
278
+ draggable={false}
279
+ onDragStart={(e) => e.preventDefault()}
280
+ />
281
+ </button>
282
+ ) : (
283
+ <button
284
+ type="button"
285
+ className="flex h-full w-full items-center justify-center"
286
+ onMouseDown={(e) => e.stopPropagation()}
287
+ onPointerDown={(e) => e.stopPropagation()}
288
+ onClick={() => {
289
+ if (!src) return;
290
+ const el = audioRef.current;
291
+ if (!el) return;
292
+ if (el.paused) {
293
+ void el.play();
294
+ } else {
295
+ el.pause();
296
+ }
297
+ }}
298
+ title={!src ? "请先配置音频源" : playing ? "点击暂停" : "点击播放"}
299
+ >
300
+ {renderIcon()}
301
+ </button>
302
+ )}
303
+ {shouldAnimate && visualEffect === "ripple" ? (
304
+ <>
305
+ <span
306
+ className="pointer-events-none absolute h-16 w-16 rounded-full border border-primary/70 animate-ping"
307
+ style={{ animationDuration: `${speedMs}ms` }}
308
+ />
309
+ <span
310
+ className="pointer-events-none absolute h-24 w-24 rounded-full border border-primary/40 animate-ping"
311
+ style={{ animationDuration: `${speedMs}ms`, animationDelay: `${Math.round(speedMs / 3)}ms` }}
312
+ />
313
+ </>
314
+ ) : null}
315
+ <span className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
316
+ {!src ? "未配置音频" : playing ? "暂停" : "播放"}
317
+ </span>
318
+ <audio ref={audioRef} src={src} preload="metadata" className="hidden" />
319
+ </div>
320
+ );
321
+ }
322
+ if (!src) {
323
+ return (
324
+ <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
325
+ 音频占位
326
+ </div>
327
+ );
328
+ }
329
+ return (
330
+ <div className="relative flex h-full w-full items-center justify-center rounded border border-border/60 bg-muted/10 px-2">
331
+ <div className="pointer-events-none text-[11px] text-muted-foreground">
332
+ {playing ? "音频播放中" : "音频已就绪"}
333
+ </div>
334
+ <button
335
+ type="button"
336
+ className="absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white"
337
+ onMouseDown={(e) => e.stopPropagation()}
338
+ onPointerDown={(e) => e.stopPropagation()}
339
+ onClick={() => {
340
+ const el = audioRef.current;
341
+ if (!el) return;
342
+ if (el.paused) {
343
+ void el.play();
344
+ } else {
345
+ el.pause();
346
+ }
347
+ }}
348
+ title={playing ? "点击暂停" : "点击播放"}
349
+ >
350
+ {playing ? "暂停" : "播放"}
351
+ </button>
352
+ <audio ref={audioRef} src={src} preload="metadata" className="hidden" />
353
+ </div>
354
+ );
355
+ }
356
+
357
+ function VideoNodeContent({
358
+ element,
359
+ selected = false,
360
+ }: {
361
+ element: PanelElement;
362
+ selected?: boolean;
363
+ }) {
364
+ const src = element.videoSrc || element.videoRemoteUrl || "";
365
+ const videoRef = useRef<HTMLVideoElement | null>(null);
366
+ const [playing, setPlaying] = useState(false);
367
+ useEffect(() => {
368
+ const el = videoRef.current;
369
+ if (!el) return;
370
+ const onPlay = () => setPlaying(true);
371
+ const onPause = () => setPlaying(false);
372
+ const onEnded = () => setPlaying(false);
373
+ el.addEventListener("play", onPlay);
374
+ el.addEventListener("pause", onPause);
375
+ el.addEventListener("ended", onEnded);
376
+ return () => {
377
+ el.removeEventListener("play", onPlay);
378
+ el.removeEventListener("pause", onPause);
379
+ el.removeEventListener("ended", onEnded);
380
+ };
381
+ }, []);
382
+ useEffect(() => {
383
+ if (!selected || element.mediaAutoPauseOnEdit === false) return;
384
+ const el = videoRef.current;
385
+ if (!el) return;
386
+ if (!el.paused) el.pause();
387
+ }, [selected, element.mediaAutoPauseOnEdit]);
388
+ if (!src) {
389
+ return (
390
+ <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
391
+ 视频占位
392
+ </div>
393
+ );
394
+ }
395
+ return (
396
+ <div className="relative h-full w-full p-1">
397
+ <video ref={videoRef} src={src} className="h-full w-full rounded object-contain pointer-events-none" />
398
+ <button
399
+ type="button"
400
+ className="absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white"
401
+ onMouseDown={(e) => e.stopPropagation()}
402
+ onPointerDown={(e) => e.stopPropagation()}
403
+ onClick={() => {
404
+ const el = videoRef.current;
405
+ if (!el) return;
406
+ if (el.paused) {
407
+ void el.play();
408
+ } else {
409
+ el.pause();
410
+ }
411
+ }}
412
+ title={playing ? "点击暂停" : "点击播放"}
413
+ >
414
+ {playing ? "暂停" : "播放"}
415
+ </button>
416
+ </div>
417
+ );
418
+ }
419
+
420
+ function hasBackgroundImage(element: PanelElement) {
421
+ const style = element.style ?? {};
422
+ return Boolean(style.backgroundImage || style.backgroundImageRemoteUrl);
423
+ }
424
+
425
+ function ImageNodeContent({ element }: { element: PanelElement }) {
426
+ if (hasBackgroundImage(element)) return null;
427
+ return (
428
+ <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
429
+ 图片占位
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function EmptyNodePlaceholder({ element }: { element: PanelElement }) {
435
+ const labelMap: Record<string, string> = {
436
+ video: "视频占位",
437
+ audio: "音频占位",
438
+ };
439
+ const label = labelMap[element.materialType ?? ""] ?? `${element.materialType ?? "节点"} 占位`;
440
+ return (
441
+ <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
442
+ {label}
443
+ </div>
444
+ );
445
+ }
446
+
447
+ function GeometryNodeContent({ element }: { element: PanelElement }) {
448
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
449
+ const elementRef = useRef(element);
450
+ elementRef.current = element;
451
+ const shape = element.geometryShape ?? "rect";
452
+ const color = element.geometryColor ?? "#3b82f6";
453
+ const script = element.geometryScript ?? "";
454
+ const sketch = element.geometrySketchDataUrl ?? "";
455
+
456
+ useEffect(() => {
457
+ const canvas = canvasRef.current;
458
+ if (!canvas) return;
459
+ const ctx = canvas.getContext("2d");
460
+ if (!ctx) return;
461
+
462
+ const draw = () => {
463
+ const dpr = window.devicePixelRatio || 1;
464
+ const width = Math.max(1, canvas.clientWidth);
465
+ const height = Math.max(1, canvas.clientHeight);
466
+ canvas.width = Math.round(width * dpr);
467
+ canvas.height = Math.round(height * dpr);
468
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
469
+ ctx.clearRect(0, 0, width, height);
470
+
471
+ const drawShapePath = () => {
472
+ const cx = width / 2;
473
+ const cy = height / 2;
474
+ const r = Math.max(10, Math.min(width, height) * 0.42);
475
+ ctx.beginPath();
476
+ if (shape === "circle") {
477
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
478
+ } else if (shape === "triangle") {
479
+ ctx.moveTo(cx, cy - r);
480
+ ctx.lineTo(cx + r * 0.9, cy + r * 0.8);
481
+ ctx.lineTo(cx - r * 0.9, cy + r * 0.8);
482
+ ctx.closePath();
483
+ } else if (shape === "diamond") {
484
+ ctx.moveTo(cx, cy - r);
485
+ ctx.lineTo(cx + r, cy);
486
+ ctx.lineTo(cx, cy + r);
487
+ ctx.lineTo(cx - r, cy);
488
+ ctx.closePath();
489
+ } else if (shape === "hexagon") {
490
+ for (let i = 0; i < 6; i += 1) {
491
+ const a = (Math.PI / 3) * i - Math.PI / 6;
492
+ const x = cx + r * Math.cos(a);
493
+ const y = cy + r * Math.sin(a);
494
+ if (i === 0) ctx.moveTo(x, y);
495
+ else ctx.lineTo(x, y);
496
+ }
497
+ ctx.closePath();
498
+ } else if (shape === "star") {
499
+ const inner = r * 0.45;
500
+ for (let i = 0; i < 10; i += 1) {
501
+ const rr = i % 2 === 0 ? r : inner;
502
+ const a = (Math.PI / 5) * i - Math.PI / 2;
503
+ const x = cx + rr * Math.cos(a);
504
+ const y = cy + rr * Math.sin(a);
505
+ if (i === 0) ctx.moveTo(x, y);
506
+ else ctx.lineTo(x, y);
507
+ }
508
+ ctx.closePath();
509
+ } else if (shape === "heart") {
510
+ const top = cy - r * 0.2;
511
+ ctx.moveTo(cx, cy + r * 0.9);
512
+ ctx.bezierCurveTo(cx - r * 1.2, cy + r * 0.25, cx - r * 0.9, top - r * 0.8, cx, top);
513
+ ctx.bezierCurveTo(cx + r * 0.9, top - r * 0.8, cx + r * 1.2, cy + r * 0.25, cx, cy + r * 0.9);
514
+ ctx.closePath();
515
+ } else {
516
+ const rr = Math.max(4, Math.min(width, height) * 0.08);
517
+ const x = width * 0.08;
518
+ const y = height * 0.08;
519
+ const w = width * 0.84;
520
+ const h = height * 0.84;
521
+ ctx.moveTo(x + rr, y);
522
+ ctx.lineTo(x + w - rr, y);
523
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
524
+ ctx.lineTo(x + w, y + h - rr);
525
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
526
+ ctx.lineTo(x + rr, y + h);
527
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
528
+ ctx.lineTo(x, y + rr);
529
+ ctx.quadraticCurveTo(x, y, x + rr, y);
530
+ ctx.closePath();
531
+ }
532
+ };
533
+
534
+ drawShapePath();
535
+ ctx.fillStyle = color;
536
+ ctx.fill();
537
+
538
+ if (sketch) {
539
+ const img = new Image();
540
+ img.onload = () => {
541
+ ctx.save();
542
+ drawShapePath();
543
+ ctx.clip();
544
+ ctx.drawImage(img, 0, 0, width, height);
545
+ ctx.restore();
546
+ };
547
+ img.src = sketch;
548
+ }
549
+
550
+ if (script.trim()) {
551
+ try {
552
+ const fn = new Function("ctx", "width", "height", "element", script);
553
+ fn(ctx, width, height, elementRef.current);
554
+ } catch {
555
+ // ignore invalid script to avoid breaking render
556
+ }
557
+ }
558
+ };
559
+
560
+ draw();
561
+ const obs = new ResizeObserver(draw);
562
+ obs.observe(canvas);
563
+ return () => obs.disconnect();
564
+ // 不要依赖整个 element:移动 x/y 等会换新引用,触发 draw 后 clearRect
565
+ // 与手绘层 img.onload 异步绘制叠在一起会产生「移动结束闪一下」。
566
+ // 脚本里需要最新节点数据时读 elementRef.current。
567
+ }, [color, script, shape, sketch]);
568
+
569
+ return <canvas ref={canvasRef} className="h-full w-full" />;
570
+ }
571
+
572
+ function ChartNodeContent({
573
+ element,
574
+ previewLayoutKey,
575
+ previewMode = false,
576
+ }: {
577
+ element: PanelElement;
578
+ previewLayoutKey?: number;
579
+ previewMode?: boolean;
580
+ }) {
581
+ const hostRef = useRef<HTMLDivElement | null>(null);
582
+ const chartRef = useRef<echarts.ECharts | null>(null);
583
+ const rendererRef = useRef<"canvas" | "svg">("canvas");
584
+ const option = useMemo(() => buildChartOption(element), [element]);
585
+ const renderer = (element.chart?.renderMode ?? "canvas") as "canvas" | "svg";
586
+
587
+ const resizeChart = useCallback(() => {
588
+ chartRef.current?.resize();
589
+ }, []);
590
+
591
+ useLayoutEffect(() => {
592
+ const host = hostRef.current;
593
+ if (!host) return;
594
+ if (!chartRef.current || rendererRef.current !== renderer) {
595
+ chartRef.current?.dispose();
596
+ chartRef.current = echarts.init(host, undefined, { renderer });
597
+ rendererRef.current = renderer;
598
+ }
599
+ chartRef.current.setOption(option as echarts.EChartsOption, true);
600
+ resizeChart();
601
+ }, [element.height, element.width, option, previewLayoutKey, renderer, resizeChart]);
602
+
603
+ useEffect(() => {
604
+ const host = hostRef.current;
605
+ if (!host) return;
606
+ const onLayout = () => resizeChart();
607
+ window.addEventListener(PREVIEW_LAYOUT_EVENT, onLayout);
608
+ const obs = new ResizeObserver(onLayout);
609
+ obs.observe(host);
610
+ const parent = host.parentElement;
611
+ if (parent) obs.observe(parent);
612
+ return () => {
613
+ window.removeEventListener(PREVIEW_LAYOUT_EVENT, onLayout);
614
+ obs.disconnect();
615
+ };
616
+ }, [resizeChart]);
617
+
618
+ useEffect(() => {
619
+ return () => {
620
+ chartRef.current?.dispose();
621
+ chartRef.current = null;
622
+ };
623
+ }, []);
624
+
625
+ return (
626
+ <div
627
+ ref={hostRef}
628
+ className="h-full w-full"
629
+ style={
630
+ previewMode
631
+ ? {
632
+ width: Math.max(1, element.width),
633
+ height: Math.max(1, element.height),
634
+ minWidth: 1,
635
+ minHeight: 1,
636
+ }
637
+ : undefined
638
+ }
639
+ />
640
+ );
641
+ }
642
+
643
+ function getNodeVisualStyle(element: PanelElement): React.CSSProperties {
644
+ const style = element.style ?? {};
645
+ return {
646
+ backgroundColor: style.backgroundColor,
647
+ backgroundImage: style.backgroundImage,
648
+ backgroundSize: style.backgroundSize,
649
+ backgroundPosition: style.backgroundPosition,
650
+ borderWidth: style.borderWidth,
651
+ borderStyle: style.borderStyle,
652
+ borderColor: style.borderColor,
653
+ borderRadius: style.borderRadius,
654
+ };
655
+ }
656
+
657
+ function getNodeAABB(element: PanelElement) {
658
+ const rotate = ((element.rotate ?? 0) * Math.PI) / 180;
659
+ const cos = Math.cos(rotate);
660
+ const sin = Math.sin(rotate);
661
+ const absCos = Math.abs(cos);
662
+ const absSin = Math.abs(sin);
663
+ const bboxWidth = element.width * absCos + element.height * absSin;
664
+ const bboxHeight = element.width * absSin + element.height * absCos;
665
+ const cx = element.x + element.width / 2;
666
+ const cy = element.y + element.height / 2;
667
+ return {
668
+ left: cx - bboxWidth / 2,
669
+ top: cy - bboxHeight / 2,
670
+ right: cx + bboxWidth / 2,
671
+ bottom: cy + bboxHeight / 2,
672
+ };
673
+ }
674
+
675
+ function ReferenceNodeContent({
676
+ element,
677
+ allElements,
678
+ snapshotSource,
679
+ visitedIds = [],
680
+ }: {
681
+ element: PanelElement;
682
+ allElements: PanelElement[];
683
+ snapshotSource?: PanelElement[];
684
+ visitedIds?: string[];
685
+ }) {
686
+ const sourceNodes = useMemo(
687
+ () => {
688
+ const fromDeep =
689
+ element.refCopyMode === "deep" ? element.refSnapshot ?? snapshotSource ?? [] : null;
690
+ const base = fromDeep ?? allElements.filter((n) => n.layerId === element.refLayerId);
691
+ return base.filter((n) => n.id !== element.id && !visitedIds.includes(n.id));
692
+ },
693
+ [
694
+ allElements,
695
+ element.id,
696
+ element.refCopyMode,
697
+ element.refLayerId,
698
+ element.refSnapshot,
699
+ snapshotSource,
700
+ visitedIds,
701
+ ]
702
+ );
703
+
704
+ const layout = useMemo(() => {
705
+ if (sourceNodes.length === 0) return null;
706
+ const boxes = sourceNodes.map(getNodeAABB);
707
+ const minX = Math.min(...boxes.map((b) => b.left));
708
+ const minY = Math.min(...boxes.map((b) => b.top));
709
+ const maxX = Math.max(...boxes.map((b) => b.right));
710
+ const maxY = Math.max(...boxes.map((b) => b.bottom));
711
+ const sourceWidth = Math.max(1, maxX - minX);
712
+ const sourceHeight = Math.max(1, maxY - minY);
713
+
714
+ const innerW = Math.max(1, element.width);
715
+ const innerH = Math.max(1, element.height);
716
+ const scale = Math.max(0.05, Math.min(innerW / sourceWidth, innerH / sourceHeight));
717
+ const mappedW = sourceWidth * scale;
718
+ const mappedH = sourceHeight * scale;
719
+
720
+ return {
721
+ minX,
722
+ minY,
723
+ scale,
724
+ offsetX: (innerW - mappedW) / 2,
725
+ offsetY: (innerH - mappedH) / 2,
726
+ };
727
+ }, [element.height, element.width, sourceNodes]);
728
+
729
+ if (!layout || sourceNodes.length === 0) {
730
+ const hintText = element.refLayerId ? "引用图层暂无节点" : "请在右侧选择引用图层";
731
+ return (
732
+ <div className="flex h-full w-full items-center justify-center">
733
+ <div className="rounded border border-dashed border-border/70 px-2 py-1 text-[10px] text-muted-foreground">
734
+ {hintText}
735
+ </div>
736
+ </div>
737
+ );
738
+ }
739
+
740
+ return (
741
+ <div className="pointer-events-none relative h-full w-full overflow-hidden">
742
+ {sourceNodes.map((node) => {
743
+ const box = getNodeAABB(node);
744
+ const left = layout.offsetX + (box.left - layout.minX) * layout.scale;
745
+ const top = layout.offsetY + (box.top - layout.minY) * layout.scale;
746
+ const width = Math.max(12, node.width * layout.scale);
747
+ const height = Math.max(10, node.height * layout.scale);
748
+ const boxWidth = Math.max(12, (box.right - box.left) * layout.scale);
749
+ const boxHeight = Math.max(10, (box.bottom - box.top) * layout.scale);
750
+ return (
751
+ <div
752
+ key={node.id}
753
+ className="absolute overflow-visible"
754
+ style={{
755
+ left,
756
+ top,
757
+ width: boxWidth,
758
+ height: boxHeight,
759
+ }}
760
+ >
761
+ <div
762
+ className="absolute"
763
+ style={{
764
+ left: (boxWidth - width) / 2,
765
+ top: (boxHeight - height) / 2,
766
+ width,
767
+ height,
768
+ transform: `rotate(${node.rotate ?? 0}deg)`,
769
+ transformOrigin: "center center",
770
+ ...getNodeVisualStyle(node),
771
+ }}
772
+ >
773
+ {CHART_TYPES.has(node.materialType ?? "") ? (
774
+ <ChartNodeContent element={node} />
775
+ ) : node.materialType === "reference" ? (
776
+ <ReferenceNodeContent
777
+ element={node}
778
+ allElements={allElements}
779
+ snapshotSource={node.refSnapshot}
780
+ visitedIds={[...visitedIds, element.id]}
781
+ />
782
+ ) : node.materialType === "geometry" ? (
783
+ <GeometryNodeContent element={node} />
784
+ ) : (
785
+ <div className="h-full w-full" />
786
+ )}
787
+ </div>
788
+ </div>
789
+ );
790
+ })}
791
+ </div>
792
+ );
793
+ }
794
+
795
+ export function ElementsLayer({
796
+ elements,
797
+ allElements,
798
+ selectedIds,
799
+ onSelectIds,
800
+ updateElement,
801
+ layerLocked = false,
802
+ previewMode = false,
803
+ previewLayoutKey,
804
+ }: ElementsLayerProps) {
805
+ const sortedElements = useMemo(() => {
806
+ return [...elements].sort((a, b) => {
807
+ const za = a.zIndex ?? 1;
808
+ const zb = b.zIndex ?? 1;
809
+ if (za !== zb) return za - zb;
810
+ return a.id.localeCompare(b.id);
811
+ });
812
+ }, [elements]);
813
+ return (
814
+ <>
815
+ {sortedElements.map((el) => {
816
+ const isSelected = selectedIds.includes(el.id);
817
+ return (
818
+ <div
819
+ key={el.id}
820
+ className={[
821
+ "absolute select-none",
822
+ previewMode ? "" : "rv-selectable",
823
+ !previewMode && isSelected ? "ring-2 ring-blue-500/90 ring-offset-0" : "",
824
+ ].join(" ")}
825
+ data-element-id={el.id}
826
+ onMouseDown={(e) => {
827
+ if (previewMode) return;
828
+ if (e.button !== 0) return;
829
+ // 单击选中(与 Selecto 的框选互补)
830
+ if (e.shiftKey) {
831
+ onSelectIds(
832
+ isSelected
833
+ ? selectedIds.filter((id) => id !== el.id)
834
+ : [...selectedIds, el.id]
835
+ );
836
+ } else {
837
+ onSelectIds([el.id]);
838
+ }
839
+ }}
840
+ style={{
841
+ left: el.x,
842
+ top: el.y,
843
+ width: Math.max(1, el.width),
844
+ height: Math.max(1, el.height),
845
+ zIndex: el.zIndex ?? 1,
846
+ transform: `rotate(${el.rotate ?? 0}deg)`,
847
+ transformOrigin: "center center",
848
+ boxSizing: "border-box",
849
+ ...getNodeVisualStyle(el),
850
+ }}
851
+ >
852
+ {CHART_TYPES.has(el.materialType ?? "") ? (
853
+ <ChartNodeContent
854
+ element={el}
855
+ previewLayoutKey={previewLayoutKey}
856
+ previewMode={previewMode}
857
+ />
858
+ ) : el.materialType === "reference" ? (
859
+ <ReferenceNodeContent element={el} allElements={allElements} />
860
+ ) : el.materialType === "grid" ? (
861
+ <GridNodeContent
862
+ element={el}
863
+ allElements={allElements}
864
+ previewMode={previewMode}
865
+ />
866
+ ) : el.materialType === "text" ? (
867
+ <TextNodeContent
868
+ element={el}
869
+ editable={(el.textAllowInput ?? true) && !el.locked && !layerLocked}
870
+ onChange={(nextHtml) => {
871
+ updateElement(el.id, { textHtml: nextHtml });
872
+ }}
873
+ />
874
+ ) : el.materialType === "audio" ? (
875
+ <AudioNodeContent element={el} selected={isSelected} />
876
+ ) : el.materialType === "video" ? (
877
+ <VideoNodeContent element={el} selected={isSelected} />
878
+ ) : el.materialType === "geometry" ? (
879
+ <GeometryNodeContent element={el} />
880
+ ) : el.materialType === "image" ? (
881
+ <ImageNodeContent element={el} />
882
+ ) : (
883
+ <EmptyNodePlaceholder element={el} />
884
+ )}
885
+ </div>
886
+ );
887
+ })}
888
+ </>
889
+ );
890
+ }
891
+