@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,3221 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { State } from "../../../rx-store/src/types";
3
+ import type { PanelElement } from "./types";
4
+
5
+ import { usePanelElements } from "./hooks/usePanelElements";
6
+ import { useWorkspaceProjects } from "./hooks/useWorkspaceProjects";
7
+ import type { WorkspaceProjectRecord } from "./library/workspace-project-db";
8
+ import { WorkspaceProjectNav } from "./components/WorkspaceProjectNav";
9
+ import { PanelCanvas } from "./components/PanelCanvas";
10
+ import { PanelRulers } from "./components/PanelRulers";
11
+ import { type ViewportZoom } from "./viewportZoom";
12
+ import { ElementsLayer } from "./components/ElementsLayer";
13
+ import { SelectLayer } from "./components/SelectLayer";
14
+ import { MoveableLayer } from "./components/MoveableLayer";
15
+ import { buildChartOption, CHART_TYPES } from "./utils/chartOptionBuilder";
16
+ import { MaterialSidebar } from "./components/MaterialSidebar";
17
+ import {
18
+ BlueprintGraph,
19
+ BlueprintMetaDialog,
20
+ BlueprintNodeSwitchTaskDialog,
21
+ blueprintDocumentsEqual,
22
+ buildBlueprintExportPayload,
23
+ buildLibraryRecord,
24
+ createLibraryBlueprintId,
25
+ deleteBlueprintLibraryRecord,
26
+ downloadBlueprintExport,
27
+ documentToRunnableGraph,
28
+ getBlueprintLibraryRecord,
29
+ libraryRecordFromImport,
30
+ listBlueprintLibrary,
31
+ parseBlueprintImportFile,
32
+ putBlueprintLibraryRecord,
33
+ updateBlueprintLibraryMeta,
34
+ useBlueprintDebugSession,
35
+ useBlueprintNodeSelectionGuard,
36
+ useBlueprintPageLifecycle,
37
+ stopAllClockSchedules,
38
+ type BlueprintDocument,
39
+ type BlueprintGraphNode,
40
+ type BlueprintLibraryListItem,
41
+ type BlueprintMetaDraft,
42
+ } from "@arronqzy/react-blueprint";
43
+ import type { LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
44
+ import { abortClockNode } from "@arronqzy/blueprint-dsl";
45
+ import { WorkspaceStageSplit } from "./components/WorkspaceStageSplit";
46
+ import {
47
+ WorkspaceConfigSidebar,
48
+ type WorkspaceConfigFocus,
49
+ } from "./components/WorkspaceConfigSidebar";
50
+ import {
51
+ getViewElementScope,
52
+ setViewElementScopes,
53
+ clearViewElementScopes,
54
+ useViewElementScope,
55
+ useViewScopeStoreVersion,
56
+ } from "./scope/view-scope-store";
57
+ import { resolvePanelElementScope } from "./utils/scope-template";
58
+ import { PANEL_MESSAGES } from "./constants/messages";
59
+ import { PANEL_Z_INDEX } from "./constants/zIndex";
60
+ import {
61
+ AlertDialog,
62
+ AlertDialogAction,
63
+ AlertDialogCancel,
64
+ AlertDialogContent,
65
+ AlertDialogDescription,
66
+ AlertDialogFooter,
67
+ AlertDialogHeader,
68
+ AlertDialogTitle,
69
+ Checkbox,
70
+ Dialog,
71
+ DialogContent,
72
+ DialogDescription,
73
+ DialogFooter,
74
+ DialogHeader,
75
+ DialogTitle,
76
+ DropdownMenu,
77
+ DropdownMenuContent,
78
+ DropdownMenuItem,
79
+ DropdownMenuTrigger,
80
+ Empty,
81
+ EmptyDescription,
82
+ EmptyIcon,
83
+ EmptyTitle,
84
+ Input,
85
+ Menubar,
86
+ MenubarContent,
87
+ MenubarItem,
88
+ MenubarMenu,
89
+ MenubarRadioGroup,
90
+ MenubarRadioItem,
91
+ MenubarSeparator,
92
+ MenubarTrigger,
93
+ RadioGroup,
94
+ RadioGroupItem,
95
+ ResizableHandle,
96
+ ResizablePanel,
97
+ ResizablePanelGroup,
98
+ Select,
99
+ SelectContent,
100
+ SelectItem,
101
+ SelectTrigger,
102
+ SelectValue,
103
+ Switch,
104
+ Tabs,
105
+ TabsContent,
106
+ TabsList,
107
+ TabsTrigger,
108
+ Toaster,
109
+ Tooltip,
110
+ TooltipContent,
111
+ TooltipProvider,
112
+ TooltipTrigger,
113
+ toast,
114
+ } from "@arronqzy/ui";
115
+
116
+ function IconPlus() {
117
+ return (
118
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
119
+ <path d="M12 5v14M5 12h14" />
120
+ </svg>
121
+ );
122
+ }
123
+
124
+ function IconMerge() {
125
+ return (
126
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
127
+ <path d="M7 7h4a4 4 0 0 1 4 4v6" />
128
+ <path d="m12 14 3 3 3-3" />
129
+ <path d="M7 17h2" />
130
+ </svg>
131
+ );
132
+ }
133
+
134
+ function IconLock({ locked }: { locked: boolean }) {
135
+ return locked ? (
136
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
137
+ <rect x="4" y="11" width="16" height="9" rx="2" />
138
+ <path d="M8 11V8a4 4 0 0 1 8 0v3" />
139
+ </svg>
140
+ ) : (
141
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
142
+ <rect x="4" y="11" width="16" height="9" rx="2" />
143
+ <path d="M16 11V8a4 4 0 0 0-7-2.5" />
144
+ </svg>
145
+ );
146
+ }
147
+
148
+ function IconEdit() {
149
+ return (
150
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
151
+ <path d="M12 20h9" />
152
+ <path d="m16.5 3.5 4 4L8 20l-5 1 1-5Z" />
153
+ </svg>
154
+ );
155
+ }
156
+
157
+ function IconTrash() {
158
+ return (
159
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
160
+ <path d="M3 6h18" />
161
+ <path d="M8 6V4h8v2" />
162
+ <path d="M19 6l-1 14H6L5 6" />
163
+ </svg>
164
+ );
165
+ }
166
+
167
+ function IconCheck() {
168
+ return (
169
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
170
+ <path d="m5 12 5 5L20 7" />
171
+ </svg>
172
+ );
173
+ }
174
+
175
+ function IconClose() {
176
+ return (
177
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
178
+ <path d="M6 6l12 12M18 6 6 18" />
179
+ </svg>
180
+ );
181
+ }
182
+
183
+ function IconChevron({ expanded }: { expanded: boolean }) {
184
+ return (
185
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
186
+ {expanded ? <path d="m6 14 6-6 6 6" /> : <path d="m6 10 6 6 6-6" />}
187
+ </svg>
188
+ );
189
+ }
190
+
191
+ function IconUndo() {
192
+ return (
193
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
194
+ <path d="M9 14 4 9l5-5" />
195
+ <path d="M20 20a9 9 0 0 0-9-9H4" />
196
+ </svg>
197
+ );
198
+ }
199
+
200
+ function IconRedo() {
201
+ return (
202
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
203
+ <path d="m15 14 5-5-5-5" />
204
+ <path d="M4 20a9 9 0 0 1 9-9h7" />
205
+ </svg>
206
+ );
207
+ }
208
+
209
+ function IconHistory() {
210
+ return (
211
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
212
+ <path d="M3 12a9 9 0 1 0 3-6.7" />
213
+ <path d="M3 4v4h4" />
214
+ <path d="M12 7v5l3 2" />
215
+ </svg>
216
+ );
217
+ }
218
+
219
+ function IconBringFront() {
220
+ return (
221
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
222
+ <path d="M7 17h10M12 17V7" />
223
+ <path d="m8.5 9 3.5-3.5L15.5 9" />
224
+ </svg>
225
+ );
226
+ }
227
+
228
+ function IconSendBack() {
229
+ return (
230
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
231
+ <path d="M7 7h10M12 17V7" />
232
+ <path d="m15.5 15-3.5 3.5L8.5 15" />
233
+ </svg>
234
+ );
235
+ }
236
+
237
+ function IconForward() {
238
+ return (
239
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
240
+ <path d="M12 18V6" />
241
+ <path d="m8.5 9 3.5-3.5L15.5 9" />
242
+ </svg>
243
+ );
244
+ }
245
+
246
+ function IconBackward() {
247
+ return (
248
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
249
+ <path d="M12 6v12" />
250
+ <path d="m15.5 15-3.5 3.5L8.5 15" />
251
+ </svg>
252
+ );
253
+ }
254
+
255
+ function IconLayers() {
256
+ return (
257
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
258
+ <path d="m12 4 8 4-8 4-8-4 8-4Z" />
259
+ <path d="m4 12 8 4 8-4" />
260
+ <path d="m4 16 8 4 8-4" />
261
+ </svg>
262
+ );
263
+ }
264
+
265
+ function IconImage() {
266
+ return (
267
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
268
+ <rect x="3" y="5" width="18" height="14" rx="2" />
269
+ <circle cx="9" cy="10" r="1.5" />
270
+ <path d="m21 16-5.5-5.5L7 19" />
271
+ </svg>
272
+ );
273
+ }
274
+
275
+ function formatRelativeTime(timestamp: number, now: number): string {
276
+ const diff = Math.max(0, now - timestamp);
277
+ const sec = Math.floor(diff / 1000);
278
+ if (sec < 60) return `${sec}秒前`;
279
+ const min = Math.floor(sec / 60);
280
+ if (min < 60) return `${min}分钟前`;
281
+ const hour = Math.floor(min / 60);
282
+ if (hour < 24) return `${hour}小时前`;
283
+ const day = Math.floor(hour / 24);
284
+ return `${day}天前`;
285
+ }
286
+
287
+ function getSelectedTargetsFromIds(
288
+ container: HTMLElement | null,
289
+ ids: string[],
290
+ ) {
291
+ if (!container) return [];
292
+ const targets: HTMLElement[] = [];
293
+ for (const id of ids) {
294
+ const el = container.querySelector<HTMLElement>(
295
+ `[data-element-id="${id}"]`,
296
+ );
297
+ if (el) targets.push(el);
298
+ }
299
+ return targets;
300
+ }
301
+
302
+ function shouldClearSelectionOnBlank(target: HTMLElement | null) {
303
+ if (!target) return false;
304
+ if (
305
+ target.closest(".rv-selectable") ||
306
+ target.closest(".moveable-control-box") ||
307
+ target.closest(".moveable-line") ||
308
+ target.closest(".moveable-control") ||
309
+ target.closest(".moveable-direction")
310
+ ) {
311
+ return false;
312
+ }
313
+ if (
314
+ target.closest("button") ||
315
+ target.closest("input") ||
316
+ target.closest("select") ||
317
+ target.closest("textarea") ||
318
+ target.closest("label") ||
319
+ target.closest("a") ||
320
+ target.closest("[role='menuitem']") ||
321
+ target.closest("[data-radix-popper-content-wrapper]")
322
+ ) {
323
+ return false;
324
+ }
325
+ return true;
326
+ }
327
+
328
+ export type ReactViewPanelProps = {
329
+ initialZoom?: number;
330
+ className?: string;
331
+ };
332
+
333
+ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelProps) {
334
+ const THEME_STORAGE_KEY = "panel:theme";
335
+ const TITLE_ICON_STORAGE_KEY = "panel:titleIconDataUrl";
336
+ const themedScrollbarClass =
337
+ "scrollbar-thin [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-muted/40 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/80 [&::-webkit-scrollbar-thumb]:hover:bg-border";
338
+ const {
339
+ elements,
340
+ allElements,
341
+ byId,
342
+ layers,
343
+ activeLayerId,
344
+ updateElement,
345
+ deleteElement,
346
+ deleteElements,
347
+ bringElementsToFront,
348
+ sendElementsToBack,
349
+ bringElementsForward,
350
+ sendElementsBackward,
351
+ duplicateElement,
352
+ setReferenceCopyMode,
353
+ addElementFromMaterial,
354
+ setActiveLayer,
355
+ addLayer,
356
+ openElementsInNewLayer,
357
+ renameLayer,
358
+ toggleLayerLock,
359
+ deleteLayer,
360
+ toggleLayerMergeSelected,
361
+ mergeSelectedLayers,
362
+ setPrimaryLayer,
363
+ undo,
364
+ redo,
365
+ goToHistory,
366
+ canUndo,
367
+ canRedo,
368
+ historyCursor,
369
+ history,
370
+ exportPanelData,
371
+ importPanelData,
372
+ } = usePanelElements();
373
+
374
+ const scrollRef = useRef<HTMLDivElement | null>(null);
375
+ const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null);
376
+ const syncScrollRef = useCallback((el: HTMLDivElement | null) => {
377
+ scrollRef.current = el;
378
+ setViewportEl(el);
379
+ }, []);
380
+ const canvasRef = useRef<HTMLDivElement | null>(null);
381
+ const [canvasEl, setCanvasEl] = useState<HTMLDivElement | null>(null);
382
+ const syncCanvasRef = useCallback((el: HTMLDivElement | null) => {
383
+ canvasRef.current = el;
384
+ setCanvasEl(el);
385
+ }, []);
386
+ const panelRootRef = useRef<HTMLDivElement | null>(null);
387
+ const [zoom, setZoom] = useState<ViewportZoom>({
388
+ x: initialZoom,
389
+ y: initialZoom,
390
+ });
391
+
392
+ const adjustUniformZoom = useCallback((updater: (value: number) => number) => {
393
+ setZoom((prev) => {
394
+ const next = Math.min(4, Math.max(0.25, updater(prev.x)));
395
+ const rounded = Number(next.toFixed(4));
396
+ return { x: rounded, y: rounded };
397
+ });
398
+ }, []);
399
+ const [scroll, setScroll] = useState({ left: 0, top: 0 });
400
+
401
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
402
+ const [selectedTargets, setSelectedTargets] = useState<HTMLElement[]>([]);
403
+ const [contextMenuNodeId, setContextMenuNodeId] = useState<string | null>(null);
404
+ const [dropdownOpen, setDropdownOpen] = useState(false);
405
+ const [dropdownEpoch, setDropdownEpoch] = useState(0);
406
+ const [dropdownPoint, setDropdownPoint] = useState({ x: 0, y: 0 });
407
+ const rightPointerRef = useRef<{
408
+ active: boolean;
409
+ moved: boolean;
410
+ startX: number;
411
+ startY: number;
412
+ }>({ active: false, moved: false, startX: 0, startY: 0 });
413
+ const [copiedNodeId, setCopiedNodeId] = useState<string | null>(null);
414
+ const [isDark, setIsDark] = useState(false);
415
+ const importInputRef = useRef<HTMLInputElement | null>(null);
416
+ const blueprintImportInputRef = useRef<HTMLInputElement | null>(null);
417
+ const titleIconInputRef = useRef<HTMLInputElement | null>(null);
418
+ const [editingLayerId, setEditingLayerId] = useState<string | null>(null);
419
+ const [editingLayerName, setEditingLayerName] = useState("");
420
+ const [confirmDeleteLayerId, setConfirmDeleteLayerId] = useState<string | null>(null);
421
+ const [deleteMode, setDeleteMode] = useState<"remove" | "move">("move");
422
+ const [deleteTargetLayerId, setDeleteTargetLayerId] = useState<string>("");
423
+ const [isMergingLayers, setIsMergingLayers] = useState(false);
424
+ const [mergeLayerName, setMergeLayerName] = useState("");
425
+ const [mappingDeleteConfirmOpen, setMappingDeleteConfirmOpen] = useState(false);
426
+ const [mappingDeleteImpactCount, setMappingDeleteImpactCount] = useState(0);
427
+ const mappingDeleteProceedRef = useRef<(() => void) | null>(null);
428
+ const [isLayerPanelExpanded, setIsLayerPanelExpanded] = useState(false);
429
+ const [isHistoryPanelExpanded, setIsHistoryPanelExpanded] = useState(false);
430
+ const [historyNow, setHistoryNow] = useState(Date.now());
431
+ const [historyKeyword, setHistoryKeyword] = useState("");
432
+ const [productName, setProductName] = useState("未命名产物");
433
+ const [titleIconDataUrl, setTitleIconDataUrl] = useState<string>("");
434
+ const [titleIconPreviewOpen, setTitleIconPreviewOpen] = useState(false);
435
+ const [titleIconZoom, setTitleIconZoom] = useState(1.6);
436
+ const [pendingPreviewLayerId, setPendingPreviewLayerId] = useState<string | null>(null);
437
+ const [panelFontSize, setPanelFontSize] = useState<"sm" | "md" | "lg">("md");
438
+ const [blueprintOpen, setBlueprintOpen] = useState(false);
439
+ const [blueprintGraph, setBlueprintGraph] = useState(() => BlueprintGraph.empty());
440
+ const [blueprintMeta, setBlueprintMeta] = useState<BlueprintMetaDraft>({
441
+ name: "未命名蓝图",
442
+ remark: "",
443
+ });
444
+ const [blueprintLibraryItems, setBlueprintLibraryItems] = useState<
445
+ BlueprintLibraryListItem[]
446
+ >([]);
447
+ const [activeBlueprintLibraryId, setActiveBlueprintLibraryId] = useState<string | null>(
448
+ null
449
+ );
450
+ /** 当前库蓝图加载/同步时的文档快照,用于判断是否有未同步修改 */
451
+ const [blueprintSyncedDocument, setBlueprintSyncedDocument] =
452
+ useState<BlueprintDocument | null>(null);
453
+ /** 进入蓝图库编辑前的工作区蓝图,用于取消库选中时恢复 */
454
+ const workspaceBlueprintRef = useRef<{
455
+ document: BlueprintDocument;
456
+ meta: BlueprintMetaDraft;
457
+ } | null>(null);
458
+ const [blueprintMetaDialogOpen, setBlueprintMetaDialogOpen] = useState(false);
459
+ const [blueprintMetaDialogMode, setBlueprintMetaDialogMode] = useState<
460
+ "export" | "save"
461
+ >("save");
462
+ const [selectedBlueprintNodeId, setSelectedBlueprintNodeId] = useState<string | null>(
463
+ null
464
+ );
465
+ const [configFocus, setConfigFocus] = useState<WorkspaceConfigFocus>("view");
466
+ const [ellipsisTooltip, setEllipsisTooltip] = useState<{
467
+ open: boolean;
468
+ text: string;
469
+ x: number;
470
+ y: number;
471
+ }>({
472
+ open: false,
473
+ text: "",
474
+ x: 0,
475
+ y: 0,
476
+ });
477
+ const mergeSelectedCount = layers.filter((l) => l.mergeSelected && !l.isMapping).length;
478
+ const canMergeLayers = mergeSelectedCount >= 2;
479
+ const panelFontPx = panelFontSize === "sm" ? 12 : panelFontSize === "lg" ? 15 : 13;
480
+ const normalizedHistoryKeyword = historyKeyword.trim().toLowerCase();
481
+ useEffect(() => {
482
+ if (!isHistoryPanelExpanded) return;
483
+ setHistoryNow(Date.now());
484
+ const timer = window.setInterval(() => setHistoryNow(Date.now()), 1000);
485
+ return () => window.clearInterval(timer);
486
+ }, [isHistoryPanelExpanded]);
487
+ const applyTheme = useCallback((checked: boolean) => {
488
+ const root = document.documentElement;
489
+ root.classList.toggle("dark", checked);
490
+ root.dataset.theme = checked ? "dark" : "light";
491
+ try {
492
+ // panel 独立持久化键,避免与宿主应用的通用 theme 键互相覆盖
493
+ localStorage.setItem(THEME_STORAGE_KEY, checked ? "dark" : "light");
494
+ // 兼容旧逻辑与其他依赖 theme 的读取方
495
+ localStorage.setItem("theme", checked ? "dark" : "light");
496
+ } catch {
497
+ // ignore storage errors
498
+ }
499
+ setIsDark(checked);
500
+ }, [THEME_STORAGE_KEY]);
501
+
502
+ useEffect(() => {
503
+ const root = document.documentElement;
504
+ try {
505
+ const storedTheme =
506
+ localStorage.getItem(THEME_STORAGE_KEY) ?? localStorage.getItem("theme");
507
+ if (storedTheme === "dark" || storedTheme === "light") {
508
+ applyTheme(storedTheme === "dark");
509
+ } else {
510
+ // 默认黑色主题,并持久化
511
+ applyTheme(true);
512
+ }
513
+ } catch {
514
+ // 读取失败时也回落到默认黑色
515
+ applyTheme(true);
516
+ }
517
+ const update = () => setIsDark(root.classList.contains("dark"));
518
+ update();
519
+ const observer = new MutationObserver(update);
520
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
521
+ return () => observer.disconnect();
522
+ }, [THEME_STORAGE_KEY, applyTheme]);
523
+
524
+ useEffect(() => {
525
+ try {
526
+ const stored = localStorage.getItem("panel:fontSize");
527
+ if (stored === "sm" || stored === "md" || stored === "lg") {
528
+ setPanelFontSize(stored);
529
+ }
530
+ } catch {
531
+ // ignore storage errors
532
+ }
533
+ }, []);
534
+
535
+ useEffect(() => {
536
+ try {
537
+ localStorage.setItem("panel:fontSize", panelFontSize);
538
+ } catch {
539
+ // ignore storage errors
540
+ }
541
+ }, [panelFontSize]);
542
+
543
+ useEffect(() => {
544
+ try {
545
+ const stored = localStorage.getItem(TITLE_ICON_STORAGE_KEY) ?? "";
546
+ if (stored) setTitleIconDataUrl(stored);
547
+ } catch {
548
+ // ignore storage errors
549
+ }
550
+ }, [TITLE_ICON_STORAGE_KEY]);
551
+
552
+ useEffect(() => {
553
+ try {
554
+ if (titleIconDataUrl) {
555
+ localStorage.setItem(TITLE_ICON_STORAGE_KEY, titleIconDataUrl);
556
+ } else {
557
+ localStorage.removeItem(TITLE_ICON_STORAGE_KEY);
558
+ }
559
+ } catch {
560
+ // ignore storage errors
561
+ }
562
+ }, [TITLE_ICON_STORAGE_KEY, titleIconDataUrl]);
563
+
564
+ useEffect(() => {
565
+ if (!titleIconPreviewOpen) return;
566
+ setTitleIconZoom(1.6);
567
+ }, [titleIconPreviewOpen, titleIconDataUrl]);
568
+
569
+ useEffect(() => {
570
+ const root = panelRootRef.current;
571
+ if (!root) return;
572
+ let rafId = 0;
573
+ const schedule = () => {
574
+ if (rafId) return;
575
+ rafId = window.requestAnimationFrame(() => {
576
+ rafId = 0;
577
+ const candidates = root.querySelectorAll<HTMLElement>(
578
+ ".truncate, .line-clamp-1, .line-clamp-2, .line-clamp-3"
579
+ );
580
+ candidates.forEach((el) => {
581
+ const text = (el.textContent ?? "").trim();
582
+ if (!text) {
583
+ delete el.dataset.ellipsisOverflow;
584
+ delete el.dataset.ellipsisTooltipText;
585
+ return;
586
+ }
587
+ const overflowed =
588
+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
589
+ if (overflowed) {
590
+ el.dataset.ellipsisOverflow = "1";
591
+ el.dataset.ellipsisTooltipText = text;
592
+ } else {
593
+ delete el.dataset.ellipsisOverflow;
594
+ delete el.dataset.ellipsisTooltipText;
595
+ }
596
+ });
597
+ });
598
+ };
599
+
600
+ schedule();
601
+ const mutationObserver = new MutationObserver(schedule);
602
+ mutationObserver.observe(root, {
603
+ childList: true,
604
+ subtree: true,
605
+ characterData: true,
606
+ attributes: true,
607
+ attributeFilter: ["class", "style"],
608
+ });
609
+ const resizeObserver = new ResizeObserver(schedule);
610
+ resizeObserver.observe(root);
611
+ window.addEventListener("resize", schedule);
612
+ return () => {
613
+ if (rafId) window.cancelAnimationFrame(rafId);
614
+ mutationObserver.disconnect();
615
+ resizeObserver.disconnect();
616
+ window.removeEventListener("resize", schedule);
617
+ };
618
+ }, []);
619
+
620
+ useEffect(() => {
621
+ const root = panelRootRef.current;
622
+ if (!root) return;
623
+ const hide = () =>
624
+ setEllipsisTooltip((prev) => (prev.open ? { ...prev, open: false } : prev));
625
+ const onPointerMove = (e: PointerEvent) => {
626
+ const target = e.target as HTMLElement | null;
627
+ const holder = target?.closest<HTMLElement>("[data-ellipsis-overflow='1']");
628
+ if (!holder) {
629
+ hide();
630
+ return;
631
+ }
632
+ const text = holder.dataset.ellipsisTooltipText ?? holder.textContent?.trim() ?? "";
633
+ if (!text) {
634
+ hide();
635
+ return;
636
+ }
637
+ setEllipsisTooltip({
638
+ open: true,
639
+ text,
640
+ x: e.clientX + 8,
641
+ y: e.clientY + 14,
642
+ });
643
+ };
644
+ root.addEventListener("pointermove", onPointerMove, true);
645
+ root.addEventListener("pointerleave", hide, true);
646
+ root.addEventListener("scroll", hide, true);
647
+ return () => {
648
+ root.removeEventListener("pointermove", onPointerMove, true);
649
+ root.removeEventListener("pointerleave", hide, true);
650
+ root.removeEventListener("scroll", hide, true);
651
+ };
652
+ }, []);
653
+
654
+ const applyBlueprintNodeSelection = useCallback((nodeId: string | null) => {
655
+ setSelectedBlueprintNodeId(nodeId);
656
+ if (nodeId) {
657
+ setConfigFocus("blueprint");
658
+ setSelectedIds([]);
659
+ setSelectedTargets([]);
660
+ return;
661
+ }
662
+ setConfigFocus("view");
663
+ }, []);
664
+
665
+ const {
666
+ requestSelectNode: requestSelectBlueprintNode,
667
+ pendingSwitch: pendingBlueprintNodeSwitch,
668
+ keepTaskAndSwitch: keepBlueprintTaskAndSwitch,
669
+ cancelTaskAndSwitch: cancelBlueprintTaskAndSwitch,
670
+ stayOnCurrentNode: stayOnCurrentBlueprintNode,
671
+ } = useBlueprintNodeSelectionGuard(
672
+ selectedBlueprintNodeId,
673
+ applyBlueprintNodeSelection
674
+ );
675
+
676
+ const focusViewConfig = useCallback(() => {
677
+ setConfigFocus("view");
678
+ requestSelectBlueprintNode(null);
679
+ }, [requestSelectBlueprintNode]);
680
+
681
+ const onSelectBlueprintNode = requestSelectBlueprintNode;
682
+
683
+ const handleUpdateBlueprintNode = useCallback(
684
+ (
685
+ nodeId: string,
686
+ patch: Partial<
687
+ Pick<
688
+ BlueprintGraphNode,
689
+ | "label"
690
+ | "role"
691
+ | "nodeType"
692
+ | "configSource"
693
+ | "viewElementId"
694
+ | "viewElementIds"
695
+ | "nestedBlueprintId"
696
+ | "libraryBlueprintId"
697
+ | "lifecyclePhase"
698
+ | "fetchConfig"
699
+ | "jsonConfig"
700
+ | "logicConfig"
701
+ | "clockConfig"
702
+ >
703
+ >
704
+ ) => {
705
+ setBlueprintGraph((graph) => graph.updateNode(nodeId, patch));
706
+ },
707
+ []
708
+ );
709
+
710
+ const handleUpdateAllowFalseSignalPropagation = useCallback((value: boolean) => {
711
+ setBlueprintGraph((graph) =>
712
+ graph.withDocument({
713
+ ...graph.document,
714
+ allowFalseSignalPropagation: value,
715
+ })
716
+ );
717
+ }, []);
718
+
719
+ const clearSelection = useCallback(() => {
720
+ setSelectedIds([]);
721
+ setSelectedTargets([]);
722
+ focusViewConfig();
723
+ }, [focusViewConfig]);
724
+
725
+ useEffect(() => {
726
+ setSelectedIds([]);
727
+ setSelectedTargets([]);
728
+ setConfigFocus("view");
729
+ setSelectedBlueprintNodeId(null);
730
+ }, [activeLayerId]);
731
+
732
+ useEffect(() => {
733
+ if (isMergingLayers && !canMergeLayers) {
734
+ setIsMergingLayers(false);
735
+ setMergeLayerName("");
736
+ }
737
+ }, [canMergeLayers, isMergingLayers]);
738
+
739
+ useEffect(() => {
740
+ const onKeyDown = (e: KeyboardEvent) => {
741
+ const cmdOrCtrl = e.metaKey || e.ctrlKey;
742
+ if (!cmdOrCtrl) return;
743
+ if (e.key.toLowerCase() !== "z") return;
744
+ e.preventDefault();
745
+ if (e.shiftKey) {
746
+ if (canRedo) redo();
747
+ } else if (canUndo) {
748
+ undo();
749
+ }
750
+ };
751
+ window.addEventListener("keydown", onKeyDown);
752
+ return () => window.removeEventListener("keydown", onKeyDown);
753
+ }, [canRedo, canUndo, redo, undo]);
754
+
755
+ // 平移/滚动由 InfiniteViewer 驱动,通过 PanelCanvas 回传
756
+
757
+ /** 选中视图节点:更新选中集合并将右侧配置切回视图(最近一次触发优先) */
758
+ const selectViewElements = useCallback(
759
+ (ids: string[]) => {
760
+ setSelectedIds(ids);
761
+ if (ids.length > 0) {
762
+ focusViewConfig();
763
+ }
764
+ },
765
+ [focusViewConfig]
766
+ );
767
+
768
+ // 同步点击选中 -> targets
769
+ useEffect(() => {
770
+ setSelectedTargets(
771
+ getSelectedTargetsFromIds(canvasRef.current, selectedIds),
772
+ );
773
+ }, [selectedIds, elements, historyCursor]);
774
+
775
+ useEffect(() => {
776
+ const existing = new Set(elements.map((el) => el.id));
777
+ setSelectedIds((prev) => prev.filter((id) => existing.has(id)));
778
+ }, [elements]);
779
+
780
+ const canvasContainer = canvasEl;
781
+ const activeLayer = layers.find((l) => l.id === activeLayerId) ?? null;
782
+ const primaryLayer = layers.find((l) => l.isPrimary) ?? layers[0] ?? null;
783
+ const deletingLayer = layers.find((l) => l.id === confirmDeleteLayerId) ?? null;
784
+ const deletingLayerMode: "move" | "remove" =
785
+ deletingLayer?.isMapping ? "remove" : deleteMode;
786
+ const deleteTargetCandidates = layers.filter((l) => l.id !== confirmDeleteLayerId);
787
+ const selectedBlueprintNode = useMemo(() => {
788
+ if (!selectedBlueprintNodeId) return null;
789
+ return blueprintGraph.getNode(selectedBlueprintNodeId) ?? null;
790
+ }, [blueprintGraph, selectedBlueprintNodeId]);
791
+
792
+ const blueprintLibraryNameById = useMemo(
793
+ () => new Map(blueprintLibraryItems.map((item) => [item.id, item.name])),
794
+ [blueprintLibraryItems]
795
+ );
796
+
797
+ const blueprintLibraryOptions = useMemo(
798
+ () =>
799
+ blueprintLibraryItems.map((item) => ({
800
+ id: item.id,
801
+ label: item.name,
802
+ })),
803
+ [blueprintLibraryItems]
804
+ );
805
+
806
+ const resolveLibraryBlueprint = useCallback<LibraryBlueprintResolver>(
807
+ async (libraryBlueprintId) => {
808
+ const record = await getBlueprintLibraryRecord(libraryBlueprintId);
809
+ if (!record) return null;
810
+ const items = await listBlueprintLibrary();
811
+ const nameById = new Map(items.map((item) => [item.id, item.name]));
812
+ return documentToRunnableGraph(record.document, { libraryNameById: nameById });
813
+ },
814
+ []
815
+ );
816
+
817
+ const handleBlueprintExecutionBlocked = useCallback((message: string) => {
818
+ toast({ title: message });
819
+ }, []);
820
+
821
+ const handleViewScopeUpdate = useCallback(
822
+ (viewElementIds: string[], scope: unknown) => {
823
+ setViewElementScopes(viewElementIds, scope);
824
+ },
825
+ []
826
+ );
827
+
828
+ const blueprintDebugSession = useBlueprintDebugSession({
829
+ graph: blueprintGraph,
830
+ blueprintId: activeBlueprintLibraryId,
831
+ blueprintName: blueprintMeta.name || "未命名蓝图",
832
+ resolveLibraryBlueprint,
833
+ libraryNameById: blueprintLibraryNameById,
834
+ onExecutionBlocked: handleBlueprintExecutionBlocked,
835
+ onViewScopeUpdate: handleViewScopeUpdate,
836
+ });
837
+
838
+ const { selectLifecycleNode, selectedLifecycleNodeId: debugLifecycleNodeId } =
839
+ blueprintDebugSession;
840
+
841
+ useEffect(() => {
842
+ if (!selectedBlueprintNodeId) return;
843
+ const node = blueprintGraph.getNode(selectedBlueprintNodeId);
844
+ if (node?.role !== "lifecycle") return;
845
+ if (debugLifecycleNodeId === selectedBlueprintNodeId) return;
846
+ selectLifecycleNode(selectedBlueprintNodeId);
847
+ }, [
848
+ blueprintGraph,
849
+ debugLifecycleNodeId,
850
+ selectLifecycleNode,
851
+ selectedBlueprintNodeId,
852
+ ]);
853
+
854
+ const sidebarConfigFocus: WorkspaceConfigFocus =
855
+ blueprintDebugSession.logPanelOpen ? "blueprint-log" : configFocus;
856
+
857
+ const selectedDebugLifecyclePhase =
858
+ blueprintDebugSession.lifecycleNodes.find(
859
+ (node) => node.id === blueprintDebugSession.selectedLifecycleNodeId
860
+ )?.phase;
861
+
862
+ const blueprintDebugToolbar = useMemo(
863
+ () => ({
864
+ lifecycleNodes: blueprintDebugSession.lifecycleNodes,
865
+ selectedLifecycleNodeId: blueprintDebugSession.selectedLifecycleNodeId,
866
+ onSelectLifecycleNode: blueprintDebugSession.selectLifecycleNode,
867
+ onRunAll: () => void blueprintDebugSession.runAll(),
868
+ onResetToStart: () => void blueprintDebugSession.resetToStart(),
869
+ onStepBack: () => void blueprintDebugSession.stepBack(),
870
+ onStepNext: () => void blueprintDebugSession.stepNext(),
871
+ canResetToStart: blueprintDebugSession.canResetToStart,
872
+ canStepBack: blueprintDebugSession.canStepBack,
873
+ canStepNext: blueprintDebugSession.canStepNext,
874
+ chainComplete: blueprintDebugSession.chainComplete,
875
+ falseSignalHalt: blueprintDebugSession.falseSignalHalt,
876
+ logPanelOpen: blueprintDebugSession.logPanelOpen,
877
+ onToggleLogPanel: () =>
878
+ blueprintDebugSession.setLogPanelOpen((open) => !open),
879
+ running: blueprintDebugSession.running,
880
+ }),
881
+ [blueprintDebugSession]
882
+ );
883
+
884
+ const executionLogView = useMemo(
885
+ () => ({
886
+ entries: blueprintDebugSession.entries,
887
+ settings: blueprintDebugSession.settings,
888
+ onUpdateSettings: blueprintDebugSession.updateSettings,
889
+ onSave: blueprintDebugSession.saveCurrentRun,
890
+ onExport: blueprintDebugSession.exportCurrentRun,
891
+ onClear: blueprintDebugSession.clearLog,
892
+ onClearAllSaved: async () => {
893
+ const removed = await blueprintDebugSession.clearAllSavedRuns();
894
+ toast({
895
+ title:
896
+ removed > 0
897
+ ? `已清空 IndexedDB 中 ${removed} 条蓝图日志`
898
+ : "IndexedDB 中暂无已保存日志",
899
+ });
900
+ },
901
+ hasSavedRuns: blueprintDebugSession.totalSavedRunCount > 0,
902
+ onApplyRetention: blueprintDebugSession.applyRetention,
903
+ lifecyclePhase: selectedDebugLifecyclePhase,
904
+ }),
905
+ [blueprintDebugSession, selectedDebugLifecyclePhase]
906
+ );
907
+
908
+ const handleAbortClock = useCallback(
909
+ (nodeId: string) => {
910
+ blueprintDebugSession.abortClock(nodeId);
911
+ abortClockNode(activeBlueprintLibraryId ?? "local", nodeId);
912
+ },
913
+ [activeBlueprintLibraryId, blueprintDebugSession]
914
+ );
915
+
916
+ const blueprintCanvasProps = useMemo(
917
+ () => ({
918
+ graph: blueprintGraph,
919
+ onGraphChange: setBlueprintGraph,
920
+ selectedNodeId: selectedBlueprintNodeId,
921
+ onSelectNode: onSelectBlueprintNode,
922
+ onAbortClock: handleAbortClock,
923
+ libraryNameById: blueprintLibraryNameById,
924
+ executionOverlay: blueprintDebugSession.executionOverlay,
925
+ }),
926
+ [
927
+ blueprintGraph,
928
+ blueprintDebugSession.executionOverlay,
929
+ blueprintLibraryNameById,
930
+ handleAbortClock,
931
+ onSelectBlueprintNode,
932
+ selectedBlueprintNodeId,
933
+ ]
934
+ );
935
+
936
+ const refreshBlueprintLibrary = useCallback(async () => {
937
+ const items = await listBlueprintLibrary();
938
+ setBlueprintLibraryItems(items);
939
+ }, []);
940
+
941
+ useEffect(() => {
942
+ void refreshBlueprintLibrary();
943
+ }, [refreshBlueprintLibrary]);
944
+
945
+ const loadBlueprintFromLibrary = useCallback(
946
+ async (id: string) => {
947
+ const record = await getBlueprintLibraryRecord(id);
948
+ if (!record) {
949
+ toast({ title: "蓝图不存在或已被删除" });
950
+ void refreshBlueprintLibrary();
951
+ return;
952
+ }
953
+ setBlueprintGraph(BlueprintGraph.fromDocument(record.document));
954
+ setBlueprintMeta({
955
+ name: record.name,
956
+ remark: record.remark ?? "",
957
+ });
958
+ setActiveBlueprintLibraryId(record.id);
959
+ setBlueprintSyncedDocument(record.document);
960
+ setSelectedBlueprintNodeId(null);
961
+ setConfigFocus("blueprint");
962
+ setBlueprintOpen(true);
963
+ },
964
+ [refreshBlueprintLibrary]
965
+ );
966
+
967
+ const snapshotWorkspaceBlueprint = useCallback(() => {
968
+ workspaceBlueprintRef.current = {
969
+ document: blueprintGraph.document,
970
+ meta: { ...blueprintMeta },
971
+ };
972
+ }, [blueprintGraph.document, blueprintMeta]);
973
+
974
+ const returnToWorkspaceBlueprint = useCallback(() => {
975
+ const snapshot = workspaceBlueprintRef.current;
976
+ if (snapshot) {
977
+ setBlueprintGraph(BlueprintGraph.fromDocument(snapshot.document));
978
+ setBlueprintMeta(snapshot.meta);
979
+ }
980
+ setActiveBlueprintLibraryId(null);
981
+ setBlueprintSyncedDocument(null);
982
+ setSelectedBlueprintNodeId(null);
983
+ }, []);
984
+
985
+ const handleSelectBlueprintLibraryItem = useCallback(
986
+ async (id: string) => {
987
+ if (activeBlueprintLibraryId === id) {
988
+ returnToWorkspaceBlueprint();
989
+ return;
990
+ }
991
+ if (!activeBlueprintLibraryId) {
992
+ snapshotWorkspaceBlueprint();
993
+ }
994
+ await loadBlueprintFromLibrary(id);
995
+ },
996
+ [
997
+ activeBlueprintLibraryId,
998
+ loadBlueprintFromLibrary,
999
+ returnToWorkspaceBlueprint,
1000
+ snapshotWorkspaceBlueprint,
1001
+ ]
1002
+ );
1003
+
1004
+ const blueprintLibraryDirty = useMemo(() => {
1005
+ if (!activeBlueprintLibraryId || !blueprintSyncedDocument) return false;
1006
+ return !blueprintDocumentsEqual(
1007
+ blueprintGraph.document,
1008
+ blueprintSyncedDocument
1009
+ );
1010
+ }, [
1011
+ activeBlueprintLibraryId,
1012
+ blueprintGraph.document,
1013
+ blueprintSyncedDocument,
1014
+ ]);
1015
+
1016
+ const syncBlueprintToLibrary = useCallback(async () => {
1017
+ if (!activeBlueprintLibraryId) return;
1018
+
1019
+ const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId);
1020
+ if (!existing) {
1021
+ toast({ title: "蓝图不存在或已被删除" });
1022
+ void refreshBlueprintLibrary();
1023
+ setActiveBlueprintLibraryId(null);
1024
+ setBlueprintSyncedDocument(null);
1025
+ return;
1026
+ }
1027
+
1028
+ const record = buildLibraryRecord({
1029
+ id: existing.id,
1030
+ createdAt: existing.createdAt,
1031
+ document: blueprintGraph.document,
1032
+ meta: blueprintMeta,
1033
+ source: existing.source,
1034
+ });
1035
+ await putBlueprintLibraryRecord(record);
1036
+ setBlueprintSyncedDocument(blueprintGraph.document);
1037
+ await refreshBlueprintLibrary();
1038
+ toast({ title: `已同步「${record.name}」` });
1039
+ }, [
1040
+ activeBlueprintLibraryId,
1041
+ blueprintGraph.document,
1042
+ blueprintMeta,
1043
+ refreshBlueprintLibrary,
1044
+ ]);
1045
+
1046
+ const saveBlueprintToLibrary = useCallback(
1047
+ async (meta: BlueprintMetaDraft) => {
1048
+ setBlueprintMeta(meta);
1049
+
1050
+ let recordId = activeBlueprintLibraryId ?? undefined;
1051
+ let createdAt: number | undefined;
1052
+ if (activeBlueprintLibraryId) {
1053
+ const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId);
1054
+ if (existing?.source === "saved") {
1055
+ recordId = existing.id;
1056
+ createdAt = existing.createdAt;
1057
+ } else {
1058
+ recordId = createLibraryBlueprintId();
1059
+ }
1060
+ } else {
1061
+ recordId = createLibraryBlueprintId();
1062
+ }
1063
+
1064
+ const record = buildLibraryRecord({
1065
+ id: recordId,
1066
+ createdAt,
1067
+ document: blueprintGraph.document,
1068
+ meta,
1069
+ source: "saved",
1070
+ });
1071
+ await putBlueprintLibraryRecord(record);
1072
+ setActiveBlueprintLibraryId(record.id);
1073
+ setBlueprintSyncedDocument(blueprintGraph.document);
1074
+ await refreshBlueprintLibrary();
1075
+ toast({ title: "蓝图已保存到本地" });
1076
+ },
1077
+ [activeBlueprintLibraryId, blueprintGraph.document, refreshBlueprintLibrary]
1078
+ );
1079
+
1080
+ const openBlueprintMetaDialog = useCallback((mode: "export" | "save") => {
1081
+ setBlueprintMetaDialogMode(mode);
1082
+ setBlueprintMetaDialogOpen(true);
1083
+ }, []);
1084
+
1085
+ const handleBlueprintMetaConfirm = useCallback(
1086
+ async (meta: BlueprintMetaDraft) => {
1087
+ if (blueprintMetaDialogMode === "export") {
1088
+ setBlueprintMeta(meta);
1089
+ downloadBlueprintExport(
1090
+ buildBlueprintExportPayload(blueprintGraph.document, meta)
1091
+ );
1092
+ toast({ title: "蓝图已导出" });
1093
+ return;
1094
+ }
1095
+ await saveBlueprintToLibrary(meta);
1096
+ },
1097
+ [blueprintGraph.document, blueprintMetaDialogMode, saveBlueprintToLibrary]
1098
+ );
1099
+
1100
+ const handleRenameBlueprintLibraryItem = useCallback(
1101
+ async (id: string, name: string) => {
1102
+ const updated = await updateBlueprintLibraryMeta(id, { name });
1103
+ if (!updated) {
1104
+ toast({ title: "重命名失败" });
1105
+ void refreshBlueprintLibrary();
1106
+ return;
1107
+ }
1108
+ await refreshBlueprintLibrary();
1109
+ if (activeBlueprintLibraryId === id) {
1110
+ setBlueprintMeta((prev) => ({ ...prev, name }));
1111
+ }
1112
+ toast({ title: "蓝图已重命名" });
1113
+ },
1114
+ [activeBlueprintLibraryId, refreshBlueprintLibrary]
1115
+ );
1116
+
1117
+ const handleDeleteBlueprintLibraryItem = useCallback(
1118
+ async (id: string) => {
1119
+ await deleteBlueprintLibraryRecord(id);
1120
+ await refreshBlueprintLibrary();
1121
+ if (activeBlueprintLibraryId === id) {
1122
+ setActiveBlueprintLibraryId(null);
1123
+ setBlueprintSyncedDocument(null);
1124
+ }
1125
+ toast({ title: "蓝图已从库中删除" });
1126
+ },
1127
+ [activeBlueprintLibraryId, refreshBlueprintLibrary]
1128
+ );
1129
+
1130
+ const openBlueprintExportDialog = useCallback(() => {
1131
+ openBlueprintMetaDialog("export");
1132
+ }, [openBlueprintMetaDialog]);
1133
+
1134
+ const handleBlueprintImportFile = useCallback(
1135
+ async (file: File) => {
1136
+ const text = await file.text();
1137
+ const payload = parseBlueprintImportFile(JSON.parse(text) as unknown);
1138
+ const record = libraryRecordFromImport(payload);
1139
+ await putBlueprintLibraryRecord(record);
1140
+ await refreshBlueprintLibrary();
1141
+ if (!activeBlueprintLibraryId) {
1142
+ snapshotWorkspaceBlueprint();
1143
+ }
1144
+ await loadBlueprintFromLibrary(record.id);
1145
+ toast({ title: "蓝图已导入并加载" });
1146
+ },
1147
+ [
1148
+ activeBlueprintLibraryId,
1149
+ loadBlueprintFromLibrary,
1150
+ refreshBlueprintLibrary,
1151
+ snapshotWorkspaceBlueprint,
1152
+ ]
1153
+ );
1154
+
1155
+ useEffect(() => {
1156
+ if (blueprintOpen) return;
1157
+ stopAllClockSchedules();
1158
+ }, [blueprintOpen]);
1159
+
1160
+ const handleWorkspaceProjectApplied = useCallback(
1161
+ (record: WorkspaceProjectRecord) => {
1162
+ workspaceBlueprintRef.current = {
1163
+ document: record.blueprintDocument,
1164
+ meta: {
1165
+ name: record.blueprintMeta?.name ?? "未命名蓝图",
1166
+ remark: record.blueprintMeta?.remark ?? "",
1167
+ },
1168
+ };
1169
+ setActiveBlueprintLibraryId(null);
1170
+ setBlueprintSyncedDocument(null);
1171
+ setSelectedBlueprintNodeId(null);
1172
+ setBlueprintOpen(true);
1173
+ setConfigFocus("blueprint");
1174
+ },
1175
+ []
1176
+ );
1177
+
1178
+ const workspaceProjects = useWorkspaceProjects({
1179
+ exportPanelData,
1180
+ importPanelData,
1181
+ blueprintDocument: blueprintGraph.document,
1182
+ blueprintMeta,
1183
+ setBlueprintGraph,
1184
+ setBlueprintMeta,
1185
+ productName,
1186
+ setProductName,
1187
+ titleIconDataUrl,
1188
+ setTitleIconDataUrl,
1189
+ panelRevision: `${historyCursor}|${allElements.length}`,
1190
+ onProjectApplied: handleWorkspaceProjectApplied,
1191
+ });
1192
+
1193
+ useBlueprintPageLifecycle({
1194
+ graph: blueprintGraph,
1195
+ active: blueprintOpen,
1196
+ bootKey: workspaceProjects.activeProjectId ?? undefined,
1197
+ onUpdated: `${activeLayerId}|${historyCursor}`,
1198
+ resolveLibraryBlueprint,
1199
+ libraryNameById: blueprintLibraryNameById,
1200
+ rootLibraryBlueprintId: activeBlueprintLibraryId,
1201
+ onExecutionBlocked: handleBlueprintExecutionBlocked,
1202
+ onViewScopeUpdate: handleViewScopeUpdate,
1203
+ });
1204
+
1205
+ const handleWorkspaceCreateProject = useCallback(async () => {
1206
+ const result = await workspaceProjects.handleCreateProject();
1207
+ if (result?.name) {
1208
+ toast({ title: `已创建工作区「${result.name}」` });
1209
+ }
1210
+ return result;
1211
+ }, [workspaceProjects]);
1212
+
1213
+ const handleWorkspaceOpenProject = useCallback(
1214
+ async (id: string) => {
1215
+ try {
1216
+ clearViewElementScopes();
1217
+ await workspaceProjects.handleOpenProject(id);
1218
+ setSelectedIds([]);
1219
+ toast({
1220
+ title: "工作区已加载",
1221
+ description: "视图与蓝图已恢复",
1222
+ });
1223
+ } catch (error) {
1224
+ toast({
1225
+ title: error instanceof Error ? error.message : "打开工作区失败",
1226
+ });
1227
+ }
1228
+ },
1229
+ [workspaceProjects]
1230
+ );
1231
+
1232
+ const handleWorkspaceSyncProject = useCallback(async () => {
1233
+ try {
1234
+ const name = await workspaceProjects.handleSyncProject();
1235
+ if (name) {
1236
+ toast({ title: `已同步「${name}」` });
1237
+ }
1238
+ } catch (error) {
1239
+ toast({
1240
+ title: error instanceof Error ? error.message : "同步失败",
1241
+ });
1242
+ }
1243
+ }, [workspaceProjects]);
1244
+
1245
+ const handleWorkspaceDeleteProject = useCallback(
1246
+ async (id: string) => {
1247
+ await workspaceProjects.handleDeleteProject(id);
1248
+ toast({ title: "工作区已删除" });
1249
+ },
1250
+ [workspaceProjects]
1251
+ );
1252
+
1253
+ const openOnlinePreviewForProject = useCallback(
1254
+ async (projectId: string, options?: { syncFirst?: boolean }) => {
1255
+ try {
1256
+ await workspaceProjects.handlePreviewProject(projectId, options);
1257
+ } catch (error) {
1258
+ toast({
1259
+ title: error instanceof Error ? error.message : "打开预览失败",
1260
+ });
1261
+ }
1262
+ },
1263
+ [workspaceProjects]
1264
+ );
1265
+
1266
+ const selectedElement = selectedIds.length === 1 ? byId.get(selectedIds[0]) ?? null : null;
1267
+ const selectedElements = useMemo(
1268
+ () => selectedIds.map((id) => byId.get(id)).filter((el): el is PanelElement => !!el),
1269
+ [byId, selectedIds]
1270
+ );
1271
+ const selectedElementScope = useViewElementScope(selectedElement?.id ?? null);
1272
+ const scopeStoreVersion = useViewScopeStoreVersion();
1273
+ const scopedCanvasElements = useMemo(() => {
1274
+ void scopeStoreVersion;
1275
+ return elements.map((el) =>
1276
+ resolvePanelElementScope(el, getViewElementScope(el.id))
1277
+ );
1278
+ }, [elements, scopeStoreVersion]);
1279
+ const selectedNodeZOrderLabel = useMemo(() => {
1280
+ if (!selectedElement) return "-";
1281
+ return String(selectedElement.zIndex ?? 1);
1282
+ }, [selectedElement]);
1283
+ const layerById = useMemo(() => {
1284
+ const map = new Map<string, (typeof layers)[number]>();
1285
+ for (const layer of layers) map.set(layer.id, layer);
1286
+ return map;
1287
+ }, [layers]);
1288
+
1289
+ const isRightLikePointer = (e: React.PointerEvent<HTMLElement>) =>
1290
+ e.button === 2 || (e.button === 0 && e.ctrlKey);
1291
+ const hasSelection = selectedIds.length > 0;
1292
+ const hasUnlockedSelection = selectedIds.some((id) => {
1293
+ const el = byId.get(id);
1294
+ if (!el || el.locked) return false;
1295
+ const layer = layerById.get(el.layerId);
1296
+ return !layer?.locked;
1297
+ });
1298
+ const showActionHint = useCallback((message: string) => {
1299
+ toast({
1300
+ title: "操作受限",
1301
+ description: message,
1302
+ });
1303
+ }, []);
1304
+ const hintUnlockNodeForDelete = useCallback(() => {
1305
+ showActionHint(PANEL_MESSAGES.nodeDeleteLocked);
1306
+ }, [showActionHint]);
1307
+ const hintUnlockLayerForDelete = useCallback(() => {
1308
+ showActionHint(PANEL_MESSAGES.nodeDeleteLayerLocked);
1309
+ }, [showActionHint]);
1310
+ const hintLockedNodesInBatchDelete = useCallback(() => {
1311
+ showActionHint(PANEL_MESSAGES.nodeBatchDeleteContainsLocked);
1312
+ }, [showActionHint]);
1313
+ const hintNodeNotFound = useCallback(() => {
1314
+ showActionHint(PANEL_MESSAGES.nodeNotFound);
1315
+ }, [showActionHint]);
1316
+ const hintUnlockNodeForMove = useCallback(() => {
1317
+ showActionHint(PANEL_MESSAGES.nodeMoveLocked);
1318
+ }, [showActionHint]);
1319
+ const hintUnlockSourceLayerForMove = useCallback(() => {
1320
+ showActionHint(PANEL_MESSAGES.nodeMoveSourceLayerLocked);
1321
+ }, [showActionHint]);
1322
+ const hintTargetLayerNotFound = useCallback(() => {
1323
+ showActionHint(PANEL_MESSAGES.targetLayerNotFound);
1324
+ }, [showActionHint]);
1325
+ const hintUnlockTargetLayerForMove = useCallback(() => {
1326
+ showActionHint(PANEL_MESSAGES.nodeMoveTargetLayerLocked);
1327
+ }, [showActionHint]);
1328
+ const getLayerDeleteBlockReason = useCallback(
1329
+ (layerId: string) => {
1330
+ const targetLayer = layerById.get(layerId);
1331
+ if (!targetLayer) return "图层不存在";
1332
+ if (!targetLayer.editable) return "默认图层不可删除";
1333
+ if (targetLayer.locked) return "锁定图层不可删除";
1334
+ if (targetLayer.isMapping) return null;
1335
+ const hasBlockingRef = allElements.some((el) => {
1336
+ if (el.layerId === layerId) return false;
1337
+ if (el.materialType !== "reference") return false;
1338
+ if (el.refLayerId !== layerId) return false;
1339
+ return (el.refCopyMode ?? "shallow") !== "deep";
1340
+ });
1341
+ if (hasBlockingRef) return "该图层仍被浅拷贝引用,请先删除引用节点或改为深拷贝";
1342
+ return null;
1343
+ },
1344
+ [allElements, layerById]
1345
+ );
1346
+ const requestDeleteWithMappingImpact = useCallback(
1347
+ (nodeIds: string[], onConfirm: () => void) => {
1348
+ const sourceIds = new Set(nodeIds.filter(Boolean));
1349
+ if (sourceIds.size === 0) {
1350
+ onConfirm();
1351
+ return;
1352
+ }
1353
+ const impactedLayerIds = new Set<string>();
1354
+ allElements.forEach((el) => {
1355
+ if (!el.mappingSourceNodeId) return;
1356
+ if (!sourceIds.has(el.mappingSourceNodeId)) return;
1357
+ impactedLayerIds.add(el.layerId);
1358
+ });
1359
+ if (impactedLayerIds.size === 0) {
1360
+ onConfirm();
1361
+ return;
1362
+ }
1363
+ mappingDeleteProceedRef.current = onConfirm;
1364
+ setMappingDeleteImpactCount(impactedLayerIds.size);
1365
+ setMappingDeleteConfirmOpen(true);
1366
+ },
1367
+ [allElements]
1368
+ );
1369
+
1370
+ const handleExport = useCallback(() => {
1371
+ const data = exportPanelData();
1372
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
1373
+ type: "application/json",
1374
+ });
1375
+ const url = URL.createObjectURL(blob);
1376
+ const a = document.createElement("a");
1377
+ a.href = url;
1378
+ const safeName = (productName.trim() || "panel")
1379
+ .replace(/[\\/:*?"<>|]/g, "-")
1380
+ .replace(/\s+/g, "-");
1381
+ a.download = `${safeName}-${Date.now()}.json`;
1382
+ a.click();
1383
+ URL.revokeObjectURL(url);
1384
+ }, [exportPanelData, productName]);
1385
+
1386
+ const handleImportFile = useCallback(
1387
+ async (file: File) => {
1388
+ const text = await file.text();
1389
+ const parsed = JSON.parse(text) as State;
1390
+ const ok = importPanelData(parsed);
1391
+ if (!ok) {
1392
+ window.alert("导入失败:文件格式不正确");
1393
+ } else {
1394
+ setSelectedIds([]);
1395
+ }
1396
+ },
1397
+ [importPanelData]
1398
+ );
1399
+
1400
+ const normalizeTitleIconFile = useCallback((file: File) => {
1401
+ return new Promise<string>((resolve, reject) => {
1402
+ const reader = new FileReader();
1403
+ reader.onerror = () => reject(new Error("read-failed"));
1404
+ reader.onload = () => {
1405
+ const src = typeof reader.result === "string" ? reader.result : "";
1406
+ if (!src) {
1407
+ reject(new Error("empty-data"));
1408
+ return;
1409
+ }
1410
+ const img = new Image();
1411
+ img.onload = () => {
1412
+ const size = 64;
1413
+ const canvas = document.createElement("canvas");
1414
+ canvas.width = size;
1415
+ canvas.height = size;
1416
+ const ctx = canvas.getContext("2d");
1417
+ if (!ctx) {
1418
+ reject(new Error("canvas-context-failed"));
1419
+ return;
1420
+ }
1421
+ ctx.clearRect(0, 0, size, size);
1422
+ // 保持长宽比居中绘制,统一导出 PNG,提升 favicon 兼容性
1423
+ const scale = Math.min(size / img.width, size / img.height);
1424
+ const drawW = img.width * scale;
1425
+ const drawH = img.height * scale;
1426
+ const dx = (size - drawW) / 2;
1427
+ const dy = (size - drawH) / 2;
1428
+ ctx.drawImage(img, dx, dy, drawW, drawH);
1429
+ resolve(canvas.toDataURL("image/png"));
1430
+ };
1431
+ img.onerror = () => reject(new Error("image-decode-failed"));
1432
+ img.src = src;
1433
+ };
1434
+ reader.readAsDataURL(file);
1435
+ });
1436
+ }, []);
1437
+
1438
+ const buildPreviewForLayer = useCallback((targetLayerId: string, retryCount = 0) => {
1439
+ const targetLayer = layers.find((l) => l.id === targetLayerId);
1440
+ const layerElements = allElements.filter((el) => el.layerId === targetLayerId);
1441
+ const getAABB = (el: PanelElement) => {
1442
+ const w = Math.max(1, el.width);
1443
+ const h = Math.max(1, el.height);
1444
+ const rad = ((el.rotate ?? 0) * Math.PI) / 180;
1445
+ const absCos = Math.abs(Math.cos(rad));
1446
+ const absSin = Math.abs(Math.sin(rad));
1447
+ const bw = w * absCos + h * absSin;
1448
+ const bh = w * absSin + h * absCos;
1449
+ const cx = el.x + w / 2;
1450
+ const cy = el.y + h / 2;
1451
+ return {
1452
+ left: cx - bw / 2,
1453
+ top: cy - bh / 2,
1454
+ right: cx + bw / 2,
1455
+ bottom: cy + bh / 2,
1456
+ };
1457
+ };
1458
+ const fallbackBoxes = layerElements.map(getAABB);
1459
+ // 使用节点几何边界(含旋转)作为统一坐标系,避免视口缩放/平移导致预览范围偏移
1460
+ const minX = fallbackBoxes.length ? Math.min(...fallbackBoxes.map((b) => b.left)) : 0;
1461
+ const minY = fallbackBoxes.length ? Math.min(...fallbackBoxes.map((b) => b.top)) : 0;
1462
+ const maxX = fallbackBoxes.length ? Math.max(...fallbackBoxes.map((b) => b.right)) : 1;
1463
+ const maxY = fallbackBoxes.length ? Math.max(...fallbackBoxes.map((b) => b.bottom)) : 1;
1464
+ const sceneWidth = Math.max(1, maxX - minX);
1465
+ const sceneHeight = Math.max(1, maxY - minY);
1466
+ const serializeNodeDom = (
1467
+ sourceNode: HTMLElement,
1468
+ sourceElement: PanelElement,
1469
+ isChartNode: boolean
1470
+ ): string => {
1471
+ const clone = sourceNode.cloneNode(true) as HTMLElement;
1472
+ // 去掉编辑态选中 ring 等视觉痕迹,避免预览出现 Moveable/编辑辅助效果
1473
+ clone.className = clone.className
1474
+ .split(/\s+/)
1475
+ .filter((cls) => cls && !cls.startsWith("ring-"))
1476
+ .join(" ");
1477
+ clone.removeAttribute("data-moveable-target");
1478
+ // 以整体包围盒左上角为 (0,0) 做归一化,保证“整块内容”完整进入预览
1479
+ clone.style.left = `${sourceElement.x - minX}px`;
1480
+ clone.style.top = `${sourceElement.y - minY}px`;
1481
+ const sourceCanvases = Array.from(sourceNode.querySelectorAll("canvas"));
1482
+ const cloneCanvases = Array.from(clone.querySelectorAll("canvas"));
1483
+ if (isChartNode) return clone.outerHTML;
1484
+ sourceCanvases.forEach((srcCanvas, idx) => {
1485
+ const clonedCanvas = cloneCanvases[idx];
1486
+ if (!clonedCanvas) return;
1487
+ try {
1488
+ const dataUrl = srcCanvas.toDataURL("image/png");
1489
+ const img = document.createElement("img");
1490
+ img.src = dataUrl;
1491
+ img.style.width = "100%";
1492
+ img.style.height = "100%";
1493
+ img.style.objectFit = "fill";
1494
+ img.style.display = "block";
1495
+ clonedCanvas.replaceWith(img);
1496
+ } catch {
1497
+ // ignore tainted canvas
1498
+ }
1499
+ });
1500
+ return clone.outerHTML;
1501
+ };
1502
+
1503
+ const mountedNodes = layerElements.map((el) => ({
1504
+ el,
1505
+ node: canvasRef.current?.querySelector<HTMLElement>(`[data-element-id="${el.id}"]`) ?? null,
1506
+ }));
1507
+ const mountedCount = mountedNodes.filter((item) => !!item.node).length;
1508
+ const previewNodesHtml = mountedNodes
1509
+ .map((item) => {
1510
+ if (!item.node) return "";
1511
+ return serializeNodeDom(item.node, item.el, CHART_TYPES.has(item.el.materialType ?? ""));
1512
+ })
1513
+ .filter(Boolean)
1514
+ .join("");
1515
+ if (mountedCount < layerElements.length && layerElements.length > 0 && retryCount < 20) {
1516
+ window.setTimeout(() => {
1517
+ buildPreviewForLayer(targetLayerId, retryCount + 1);
1518
+ }, 80);
1519
+ return;
1520
+ }
1521
+ if (!previewNodesHtml && layerElements.length > 0 && retryCount < 20) {
1522
+ window.setTimeout(() => {
1523
+ buildPreviewForLayer(targetLayerId, retryCount + 1);
1524
+ }, 80);
1525
+ return;
1526
+ }
1527
+ const previewMeta = JSON.stringify({
1528
+ gridNodeIds: layerElements.filter((el) => el.materialType === "grid").map((el) => el.id),
1529
+ chartNodes: layerElements
1530
+ .filter((el) => CHART_TYPES.has(el.materialType ?? ""))
1531
+ .map((el) => ({
1532
+ id: el.id,
1533
+ renderer: el.chart?.renderMode ?? "canvas",
1534
+ option: buildChartOption(el),
1535
+ })),
1536
+ }).replace(/<\//g, "<\\/");
1537
+
1538
+ const headStyles = Array.from(
1539
+ document.querySelectorAll('style, link[rel="stylesheet"]')
1540
+ )
1541
+ .map((el) => el.outerHTML)
1542
+ .join("\n");
1543
+ const faviconHrefWithVersion = titleIconDataUrl
1544
+ ? titleIconDataUrl.startsWith("data:")
1545
+ ? titleIconDataUrl
1546
+ : `${titleIconDataUrl}${titleIconDataUrl.includes("?") ? "&" : "?"}v=${Date.now()}`
1547
+ : "";
1548
+ const previewFaviconLink = faviconHrefWithVersion
1549
+ ? `<link rel="icon" type="image/png" href="${faviconHrefWithVersion}" /><link rel="shortcut icon" type="image/png" href="${faviconHrefWithVersion}" />`
1550
+ : "";
1551
+ const previewTitle = productName.trim() || "未命名产物";
1552
+ const escapedPreviewTitle = previewTitle
1553
+ .replace(/&/g, "&amp;")
1554
+ .replace(/</g, "&lt;")
1555
+ .replace(/>/g, "&gt;")
1556
+ .replace(/"/g, "&quot;")
1557
+ .replace(/'/g, "&#39;");
1558
+
1559
+ const faviconJson = JSON.stringify(faviconHrefWithVersion || "");
1560
+ const titleJson = JSON.stringify(previewTitle);
1561
+ const html = `<!doctype html>
1562
+ <html>
1563
+ <head>
1564
+ <meta charset="utf-8"/>
1565
+ <title>${escapedPreviewTitle}</title>
1566
+ ${headStyles}
1567
+ ${previewFaviconLink}
1568
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
1569
+ </head>
1570
+ <body style="margin:0;background:#fff;overflow:hidden;">
1571
+ <div id="preview-root" style="position:fixed;left:0;top:0;width:100vw;height:100vh;overflow:hidden;">
1572
+ <div id="preview-scene" style="position:absolute;left:0;top:0;width:${sceneWidth}px;height:${sceneHeight}px;transform-origin:left top;">
1573
+ ${previewNodesHtml}
1574
+ </div>
1575
+ </div>
1576
+ <script id="preview-meta" type="application/json">${previewMeta}</script>
1577
+ <script>
1578
+ (function () {
1579
+ var faviconHref = ${faviconJson};
1580
+ var previewTitle = ${titleJson};
1581
+ if (previewTitle) {
1582
+ document.title = previewTitle;
1583
+ }
1584
+ if (faviconHref) {
1585
+ var rels = ["icon", "shortcut icon"];
1586
+ rels.forEach(function (rel) {
1587
+ var existing = document.querySelector("link[rel='" + rel + "']");
1588
+ if (existing && existing.parentNode) {
1589
+ existing.parentNode.removeChild(existing);
1590
+ }
1591
+ var link = document.createElement("link");
1592
+ link.rel = rel;
1593
+ link.type = "image/png";
1594
+ link.href = faviconHref;
1595
+ document.head.appendChild(link);
1596
+ });
1597
+ }
1598
+ var scene = document.getElementById("preview-scene");
1599
+ function fitScene() {
1600
+ if (!scene) return;
1601
+ var vw = window.innerWidth || 1;
1602
+ var vh = window.innerHeight || 1;
1603
+ var sw = ${sceneWidth} || 1;
1604
+ var sh = ${sceneHeight} || 1;
1605
+ // 铺满预览窗口:宽高分别缩放,保证内容贴住四边
1606
+ var scaleX = vw / sw;
1607
+ var scaleY = vh / sh;
1608
+ scene.style.transform = "translate(0px,0px) scale(" + scaleX + "," + scaleY + ")";
1609
+ }
1610
+ fitScene();
1611
+ window.addEventListener("resize", fitScene);
1612
+ var metaRaw = document.getElementById("preview-meta");
1613
+ var meta = {};
1614
+ try { meta = JSON.parse((metaRaw && metaRaw.textContent) || "{}"); } catch (e) {}
1615
+ var gridNodeIds = Array.isArray(meta.gridNodeIds) ? meta.gridNodeIds : [];
1616
+ var chartNodes = Array.isArray(meta.chartNodes) ? meta.chartNodes : [];
1617
+ gridNodeIds.forEach(function (id) {
1618
+ var gridNode = document.querySelector("[data-element-id='" + id + "']");
1619
+ if (!gridNode) return;
1620
+ var content = gridNode.firstElementChild;
1621
+ if (content) {
1622
+ // 预览中隐藏网格编辑态占位格,仅保留内部真实节点呈现
1623
+ content.innerHTML = "";
1624
+ content.removeAttribute("style");
1625
+ }
1626
+ });
1627
+ chartNodes.forEach(function (n) {
1628
+ var host = document.querySelector("[data-element-id='" + n.id + "'] > div");
1629
+ if (!host || !window.echarts) return;
1630
+ host.innerHTML = "";
1631
+ var chart = window.echarts.init(host, null, { renderer: n.renderer || "canvas" });
1632
+ chart.setOption(n.option || {}, true);
1633
+ chart.resize();
1634
+ window.addEventListener("resize", function () { chart.resize(); });
1635
+ });
1636
+ })();
1637
+ </script>
1638
+ </body>
1639
+ </html>`;
1640
+ const win = window.open("", "_blank");
1641
+ if (!win) return;
1642
+ win.document.write(html);
1643
+ win.document.close();
1644
+ win.document.title = previewTitle;
1645
+ if (faviconHrefWithVersion) {
1646
+ ["icon", "shortcut icon"].forEach((rel) => {
1647
+ const existing = win.document.querySelector(`link[rel='${rel}']`);
1648
+ if (existing?.parentNode) existing.parentNode.removeChild(existing);
1649
+ const link = win.document.createElement("link");
1650
+ link.rel = rel;
1651
+ link.type = "image/png";
1652
+ link.href = faviconHrefWithVersion;
1653
+ win.document.head.appendChild(link);
1654
+ });
1655
+ }
1656
+ }, [allElements, layers, productName, titleIconDataUrl]);
1657
+
1658
+ const handlePreviewLayer = useCallback(() => {
1659
+ void (async () => {
1660
+ try {
1661
+ let projectId = workspaceProjects.activeProjectId;
1662
+ if (!projectId) {
1663
+ const created = await handleWorkspaceCreateProject();
1664
+ projectId = created?.id ?? null;
1665
+ } else if (workspaceProjects.dirty) {
1666
+ await handleWorkspaceSyncProject();
1667
+ }
1668
+ if (projectId) {
1669
+ await openOnlinePreviewForProject(projectId);
1670
+ }
1671
+ } catch (error) {
1672
+ toast({
1673
+ title: error instanceof Error ? error.message : "打开预览失败",
1674
+ });
1675
+ }
1676
+ })();
1677
+ }, [
1678
+ handleWorkspaceCreateProject,
1679
+ handleWorkspaceSyncProject,
1680
+ openOnlinePreviewForProject,
1681
+ workspaceProjects.activeProjectId,
1682
+ workspaceProjects.dirty,
1683
+ ]);
1684
+
1685
+ useEffect(() => {
1686
+ if (!pendingPreviewLayerId) return;
1687
+ if (activeLayerId !== pendingPreviewLayerId) return;
1688
+ let cancelled = false;
1689
+ window.requestAnimationFrame(() => {
1690
+ window.requestAnimationFrame(() => {
1691
+ if (cancelled) return;
1692
+ buildPreviewForLayer(pendingPreviewLayerId);
1693
+ setPendingPreviewLayerId(null);
1694
+ });
1695
+ });
1696
+ return () => {
1697
+ cancelled = true;
1698
+ };
1699
+ }, [activeLayerId, buildPreviewForLayer, pendingPreviewLayerId]);
1700
+
1701
+ return (
1702
+ <div
1703
+ ref={panelRootRef}
1704
+ className={[
1705
+ "relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-background text-foreground",
1706
+ className ?? "",
1707
+ ].join(" ")}
1708
+ style={{ ["--panel-font-px" as string]: `${panelFontPx}px` }}
1709
+ >
1710
+ <TooltipProvider delayDuration={120}>
1711
+ <Tooltip open={ellipsisTooltip.open}>
1712
+ <TooltipTrigger asChild>
1713
+ <span
1714
+ aria-hidden="true"
1715
+ className="pointer-events-none fixed z-[10000] h-0 w-0"
1716
+ style={{ left: ellipsisTooltip.x, top: ellipsisTooltip.y }}
1717
+ />
1718
+ </TooltipTrigger>
1719
+ <TooltipContent side="top" align="start" className="max-w-[420px] break-words text-[11px]">
1720
+ {ellipsisTooltip.text}
1721
+ </TooltipContent>
1722
+ </Tooltip>
1723
+ </TooltipProvider>
1724
+ <style>{`
1725
+ .panel-font-root :is(span, label, p, strong, button, input, textarea, select, option, a, li) {
1726
+ font-size: var(--panel-font-px) !important;
1727
+ }
1728
+ .panel-font-root :is(input, textarea):focus,
1729
+ .panel-font-root :is(input, textarea):focus-visible {
1730
+ outline: none !important;
1731
+ box-shadow: none !important;
1732
+ }
1733
+ .panel-font-root button[role="combobox"] {
1734
+ border-width: 1px !important;
1735
+ border-color: hsl(var(--foreground) / 0.35) !important;
1736
+ background: hsl(var(--background)) !important;
1737
+ box-shadow: 0 0 0 1px hsl(var(--foreground) / 0.12) inset !important;
1738
+ }
1739
+ .panel-font-root button[role="combobox"]:focus,
1740
+ .panel-font-root button[role="combobox"]:focus-visible,
1741
+ .panel-font-root button[role="combobox"][data-state="open"] {
1742
+ outline: none !important;
1743
+ box-shadow: 0 0 0 1px hsl(var(--foreground) / 0.12) inset !important;
1744
+ border-color: hsl(var(--foreground) / 0.35) !important;
1745
+ }
1746
+ .panel-font-root button[role="checkbox"] {
1747
+ border-width: 2px !important;
1748
+ border-color: hsl(var(--foreground) / 0.8) !important;
1749
+ background: hsl(var(--background)) !important;
1750
+ color: hsl(var(--background)) !important;
1751
+ box-shadow: 0 0 0 1px hsl(var(--foreground) / 0.4) inset !important;
1752
+ }
1753
+ .panel-font-root button[role="checkbox"][data-state="checked"] {
1754
+ border-color: hsl(var(--primary)) !important;
1755
+ background: hsl(var(--primary)) !important;
1756
+ color: hsl(var(--primary-foreground)) !important;
1757
+ box-shadow: 0 0 0 1px hsl(var(--primary) / 0.45) inset !important;
1758
+ }
1759
+ `}</style>
1760
+ <nav className="panel-font-root relative z-30 flex items-center gap-2 border-b border-border bg-background/95 px-2 text-foreground">
1761
+ <Menubar className="h-8 border-0 bg-transparent p-0 shadow-none">
1762
+ <MenubarMenu>
1763
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">文件</MenubarTrigger>
1764
+ <MenubarContent className="z-[10100]">
1765
+ {/* <MenubarItem onClick={handlePreviewLayer}>预览</MenubarItem> */}
1766
+ <MenubarItem onClick={handleExport}>导出</MenubarItem>
1767
+ <MenubarItem onClick={() => importInputRef.current?.click()}>导入</MenubarItem>
1768
+ </MenubarContent>
1769
+ </MenubarMenu>
1770
+ <MenubarMenu>
1771
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">编辑</MenubarTrigger>
1772
+ <MenubarContent className="z-[10100]">
1773
+ <MenubarItem disabled={!canUndo} onClick={undo}>撤销</MenubarItem>
1774
+ <MenubarItem disabled={!canRedo} onClick={redo}>重做</MenubarItem>
1775
+ <MenubarSeparator />
1776
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsForward(selectedIds)}>上移一层</MenubarItem>
1777
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsBackward(selectedIds)}>下移一层</MenubarItem>
1778
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsToFront(selectedIds)}>置顶</MenubarItem>
1779
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsToBack(selectedIds)}>置底</MenubarItem>
1780
+ </MenubarContent>
1781
+ </MenubarMenu>
1782
+ <MenubarMenu>
1783
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">视图</MenubarTrigger>
1784
+ <MenubarContent className="z-[10100]">
1785
+ <MenubarItem onClick={() => adjustUniformZoom((z) => z - 0.1)}>缩小</MenubarItem>
1786
+ <MenubarItem onClick={() => adjustUniformZoom((z) => z + 0.1)}>放大</MenubarItem>
1787
+ <MenubarSeparator />
1788
+ <MenubarItem
1789
+ onClick={() => {
1790
+ applyTheme(!isDark);
1791
+ }}
1792
+ >
1793
+ {isDark ? "切换到浅色" : "切换到深色"}
1794
+ </MenubarItem>
1795
+ </MenubarContent>
1796
+ </MenubarMenu>
1797
+ <MenubarMenu>
1798
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">蓝图</MenubarTrigger>
1799
+ <MenubarContent className="z-[10100]">
1800
+ <MenubarItem onClick={openBlueprintExportDialog}>导出</MenubarItem>
1801
+ <MenubarItem onClick={() => blueprintImportInputRef.current?.click()}>
1802
+ 导入
1803
+ </MenubarItem>
1804
+ </MenubarContent>
1805
+ </MenubarMenu>
1806
+ <MenubarMenu>
1807
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">设置</MenubarTrigger>
1808
+ <MenubarContent className="z-[10100]">
1809
+ <MenubarRadioGroup
1810
+ value={panelFontSize}
1811
+ onValueChange={(value) => setPanelFontSize(value as "sm" | "md" | "lg")}
1812
+ >
1813
+ <MenubarRadioItem value="sm">字体:小</MenubarRadioItem>
1814
+ <MenubarRadioItem value="md">字体:中</MenubarRadioItem>
1815
+ <MenubarRadioItem value="lg">字体:大</MenubarRadioItem>
1816
+ </MenubarRadioGroup>
1817
+ </MenubarContent>
1818
+ </MenubarMenu>
1819
+ </Menubar>
1820
+ <WorkspaceProjectNav
1821
+ projects={workspaceProjects.projects}
1822
+ activeProjectId={workspaceProjects.activeProjectId}
1823
+ activeProjectName={workspaceProjects.activeProjectName}
1824
+ dirty={workspaceProjects.dirty}
1825
+ onCreateProject={handleWorkspaceCreateProject}
1826
+ onOpenProject={handleWorkspaceOpenProject}
1827
+ onSyncProject={handleWorkspaceSyncProject}
1828
+ onDeleteProject={handleWorkspaceDeleteProject}
1829
+ onPreviewProject={openOnlinePreviewForProject}
1830
+ />
1831
+ <div className="flex-1" />
1832
+ </nav>
1833
+ <ResizablePanelGroup direction="horizontal" className="min-h-0 w-full flex-1">
1834
+ <ResizablePanel defaultSize={15} minSize={15}>
1835
+ <div className="panel-font-root h-full">
1836
+ <MaterialSidebar
1837
+ layers={layers}
1838
+ allElements={allElements}
1839
+ selectedIds={selectedIds}
1840
+ onSelectNode={(nodeId, layerId) => {
1841
+ if (activeLayerId !== layerId) setActiveLayer(layerId);
1842
+ selectViewElements([nodeId]);
1843
+ }}
1844
+ onNodeContextMenu={({ nodeId, x, y }) => {
1845
+ setDropdownOpen(false);
1846
+ requestAnimationFrame(() => {
1847
+ setContextMenuNodeId(nodeId);
1848
+ setDropdownPoint({ x, y });
1849
+ setDropdownEpoch((v) => v + 1);
1850
+ setDropdownOpen(true);
1851
+ });
1852
+ }}
1853
+ onCopyNode={(nodeId, mode) => {
1854
+ duplicateElement(nodeId, { referenceCopyMode: mode });
1855
+ }}
1856
+ onDeleteNode={(nodeId) => {
1857
+ const node = byId.get(nodeId);
1858
+ if (!node) {
1859
+ hintNodeNotFound();
1860
+ return;
1861
+ }
1862
+ if (node.locked) {
1863
+ hintUnlockNodeForDelete();
1864
+ return;
1865
+ }
1866
+ if (layerById.get(node.layerId)?.locked) {
1867
+ hintUnlockLayerForDelete();
1868
+ return;
1869
+ }
1870
+ requestDeleteWithMappingImpact([nodeId], () => {
1871
+ deleteElement(nodeId);
1872
+ setSelectedIds((prev) => prev.filter((id) => id !== nodeId));
1873
+ });
1874
+ }}
1875
+ onMoveNodeToLayer={(nodeId, targetLayerId) => {
1876
+ const node = byId.get(nodeId);
1877
+ if (!node) {
1878
+ hintNodeNotFound();
1879
+ return;
1880
+ }
1881
+ if (node.locked) {
1882
+ hintUnlockNodeForMove();
1883
+ return;
1884
+ }
1885
+ if (node.layerId === targetLayerId) return;
1886
+ const sourceLayer = layerById.get(node.layerId);
1887
+ if (sourceLayer?.locked) {
1888
+ hintUnlockSourceLayerForMove();
1889
+ return;
1890
+ }
1891
+ const targetLayer = layerById.get(targetLayerId);
1892
+ if (!targetLayer) {
1893
+ hintTargetLayerNotFound();
1894
+ return;
1895
+ }
1896
+ if (targetLayer.locked) {
1897
+ hintUnlockTargetLayerForMove();
1898
+ return;
1899
+ }
1900
+ updateElement(nodeId, { layerId: targetLayerId });
1901
+ if (activeLayerId !== targetLayerId) setActiveLayer(targetLayerId);
1902
+ }}
1903
+ />
1904
+ </div>
1905
+ </ResizablePanel>
1906
+ <ResizableHandle withHandle />
1907
+ <ResizablePanel defaultSize={65} minSize={10}>
1908
+ {/* Center workspace */}
1909
+ <div
1910
+ className="panel-font-root min-w-0 h-full"
1911
+ onMouseDownCapture={(e) => {
1912
+ if (e.button !== 0) return;
1913
+ const target = e.target as HTMLElement | null;
1914
+ if (
1915
+ target?.closest(
1916
+ "[data-workspace-region='blueprint'], [data-blueprint-toggle], [data-blueprint-node-card]"
1917
+ )
1918
+ ) {
1919
+ return;
1920
+ }
1921
+ if (!shouldClearSelectionOnBlank(target)) return;
1922
+ clearSelection();
1923
+ }}
1924
+ >
1925
+ <div className="relative flex h-full flex-col overflow-hidden border border-border bg-background">
1926
+ <div
1927
+ className="relative flex items-center gap-2 border-b border-border bg-background/90 px-3 py-1.5 text-foreground"
1928
+ style={{ zIndex: PANEL_Z_INDEX.toolbar }}
1929
+ >
1930
+ <Input
1931
+ value={productName}
1932
+ onChange={(e) => setProductName(e.target.value)}
1933
+ placeholder="请输入当前构建产物名称"
1934
+ className="h-7 w-[220px] text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none"
1935
+ />
1936
+ <span className="text-[11px] text-muted-foreground">产物名称</span>
1937
+ <TooltipProvider delayDuration={150}>
1938
+ <Tooltip>
1939
+ <TooltipTrigger asChild>
1940
+ <button
1941
+ type="button"
1942
+ onClick={() => titleIconInputRef.current?.click()}
1943
+ className="rounded border border-border p-1 hover:bg-accent"
1944
+ aria-label="上传 title 图标"
1945
+ >
1946
+ <IconImage />
1947
+ </button>
1948
+ </TooltipTrigger>
1949
+ <TooltipContent className="z-[10000]">上传 title 图标</TooltipContent>
1950
+ </Tooltip>
1951
+ {titleIconDataUrl ? (
1952
+ <Tooltip>
1953
+ <TooltipTrigger asChild>
1954
+ <button
1955
+ type="button"
1956
+ onClick={() => setTitleIconDataUrl("")}
1957
+ className="rounded border border-border p-1 hover:bg-accent"
1958
+ aria-label="清除 title 图标"
1959
+ >
1960
+ <IconClose />
1961
+ </button>
1962
+ </TooltipTrigger>
1963
+ <TooltipContent className="z-[10000]">清除 title 图标</TooltipContent>
1964
+ </Tooltip>
1965
+ ) : null}
1966
+ {titleIconDataUrl ? (
1967
+ <Tooltip>
1968
+ <TooltipTrigger asChild>
1969
+ <button
1970
+ type="button"
1971
+ onClick={() => setTitleIconPreviewOpen(true)}
1972
+ className="h-7 shrink-0 flex min-w-[92px] items-center gap-1.5 rounded border border-border bg-muted/30 px-2 py-1 hover:bg-accent/40"
1973
+ aria-label="查看 title 图标大图"
1974
+ >
1975
+ <img
1976
+ src={titleIconDataUrl}
1977
+ alt="title 图标缩略图"
1978
+ className="h-5 w-5 shrink-0 rounded border border-border/60 object-cover"
1979
+ />
1980
+ <span className="text-[10px] text-muted-foreground">Title Icon</span>
1981
+ </button>
1982
+ </TooltipTrigger>
1983
+ <TooltipContent className="z-[10000]">点击查看 title 图标大图</TooltipContent>
1984
+ </Tooltip>
1985
+ ) : null}
1986
+ <Tooltip>
1987
+ <TooltipTrigger asChild>
1988
+ <button
1989
+ type="button"
1990
+ onClick={undo}
1991
+ disabled={!canUndo}
1992
+ className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
1993
+ aria-label="撤销"
1994
+ >
1995
+ <IconUndo />
1996
+ </button>
1997
+ </TooltipTrigger>
1998
+ <TooltipContent className="z-[10000]">撤销(Cmd/Ctrl + Z)</TooltipContent>
1999
+ </Tooltip>
2000
+ <Tooltip>
2001
+ <TooltipTrigger asChild>
2002
+ <button
2003
+ type="button"
2004
+ onClick={redo}
2005
+ disabled={!canRedo}
2006
+ className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2007
+ aria-label="重做"
2008
+ >
2009
+ <IconRedo />
2010
+ </button>
2011
+ </TooltipTrigger>
2012
+ <TooltipContent className="z-[10000]">重做(Cmd/Ctrl + Shift + Z)</TooltipContent>
2013
+ </Tooltip>
2014
+ <Tooltip>
2015
+ <TooltipTrigger asChild>
2016
+ <div className="relative">
2017
+ <button
2018
+ type="button"
2019
+ onClick={() => setIsHistoryPanelExpanded((prev) => !prev)}
2020
+ className={[
2021
+ "rounded border border-border p-1 hover:bg-accent",
2022
+ isHistoryPanelExpanded ? "bg-accent/60" : "",
2023
+ ].join(" ")}
2024
+ aria-label={isHistoryPanelExpanded ? "收起操作历史" : "展开操作历史"}
2025
+ >
2026
+ <IconHistory />
2027
+ </button>
2028
+ {isHistoryPanelExpanded ? (
2029
+ <div
2030
+ className="absolute left-1/2 top-[calc(100%+6px)] w-[280px] -translate-x-1/2 rounded-lg border border-border bg-card/95 p-2 shadow-lg backdrop-blur"
2031
+ style={{ zIndex: PANEL_Z_INDEX.historyPopover }}
2032
+ >
2033
+ <div className="mb-1 flex items-center justify-between">
2034
+ <span className="text-[11px] font-semibold text-muted-foreground">操作历史</span>
2035
+ <span className="text-[11px] text-muted-foreground">
2036
+ {history.length > 0 ? `${historyCursor + 1}/${history.length}` : "0/0"}
2037
+ </span>
2038
+ </div>
2039
+ <Input
2040
+ value={historyKeyword}
2041
+ onChange={(e) => setHistoryKeyword(e.target.value)}
2042
+ placeholder="搜索历史,如:删除、图层、缩放..."
2043
+ className="mb-2 h-7 text-xs"
2044
+ />
2045
+ <div
2046
+ className={`max-h-52 space-y-1 overflow-auto pr-1 text-[11px] ${themedScrollbarClass}`}
2047
+ >
2048
+ {history.length === 0 ? (
2049
+ <Empty className="py-4">
2050
+ <EmptyIcon className="h-8 w-8">
2051
+ <IconHistory />
2052
+ </EmptyIcon>
2053
+ <EmptyTitle className="text-xs">暂无操作历史</EmptyTitle>
2054
+ <EmptyDescription className="text-[11px]">
2055
+ 进行一次操作后,这里会展示历史记录。
2056
+ </EmptyDescription>
2057
+ </Empty>
2058
+ ) : (
2059
+ history
2060
+ .slice(Math.max(0, history.length - 20))
2061
+ .reverse()
2062
+ .filter((item) =>
2063
+ !normalizedHistoryKeyword
2064
+ ? true
2065
+ : item.label.toLowerCase().includes(normalizedHistoryKeyword)
2066
+ )
2067
+ .map((item) => (
2068
+ <button
2069
+ key={`${item.index}-${item.timestamp}`}
2070
+ type="button"
2071
+ className={[
2072
+ "w-full rounded border px-2 py-1 text-left transition-colors",
2073
+ item.active
2074
+ ? "border-primary/50 bg-primary/10 text-foreground"
2075
+ : "border-border/60 bg-background/70 text-muted-foreground hover:bg-accent/50",
2076
+ ].join(" ")}
2077
+ title={new Date(item.timestamp).toLocaleString()}
2078
+ onClick={() => {
2079
+ if (item.active) return;
2080
+ goToHistory(item.index);
2081
+ }}
2082
+ >
2083
+ <div className="flex items-center justify-between gap-2">
2084
+ <span className="truncate">{item.label}</span>
2085
+ <span className="shrink-0 text-[10px] text-muted-foreground">
2086
+ {formatRelativeTime(item.timestamp, historyNow)}
2087
+ </span>
2088
+ </div>
2089
+ </button>
2090
+ ))
2091
+ )}
2092
+ {history.length > 0 &&
2093
+ history
2094
+ .slice(Math.max(0, history.length - 20))
2095
+ .reverse()
2096
+ .filter((item) =>
2097
+ !normalizedHistoryKeyword
2098
+ ? true
2099
+ : item.label.toLowerCase().includes(normalizedHistoryKeyword)
2100
+ ).length === 0 ? (
2101
+ <Empty className="py-4">
2102
+ <EmptyIcon className="h-8 w-8">
2103
+ <svg
2104
+ viewBox="0 0 24 24"
2105
+ className="h-4 w-4"
2106
+ fill="none"
2107
+ stroke="currentColor"
2108
+ strokeWidth="1.8"
2109
+ aria-hidden="true"
2110
+ >
2111
+ <circle cx="11" cy="11" r="7" />
2112
+ <path d="m20 20-3.5-3.5" />
2113
+ </svg>
2114
+ </EmptyIcon>
2115
+ <EmptyTitle className="text-xs">未匹配到历史项</EmptyTitle>
2116
+ <EmptyDescription className="text-[11px]">
2117
+ 尝试更换关键词,例如删除、图层、缩放。
2118
+ </EmptyDescription>
2119
+ </Empty>
2120
+ ) : null}
2121
+ </div>
2122
+ </div>
2123
+ ) : null}
2124
+ </div>
2125
+ </TooltipTrigger>
2126
+ <TooltipContent className="z-[10000]">
2127
+ {isHistoryPanelExpanded ? "收起操作历史" : "展开操作历史"}
2128
+ </TooltipContent>
2129
+ </Tooltip>
2130
+ </TooltipProvider>
2131
+ <DropdownMenu>
2132
+ <DropdownMenuTrigger asChild>
2133
+ <button
2134
+ type="button"
2135
+ disabled={!hasUnlockedSelection}
2136
+ className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2137
+ aria-label="节点层级操作"
2138
+ >
2139
+ <IconLayers />
2140
+ </button>
2141
+ </DropdownMenuTrigger>
2142
+ <DropdownMenuContent align="start" sideOffset={6} className="z-[10000] w-44">
2143
+ <DropdownMenuItem
2144
+ disabled={!hasUnlockedSelection}
2145
+ onClick={() => bringElementsForward(selectedIds)}
2146
+ >
2147
+ <span className="mr-2 inline-flex"><IconForward /></span>
2148
+ 上移一层
2149
+ </DropdownMenuItem>
2150
+ <DropdownMenuItem
2151
+ disabled={!hasUnlockedSelection}
2152
+ onClick={() => sendElementsBackward(selectedIds)}
2153
+ >
2154
+ <span className="mr-2 inline-flex"><IconBackward /></span>
2155
+ 下移一层
2156
+ </DropdownMenuItem>
2157
+ <DropdownMenuItem
2158
+ disabled={!hasUnlockedSelection}
2159
+ onClick={() => bringElementsToFront(selectedIds)}
2160
+ >
2161
+ <span className="mr-2 inline-flex"><IconBringFront /></span>
2162
+ 置顶
2163
+ </DropdownMenuItem>
2164
+ <DropdownMenuItem
2165
+ disabled={!hasUnlockedSelection}
2166
+ onClick={() => sendElementsToBack(selectedIds)}
2167
+ >
2168
+ <span className="mr-2 inline-flex"><IconSendBack /></span>
2169
+ 置底
2170
+ </DropdownMenuItem>
2171
+ </DropdownMenuContent>
2172
+ </DropdownMenu>
2173
+ <div className="flex-1" />
2174
+ <div
2175
+ data-blueprint-toggle
2176
+ className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1"
2177
+ >
2178
+ <span className="text-[11px] text-muted-foreground">蓝图</span>
2179
+ <Switch
2180
+ checked={blueprintOpen}
2181
+ onCheckedChange={setBlueprintOpen}
2182
+ aria-label="显示蓝图面板"
2183
+ />
2184
+ </div>
2185
+ <TooltipProvider delayDuration={150}>
2186
+ <Tooltip>
2187
+ <TooltipTrigger asChild>
2188
+ <div className="relative">
2189
+ <Switch
2190
+ checked={isDark}
2191
+ onCheckedChange={applyTheme}
2192
+ aria-label="切换主题"
2193
+ className="data-[state=checked]:bg-primary/80 data-[state=unchecked]:bg-secondary"
2194
+ />
2195
+ <div
2196
+ className={[
2197
+ "pointer-events-none absolute left-0.5 top-0.5 flex h-5 w-5 items-center justify-center transition-transform duration-200",
2198
+ isDark ? "translate-x-5" : "translate-x-0",
2199
+ ].join(" ")}
2200
+ >
2201
+ {isDark ? (
2202
+ <svg
2203
+ viewBox="0 0 24 24"
2204
+ className="h-3 w-3 text-foreground"
2205
+ fill="none"
2206
+ stroke="currentColor"
2207
+ strokeWidth="1.8"
2208
+ >
2209
+ <path d="M21 12.79A9 9 0 1 1 11.21 3c0 .66.08 1.3.22 1.92A7 7 0 0 0 19.08 12c.62.14 1.26.22 1.92.22Z" />
2210
+ </svg>
2211
+ ) : (
2212
+ <svg
2213
+ viewBox="0 0 24 24"
2214
+ className="h-3 w-3 text-foreground"
2215
+ fill="none"
2216
+ stroke="currentColor"
2217
+ strokeWidth="1.8"
2218
+ >
2219
+ <circle cx="12" cy="12" r="4" />
2220
+ <path d="M12 2v2.5M12 19.5V22M4.93 4.93l1.77 1.77M17.3 17.3l1.77 1.77M2 12h2.5M19.5 12H22M4.93 19.07 6.7 17.3M17.3 6.7l1.77-1.77" />
2221
+ </svg>
2222
+ )}
2223
+ </div>
2224
+ </div>
2225
+ </TooltipTrigger>
2226
+ <TooltipContent className="z-[10000]">切换深色/浅色主题</TooltipContent>
2227
+ </Tooltip>
2228
+ <Tooltip>
2229
+ <TooltipTrigger asChild>
2230
+ <button
2231
+ type="button"
2232
+ onClick={() => adjustUniformZoom((z) => z - 0.1)}
2233
+ className="rounded-md border border-border bg-secondary px-2 py-1 text-xs text-secondary-foreground hover:bg-secondary/80"
2234
+ >
2235
+ -
2236
+ </button>
2237
+ </TooltipTrigger>
2238
+ <TooltipContent className="z-[10000]">缩小画布</TooltipContent>
2239
+ </Tooltip>
2240
+ </TooltipProvider>
2241
+ <span className="w-20 text-center text-xs">
2242
+ {zoom.x === zoom.y
2243
+ ? `${(zoom.x * 100).toFixed(0)}%`
2244
+ : `${(zoom.x * 100).toFixed(0)}%×${(zoom.y * 100).toFixed(0)}%`}
2245
+ </span>
2246
+ <TooltipProvider delayDuration={150}>
2247
+ <Tooltip>
2248
+ <TooltipTrigger asChild>
2249
+ <button
2250
+ type="button"
2251
+ onClick={() => adjustUniformZoom((z) => z + 0.1)}
2252
+ className="rounded-md border border-border bg-secondary px-2 py-1 text-xs text-secondary-foreground hover:bg-secondary/80"
2253
+ >
2254
+ +
2255
+ </button>
2256
+ </TooltipTrigger>
2257
+ <TooltipContent className="z-[10000]">放大画布</TooltipContent>
2258
+ </Tooltip>
2259
+ </TooltipProvider>
2260
+ </div>
2261
+ <Input
2262
+ ref={importInputRef}
2263
+ type="file"
2264
+ accept="application/json"
2265
+ className="hidden"
2266
+ onChange={async (e) => {
2267
+ const file = e.target.files?.[0];
2268
+ e.currentTarget.value = "";
2269
+ if (!file) return;
2270
+ try {
2271
+ await handleImportFile(file);
2272
+ } catch {
2273
+ window.alert("导入失败:JSON 解析错误");
2274
+ }
2275
+ }}
2276
+ />
2277
+ <Input
2278
+ ref={blueprintImportInputRef}
2279
+ type="file"
2280
+ accept="application/json"
2281
+ className="hidden"
2282
+ onChange={async (e) => {
2283
+ const file = e.target.files?.[0];
2284
+ e.currentTarget.value = "";
2285
+ if (!file) return;
2286
+ try {
2287
+ await handleBlueprintImportFile(file);
2288
+ } catch {
2289
+ window.alert("蓝图导入失败:文件格式不正确");
2290
+ }
2291
+ }}
2292
+ />
2293
+ <Input
2294
+ ref={titleIconInputRef}
2295
+ type="file"
2296
+ accept="image/*"
2297
+ className="hidden"
2298
+ onChange={async (e) => {
2299
+ const file = e.target.files?.[0];
2300
+ e.currentTarget.value = "";
2301
+ if (!file) return;
2302
+ try {
2303
+ const normalizedDataUrl = await normalizeTitleIconFile(file);
2304
+ setTitleIconDataUrl(normalizedDataUrl);
2305
+ } catch {
2306
+ window.alert("图标读取失败,请重试");
2307
+ }
2308
+ }}
2309
+ />
2310
+
2311
+ <WorkspaceStageSplit
2312
+ blueprintOpen={blueprintOpen}
2313
+ blueprintProps={blueprintCanvasProps}
2314
+ blueprintLibraryItems={blueprintLibraryItems}
2315
+ activeBlueprintLibraryId={activeBlueprintLibraryId}
2316
+ currentBlueprintLabel={blueprintMeta.name}
2317
+ onSelectBlueprintLibraryItem={(id) => {
2318
+ void handleSelectBlueprintLibraryItem(id);
2319
+ }}
2320
+ onRenameBlueprintLibraryItem={(id, name) => {
2321
+ void handleRenameBlueprintLibraryItem(id, name);
2322
+ }}
2323
+ onDeleteBlueprintLibraryItem={(id) => {
2324
+ void handleDeleteBlueprintLibraryItem(id);
2325
+ }}
2326
+ onSaveBlueprint={() => openBlueprintMetaDialog("save")}
2327
+ onSyncBlueprint={() => {
2328
+ void syncBlueprintToLibrary();
2329
+ }}
2330
+ canSyncBlueprint={blueprintLibraryDirty}
2331
+ blueprintDebug={blueprintDebugToolbar}
2332
+ viewStage={
2333
+ <div
2334
+ data-workspace-region="view"
2335
+ className="relative h-full min-h-0 flex-1"
2336
+ >
2337
+ <PanelRulers
2338
+ zoomX={zoom.x}
2339
+ zoomY={zoom.y}
2340
+ scrollLeft={scroll.left}
2341
+ scrollTop={scroll.top}
2342
+ />
2343
+
2344
+ <div
2345
+ className="h-full w-full"
2346
+ onPointerDownCapture={(e) => {
2347
+ if (!isRightLikePointer(e)) return;
2348
+ rightPointerRef.current.active = true;
2349
+ rightPointerRef.current.moved = false;
2350
+ rightPointerRef.current.startX = e.clientX;
2351
+ rightPointerRef.current.startY = e.clientY;
2352
+ }}
2353
+ onPointerMoveCapture={(e) => {
2354
+ if (!rightPointerRef.current.active) return;
2355
+ const dx = e.clientX - rightPointerRef.current.startX;
2356
+ const dy = e.clientY - rightPointerRef.current.startY;
2357
+ if (Math.hypot(dx, dy) > 10) {
2358
+ rightPointerRef.current.moved = true;
2359
+ }
2360
+ }}
2361
+ onPointerUpCapture={(e) => {
2362
+ if (!rightPointerRef.current.active) return;
2363
+ const wasMoved = rightPointerRef.current.moved;
2364
+ rightPointerRef.current.active = false;
2365
+ if (!isRightLikePointer(e) || wasMoved) return;
2366
+
2367
+ const target = e.target as HTMLElement | null;
2368
+ const nodeEl = target?.closest<HTMLElement>("[data-element-id]");
2369
+ const nodeIdFromElement = nodeEl?.dataset.elementId ?? null;
2370
+ const onMoveable =
2371
+ !!target?.closest(".moveable-control-box") ||
2372
+ !!target?.closest(".moveable-line") ||
2373
+ !!target?.closest(".moveable-control") ||
2374
+ !!target?.closest(".moveable-direction");
2375
+ const nodeId =
2376
+ nodeIdFromElement ??
2377
+ (onMoveable && selectedIds.length > 0 ? selectedIds[0] : null);
2378
+ const nextPoint = { x: e.clientX, y: e.clientY };
2379
+
2380
+ setDropdownOpen(false);
2381
+ requestAnimationFrame(() => {
2382
+ setContextMenuNodeId(nodeId);
2383
+ setDropdownPoint(nextPoint);
2384
+ setDropdownEpoch((v) => v + 1);
2385
+ setDropdownOpen(true);
2386
+ });
2387
+ }}
2388
+ onContextMenuCapture={(e) => {
2389
+ // 统一禁用系统菜单;菜单弹出改为在 pointerup 阶段控制
2390
+ e.preventDefault();
2391
+ }}
2392
+ >
2393
+ <PanelCanvas
2394
+ ref={syncScrollRef}
2395
+ zoom={zoom}
2396
+ onZoomChange={setZoom}
2397
+ onScrollChange={setScroll}
2398
+ canvasRef={syncCanvasRef}
2399
+ viewportOverlay={
2400
+ <MoveableLayer
2401
+ zoomX={zoom.x}
2402
+ zoomY={zoom.y}
2403
+ canvasContainer={canvasContainer}
2404
+ dragContainer={viewportEl}
2405
+ selectedTargets={selectedTargets}
2406
+ elementsById={byId}
2407
+ updateElement={updateElement}
2408
+ refreshToken={`${historyCursor}|${scroll.left}|${scroll.top}|${zoom.x}|${zoom.y}`}
2409
+ viewportScroll={scroll}
2410
+ />
2411
+ }
2412
+ onCanvasMouseDownCapture={(e) => {
2413
+ if (e.button !== 0) return;
2414
+ const target = e.target as HTMLElement | null;
2415
+ if (!shouldClearSelectionOnBlank(target)) return;
2416
+ clearSelection();
2417
+ }}
2418
+ onDropMaterial={({ materialId, x, y }) => {
2419
+ if (activeLayer?.locked) {
2420
+ showActionHint(PANEL_MESSAGES.activeLayerLockedCannotAdd);
2421
+ return;
2422
+ }
2423
+ addElementFromMaterial(materialId, x, y);
2424
+ }}
2425
+ className="h-full w-full"
2426
+ >
2427
+ <ElementsLayer
2428
+ elements={scopedCanvasElements}
2429
+ allElements={allElements}
2430
+ selectedIds={selectedIds}
2431
+ onSelectIds={selectViewElements}
2432
+ updateElement={updateElement}
2433
+ layerLocked={Boolean(activeLayer?.locked)}
2434
+ />
2435
+
2436
+ <SelectLayer
2437
+ container={canvasContainer}
2438
+ dragContainer={scrollRef.current}
2439
+ rootContainer={scrollRef.current}
2440
+ selectedIds={selectedIds}
2441
+ onSelectedIdsChange={selectViewElements}
2442
+ />
2443
+
2444
+ </PanelCanvas>
2445
+ </div>
2446
+ <DropdownMenu
2447
+ key={dropdownEpoch}
2448
+ open={dropdownOpen}
2449
+ onOpenChange={setDropdownOpen}
2450
+ >
2451
+ <DropdownMenuTrigger asChild>
2452
+ <button
2453
+ type="button"
2454
+ aria-hidden="true"
2455
+ tabIndex={-1}
2456
+ className="pointer-events-none fixed h-0 w-0 opacity-0"
2457
+ style={{ left: dropdownPoint.x, top: dropdownPoint.y }}
2458
+ />
2459
+ </DropdownMenuTrigger>
2460
+ <DropdownMenuContent align="start" sideOffset={6}>
2461
+ {contextMenuNodeId ? (
2462
+ <>
2463
+ <DropdownMenuItem
2464
+ onClick={() => {
2465
+ if (!contextMenuNodeId) return;
2466
+ const node = byId.get(contextMenuNodeId);
2467
+ if (!node) return;
2468
+ const layer = layerById.get(node.layerId);
2469
+ if (layer?.locked) {
2470
+ showActionHint(PANEL_MESSAGES.nodeLayerLockedCannotCopy);
2471
+ return;
2472
+ }
2473
+ setCopiedNodeId(contextMenuNodeId);
2474
+ }}
2475
+ >
2476
+ 复制节点
2477
+ </DropdownMenuItem>
2478
+ {contextMenuNodeId &&
2479
+ byId.get(contextMenuNodeId)?.mappingSourceNodeId ? (
2480
+ <DropdownMenuItem
2481
+ onClick={() => {
2482
+ if (!contextMenuNodeId) return;
2483
+ const mappingNode = byId.get(contextMenuNodeId);
2484
+ const sourceNodeId = mappingNode?.mappingSourceNodeId;
2485
+ if (!sourceNodeId) return;
2486
+ const sourceNode = byId.get(sourceNodeId);
2487
+ if (!sourceNode) {
2488
+ showActionHint(PANEL_MESSAGES.sourceNodeNotFound);
2489
+ return;
2490
+ }
2491
+ if (activeLayerId !== sourceNode.layerId) {
2492
+ setActiveLayer(sourceNode.layerId);
2493
+ }
2494
+ selectViewElements([sourceNode.id]);
2495
+ setContextMenuNodeId(null);
2496
+ }}
2497
+ >
2498
+ 定位到源节点
2499
+ </DropdownMenuItem>
2500
+ ) : null}
2501
+ <DropdownMenuItem
2502
+ onClick={() => {
2503
+ if (!contextMenuNodeId) return;
2504
+ const shouldOpenBatch =
2505
+ selectedIds.includes(contextMenuNodeId) && selectedIds.length > 1;
2506
+ const targetIds = shouldOpenBatch ? selectedIds : [contextMenuNodeId];
2507
+ const result = openElementsInNewLayer(targetIds);
2508
+ if (!result.ok) {
2509
+ showActionHint(result.reason);
2510
+ return;
2511
+ }
2512
+ setContextMenuNodeId(null);
2513
+ }}
2514
+ >
2515
+ 在映射图层打开
2516
+ </DropdownMenuItem>
2517
+ <DropdownMenuItem
2518
+ onClick={() => {
2519
+ const targetNode = contextMenuNodeId
2520
+ ? byId.get(contextMenuNodeId)
2521
+ : null;
2522
+ const targetLayer = targetNode
2523
+ ? layerById.get(targetNode.layerId)
2524
+ : null;
2525
+ if (targetLayer?.locked) {
2526
+ hintUnlockLayerForDelete();
2527
+ return;
2528
+ }
2529
+ const shouldDeleteBatch =
2530
+ !!contextMenuNodeId &&
2531
+ selectedIds.includes(contextMenuNodeId) &&
2532
+ selectedIds.length > 1;
2533
+ if (shouldDeleteBatch) {
2534
+ requestDeleteWithMappingImpact(selectedIds, () => {
2535
+ const hasLockedNode = selectedIds.some((id) => byId.get(id)?.locked);
2536
+ if (hasLockedNode) {
2537
+ hintLockedNodesInBatchDelete();
2538
+ return;
2539
+ }
2540
+ const hasLockedLayerNode = selectedIds.some((id) => {
2541
+ const el = byId.get(id);
2542
+ if (!el) return false;
2543
+ return !!layerById.get(el.layerId)?.locked;
2544
+ });
2545
+ if (hasLockedLayerNode) {
2546
+ showActionHint(PANEL_MESSAGES.selectionContainsLockedLayerNode);
2547
+ return;
2548
+ }
2549
+ deleteElements(selectedIds);
2550
+ setSelectedIds([]);
2551
+ });
2552
+ } else {
2553
+ if (!contextMenuNodeId) return;
2554
+ requestDeleteWithMappingImpact([contextMenuNodeId], () => {
2555
+ const target = byId.get(contextMenuNodeId);
2556
+ if (target?.locked) {
2557
+ hintUnlockNodeForDelete();
2558
+ return;
2559
+ }
2560
+ deleteElement(contextMenuNodeId);
2561
+ setSelectedIds((prev) =>
2562
+ prev.filter((sid) => sid !== contextMenuNodeId)
2563
+ );
2564
+ });
2565
+ }
2566
+ setContextMenuNodeId(null);
2567
+ }}
2568
+ >
2569
+ {contextMenuNodeId && selectedIds.includes(contextMenuNodeId) && selectedIds.length > 1
2570
+ ? `删除选中节点(${selectedIds.length})`
2571
+ : "删除节点"}
2572
+ </DropdownMenuItem>
2573
+ </>
2574
+ ) : (
2575
+ <>
2576
+ <DropdownMenuItem
2577
+ disabled={!copiedNodeId}
2578
+ onClick={() => {
2579
+ if (!copiedNodeId) return;
2580
+ const sourceNode = byId.get(copiedNodeId);
2581
+ if (sourceNode && layerById.get(sourceNode.layerId)?.locked) {
2582
+ showActionHint(PANEL_MESSAGES.sourceLayerLockedCannotPaste);
2583
+ return;
2584
+ }
2585
+ const canvasEl = canvasRef.current;
2586
+ const viewportEl = scrollRef.current;
2587
+ let targetX: number | undefined;
2588
+ let targetY: number | undefined;
2589
+ if (canvasEl) {
2590
+ const canvasRect = canvasEl.getBoundingClientRect();
2591
+ targetX =
2592
+ (dropdownPoint.x - canvasRect.left) /
2593
+ Math.max(0.0001, zoom.x);
2594
+ targetY =
2595
+ (dropdownPoint.y - canvasRect.top) /
2596
+ Math.max(0.0001, zoom.y);
2597
+ } else if (viewportEl) {
2598
+ const rect = viewportEl.getBoundingClientRect();
2599
+ const centerX = rect.left + rect.width / 2;
2600
+ const centerY = rect.top + rect.height / 2;
2601
+ targetX =
2602
+ (centerX - rect.left + scroll.left) /
2603
+ Math.max(0.0001, zoom.x);
2604
+ targetY =
2605
+ (centerY - rect.top + scroll.top) /
2606
+ Math.max(0.0001, zoom.y);
2607
+ }
2608
+ duplicateElement(
2609
+ copiedNodeId,
2610
+ targetX !== undefined && targetY !== undefined
2611
+ ? { position: { x: targetX, y: targetY } }
2612
+ : undefined
2613
+ );
2614
+ }}
2615
+ >
2616
+ 粘贴节点
2617
+ </DropdownMenuItem>
2618
+ <DropdownMenuItem
2619
+ disabled={elements.length === 0}
2620
+ onClick={() => {
2621
+ selectViewElements(elements.map((el) => el.id));
2622
+ }}
2623
+ >
2624
+ 全选当前图层
2625
+ </DropdownMenuItem>
2626
+ </>
2627
+ )}
2628
+ </DropdownMenuContent>
2629
+ </DropdownMenu>
2630
+ </div>
2631
+ }
2632
+ />
2633
+
2634
+ <div className="border-t border-border bg-background/95 px-2 py-1.5">
2635
+ <TooltipProvider delayDuration={120}>
2636
+ <Tabs value={activeLayerId} onValueChange={setActiveLayer}>
2637
+ <div className="mb-1 flex items-center gap-2">
2638
+ <div className="text-[11px] font-semibold text-muted-foreground">图层</div>
2639
+ <Tooltip>
2640
+ <TooltipTrigger asChild>
2641
+ <button
2642
+ type="button"
2643
+ onClick={() => setIsLayerPanelExpanded((v) => !v)}
2644
+ aria-label={isLayerPanelExpanded ? "收起图层输入区" : "展开图层输入区"}
2645
+ className="rounded border border-border p-1 hover:bg-accent"
2646
+ >
2647
+ <IconChevron expanded={isLayerPanelExpanded} />
2648
+ </button>
2649
+ </TooltipTrigger>
2650
+ <TooltipContent className="z-[10000]">
2651
+ {isLayerPanelExpanded ? "收起输入区" : "展开输入区"}
2652
+ </TooltipContent>
2653
+ </Tooltip>
2654
+ <Tooltip>
2655
+ <TooltipTrigger asChild>
2656
+ <button
2657
+ type="button"
2658
+ onClick={addLayer}
2659
+ aria-label="新增图层"
2660
+ className="rounded border border-border p-1 hover:bg-accent"
2661
+ >
2662
+ <IconPlus />
2663
+ </button>
2664
+ </TooltipTrigger>
2665
+ <TooltipContent className="z-[10000]">新增图层</TooltipContent>
2666
+ </Tooltip>
2667
+ <Tooltip>
2668
+ <TooltipTrigger asChild>
2669
+ <button
2670
+ type="button"
2671
+ onClick={() => {
2672
+ if (!canMergeLayers) return;
2673
+ setIsMergingLayers(true);
2674
+ }}
2675
+ aria-label="合并图层"
2676
+ disabled={!canMergeLayers}
2677
+ className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2678
+ >
2679
+ <IconMerge />
2680
+ </button>
2681
+ </TooltipTrigger>
2682
+ <TooltipContent className="z-[10000]">
2683
+ {canMergeLayers ? "合并图层" : "至少勾选 2 个图层后可合并"}
2684
+ </TooltipContent>
2685
+ </Tooltip>
2686
+ </div>
2687
+
2688
+ <TabsList
2689
+ className={`h-auto w-full justify-start gap-1 overflow-x-auto bg-muted/40 p-1 ${themedScrollbarClass}`}
2690
+ >
2691
+ {layers.map((layer) => (
2692
+ <TabsTrigger
2693
+ key={layer.id}
2694
+ value={layer.id}
2695
+ className="flex min-w-[120px] items-center gap-1 px-2 py-1 text-xs"
2696
+ >
2697
+ <span className="truncate">{layer.name}</span>
2698
+ {layer.isMapping ? (
2699
+ <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
2700
+ 映射
2701
+ </span>
2702
+ ) : null}
2703
+ {layer.isPrimary ? (
2704
+ <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
2705
+ 主
2706
+ </span>
2707
+ ) : null}
2708
+ {layer.locked ? <IconLock locked /> : null}
2709
+ </TabsTrigger>
2710
+ ))}
2711
+ </TabsList>
2712
+
2713
+ <TabsContent value={activeLayerId} className="mt-2 space-y-2">
2714
+ {activeLayer ? (
2715
+ <div className="flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs">
2716
+ {editingLayerId === activeLayer.id ? (
2717
+ <Input
2718
+ value={editingLayerName}
2719
+ onChange={(e) => setEditingLayerName(e.target.value)}
2720
+ onKeyDown={(e) => {
2721
+ if (e.key === "Enter") {
2722
+ e.preventDefault();
2723
+ renameLayer(editingLayerId, editingLayerName);
2724
+ setEditingLayerId(null);
2725
+ setEditingLayerName("");
2726
+ return;
2727
+ }
2728
+ if (e.key === "Escape") {
2729
+ e.preventDefault();
2730
+ setEditingLayerId(null);
2731
+ setEditingLayerName("");
2732
+ }
2733
+ }}
2734
+ className="h-7 min-w-0 flex-1"
2735
+ placeholder="请输入图层名称"
2736
+ autoFocus
2737
+ />
2738
+ ) : (
2739
+ <span className="truncate font-medium">{activeLayer.name}</span>
2740
+ )}
2741
+ {activeLayer.isMapping ? (
2742
+ <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
2743
+ 映射图层
2744
+ </span>
2745
+ ) : null}
2746
+ {activeLayer.isPrimary ? (
2747
+ <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
2748
+ 主图层
2749
+ </span>
2750
+ ) : null}
2751
+ <div className="flex-1" />
2752
+ <Tooltip>
2753
+ <TooltipTrigger asChild>
2754
+ <button
2755
+ type="button"
2756
+ onClick={() => setPrimaryLayer(activeLayer.id)}
2757
+ disabled={activeLayer.isPrimary}
2758
+ aria-label={activeLayer.isPrimary ? "当前主图层" : "设为主图层"}
2759
+ className={[
2760
+ "rounded border px-2 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-60",
2761
+ activeLayer.isPrimary
2762
+ ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600"
2763
+ : "border-border hover:bg-accent",
2764
+ ].join(" ")}
2765
+ >
2766
+ {activeLayer.isPrimary ? "当前主图层" : "设为主图层"}
2767
+ </button>
2768
+ </TooltipTrigger>
2769
+ <TooltipContent className="z-[10000]">
2770
+ {activeLayer.isPrimary ? "当前主图层(预览使用)" : "设为主图层(预览使用)"}
2771
+ </TooltipContent>
2772
+ </Tooltip>
2773
+ <Tooltip>
2774
+ <TooltipTrigger asChild>
2775
+ <button
2776
+ type="button"
2777
+ onClick={() => toggleLayerLock(activeLayer.id)}
2778
+ disabled={!activeLayer.editable}
2779
+ aria-label={activeLayer.locked ? "解锁图层" : "锁定图层"}
2780
+ className="rounded border border-border p-1 disabled:opacity-40"
2781
+ >
2782
+ <IconLock locked={activeLayer.locked} />
2783
+ </button>
2784
+ </TooltipTrigger>
2785
+ <TooltipContent className="z-[10000]">
2786
+ {activeLayer.locked ? "解锁图层" : "锁定图层"}
2787
+ </TooltipContent>
2788
+ </Tooltip>
2789
+ <Tooltip>
2790
+ <TooltipTrigger asChild>
2791
+ {editingLayerId === activeLayer.id ? (
2792
+ <button
2793
+ type="button"
2794
+ onClick={() => {
2795
+ renameLayer(editingLayerId, editingLayerName);
2796
+ setEditingLayerId(null);
2797
+ setEditingLayerName("");
2798
+ }}
2799
+ aria-label="保存图层名称"
2800
+ className="rounded border border-border p-1 hover:bg-accent"
2801
+ >
2802
+ <IconCheck />
2803
+ </button>
2804
+ ) : (
2805
+ <button
2806
+ type="button"
2807
+ onClick={() => {
2808
+ setEditingLayerId(activeLayer.id);
2809
+ setEditingLayerName(activeLayer.name);
2810
+ }}
2811
+ disabled={!activeLayer.editable}
2812
+ aria-label="重命名图层"
2813
+ className="rounded border border-border p-1 disabled:opacity-40"
2814
+ >
2815
+ <IconEdit />
2816
+ </button>
2817
+ )}
2818
+ </TooltipTrigger>
2819
+ <TooltipContent className="z-[10000]">
2820
+ {editingLayerId === activeLayer.id ? "保存图层名称" : "重命名图层"}
2821
+ </TooltipContent>
2822
+ </Tooltip>
2823
+ {editingLayerId === activeLayer.id ? (
2824
+ <Tooltip>
2825
+ <TooltipTrigger asChild>
2826
+ <button
2827
+ type="button"
2828
+ onClick={() => {
2829
+ setEditingLayerId(null);
2830
+ setEditingLayerName("");
2831
+ }}
2832
+ aria-label="取消编辑图层名称"
2833
+ className="rounded border border-border p-1 hover:bg-accent"
2834
+ >
2835
+ <IconClose />
2836
+ </button>
2837
+ </TooltipTrigger>
2838
+ <TooltipContent className="z-[10000]">取消编辑</TooltipContent>
2839
+ </Tooltip>
2840
+ ) : null}
2841
+ <Tooltip>
2842
+ <TooltipTrigger asChild>
2843
+ <button
2844
+ type="button"
2845
+ onClick={() => {
2846
+ const blockReason = getLayerDeleteBlockReason(activeLayer.id);
2847
+ if (blockReason) {
2848
+ showActionHint(blockReason);
2849
+ return;
2850
+ }
2851
+ setConfirmDeleteLayerId(activeLayer.id);
2852
+ const firstTarget = layers.find((l) => l.id !== activeLayer.id);
2853
+ setDeleteTargetLayerId(firstTarget?.id ?? "");
2854
+ setDeleteMode(activeLayer.isMapping ? "remove" : "move");
2855
+ }}
2856
+ disabled={!activeLayer.editable || activeLayer.locked}
2857
+ aria-label={
2858
+ !activeLayer.editable
2859
+ ? "默认图层不可删除"
2860
+ : activeLayer.locked
2861
+ ? "锁定图层不可删除"
2862
+ : "删除图层"
2863
+ }
2864
+ className="rounded border border-border p-1 disabled:opacity-40"
2865
+ >
2866
+ <IconTrash />
2867
+ </button>
2868
+ </TooltipTrigger>
2869
+ <TooltipContent className="z-[10000]">
2870
+ {!activeLayer.editable
2871
+ ? "默认图层不可删除"
2872
+ : activeLayer.locked
2873
+ ? "锁定图层不可删除"
2874
+ : "删除图层"}
2875
+ </TooltipContent>
2876
+ </Tooltip>
2877
+ </div>
2878
+ ) : null}
2879
+
2880
+ {isLayerPanelExpanded ? (
2881
+ <div className="rounded border border-border bg-card px-2 py-1.5 text-xs">
2882
+ <div className="mb-1 text-muted-foreground">选择合并图层</div>
2883
+ <div className="flex flex-wrap gap-2">
2884
+ {layers.map((layer) => (
2885
+ <label key={layer.id} className="flex items-center gap-1.5">
2886
+ <Checkbox
2887
+ checked={Boolean(layer.mergeSelected)}
2888
+ disabled={layer.isMapping}
2889
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2890
+ onCheckedChange={() => toggleLayerMergeSelected(layer.id)}
2891
+ />
2892
+ <span
2893
+ className="max-w-[140px] truncate"
2894
+ title={layer.isMapping ? "映射图层不允许合并" : layer.name}
2895
+ >
2896
+ {layer.name}
2897
+ {layer.isMapping ? "(不可合并)" : ""}
2898
+ </span>
2899
+ </label>
2900
+ ))}
2901
+ </div>
2902
+ </div>
2903
+ ) : null}
2904
+ </TabsContent>
2905
+ </Tabs>
2906
+
2907
+ {isLayerPanelExpanded && isMergingLayers ? (
2908
+ <div className="mt-2 flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs">
2909
+ <span className="text-muted-foreground">合并后图层名</span>
2910
+ <Input
2911
+ value={mergeLayerName}
2912
+ onChange={(e) => setMergeLayerName(e.target.value)}
2913
+ className="h-7 min-w-0 flex-1"
2914
+ placeholder="可空,留空将随机命名"
2915
+ autoFocus
2916
+ />
2917
+ <Tooltip>
2918
+ <TooltipTrigger asChild>
2919
+ <button
2920
+ type="button"
2921
+ onClick={() => {
2922
+ if (!canMergeLayers) return;
2923
+ mergeSelectedLayers(mergeLayerName || undefined);
2924
+ setIsMergingLayers(false);
2925
+ setMergeLayerName("");
2926
+ }}
2927
+ aria-label="确认合并图层"
2928
+ disabled={!canMergeLayers}
2929
+ className="rounded border border-border p-1 hover:bg-accent"
2930
+ >
2931
+ <IconCheck />
2932
+ </button>
2933
+ </TooltipTrigger>
2934
+ <TooltipContent className="z-[10000]">确认合并</TooltipContent>
2935
+ </Tooltip>
2936
+ <Tooltip>
2937
+ <TooltipTrigger asChild>
2938
+ <button
2939
+ type="button"
2940
+ onClick={() => {
2941
+ setIsMergingLayers(false);
2942
+ setMergeLayerName("");
2943
+ }}
2944
+ aria-label="取消合并图层"
2945
+ className="rounded border border-border p-1 hover:bg-accent"
2946
+ >
2947
+ <IconClose />
2948
+ </button>
2949
+ </TooltipTrigger>
2950
+ <TooltipContent className="z-[10000]">取消合并</TooltipContent>
2951
+ </Tooltip>
2952
+ </div>
2953
+ ) : null}
2954
+ </TooltipProvider>
2955
+ </div>
2956
+ </div>
2957
+ </div>
2958
+ </ResizablePanel>
2959
+ <ResizableHandle withHandle />
2960
+ <ResizablePanel defaultSize={20} minSize={20}>
2961
+ <div className="panel-font-root h-full">
2962
+ <WorkspaceConfigSidebar
2963
+ configFocus={sidebarConfigFocus}
2964
+ executionLog={executionLogView}
2965
+ selectedBlueprintNode={selectedBlueprintNode}
2966
+ allowFalseSignalPropagation={
2967
+ blueprintGraph.document.allowFalseSignalPropagation ?? false
2968
+ }
2969
+ onUpdateAllowFalseSignalPropagation={
2970
+ handleUpdateAllowFalseSignalPropagation
2971
+ }
2972
+ onUpdateBlueprintNode={handleUpdateBlueprintNode}
2973
+ blueprintLibraryOptions={blueprintLibraryOptions}
2974
+ selectedElement={selectedElement}
2975
+ selectedElements={selectedElements}
2976
+ viewElementScope={selectedElementScope}
2977
+ allViewElements={allElements}
2978
+ layers={layers}
2979
+ updateElement={updateElement}
2980
+ setReferenceCopyMode={setReferenceCopyMode}
2981
+ nodeZOrderLabel={selectedNodeZOrderLabel}
2982
+ onExcludeSelectedNode={(nodeId) => {
2983
+ setSelectedIds((prev) => {
2984
+ const nextIds = prev.filter((id) => id !== nodeId);
2985
+ setSelectedTargets(getSelectedTargetsFromIds(canvasRef.current, nextIds));
2986
+ return nextIds;
2987
+ });
2988
+ }}
2989
+ onAdjustNodeZOrder={(nodeId, action) => {
2990
+ if (action === "bringForward") {
2991
+ bringElementsForward([nodeId]);
2992
+ return;
2993
+ }
2994
+ if (action === "sendBackward") {
2995
+ sendElementsBackward([nodeId]);
2996
+ return;
2997
+ }
2998
+ if (action === "bringToFront") {
2999
+ bringElementsToFront([nodeId]);
3000
+ return;
3001
+ }
3002
+ sendElementsToBack([nodeId]);
3003
+ }}
3004
+ />
3005
+ </div>
3006
+ </ResizablePanel>
3007
+ </ResizablePanelGroup>
3008
+ <AlertDialog
3009
+ open={mappingDeleteConfirmOpen}
3010
+ onOpenChange={(open) => {
3011
+ setMappingDeleteConfirmOpen(open);
3012
+ if (!open) mappingDeleteProceedRef.current = null;
3013
+ }}
3014
+ >
3015
+ <AlertDialogContent overlayClassName="bg-transparent pointer-events-none">
3016
+ <AlertDialogHeader>
3017
+ <AlertDialogTitle>确认删除</AlertDialogTitle>
3018
+ <AlertDialogDescription>
3019
+ 将删除 {mappingDeleteImpactCount} 个关联映射图层,是否继续?
3020
+ </AlertDialogDescription>
3021
+ </AlertDialogHeader>
3022
+ <AlertDialogFooter>
3023
+ <AlertDialogCancel>取消</AlertDialogCancel>
3024
+ <AlertDialogAction
3025
+ onClick={() => {
3026
+ const fn = mappingDeleteProceedRef.current;
3027
+ mappingDeleteProceedRef.current = null;
3028
+ setMappingDeleteConfirmOpen(false);
3029
+ fn?.();
3030
+ }}
3031
+ >
3032
+ 继续删除
3033
+ </AlertDialogAction>
3034
+ </AlertDialogFooter>
3035
+ </AlertDialogContent>
3036
+ </AlertDialog>
3037
+ <Dialog
3038
+ open={Boolean(deletingLayer)}
3039
+ onOpenChange={(open) => {
3040
+ if (open) return;
3041
+ setConfirmDeleteLayerId(null);
3042
+ setDeleteMode("move");
3043
+ setDeleteTargetLayerId("");
3044
+ }}
3045
+ >
3046
+ <DialogContent
3047
+ className="sm:max-w-[520px]"
3048
+ overlayClassName="bg-transparent pointer-events-none"
3049
+ >
3050
+ <DialogHeader>
3051
+ <DialogTitle>确认删除图层</DialogTitle>
3052
+ <DialogDescription>
3053
+ 即将删除图层:{deletingLayer?.name ?? "-"}
3054
+ </DialogDescription>
3055
+ </DialogHeader>
3056
+ {deletingLayer ? (
3057
+ <RadioGroup
3058
+ value={deletingLayerMode}
3059
+ onValueChange={(value) => setDeleteMode(value as "move" | "remove")}
3060
+ className="space-y-2 text-sm"
3061
+ >
3062
+ {!deletingLayer.isMapping ? (
3063
+ <div className="flex items-center gap-3">
3064
+ <label className="flex items-center gap-1.5">
3065
+ <RadioGroupItem value="move" />
3066
+ 节点迁移到
3067
+ </label>
3068
+ <Select
3069
+ value={deleteTargetLayerId || "__none__"}
3070
+ onValueChange={(value) =>
3071
+ setDeleteTargetLayerId(value === "__none__" ? "" : value)
3072
+ }
3073
+ disabled={deletingLayerMode !== "move" || deleteTargetCandidates.length === 0}
3074
+ >
3075
+ <SelectTrigger className="h-8 w-[220px]">
3076
+ <SelectValue placeholder="选择目标图层" />
3077
+ </SelectTrigger>
3078
+ <SelectContent>
3079
+ <SelectItem value="__none__">请选择图层</SelectItem>
3080
+ {deleteTargetCandidates.map((l) => (
3081
+ <SelectItem key={l.id} value={l.id}>
3082
+ {l.name}
3083
+ </SelectItem>
3084
+ ))}
3085
+ </SelectContent>
3086
+ </Select>
3087
+ </div>
3088
+ ) : null}
3089
+ <label className="flex items-center gap-1.5">
3090
+ <RadioGroupItem value="remove" />
3091
+ {deletingLayer.isMapping ? "删除映射图层及其节点" : "同时删除该图层下所有节点"}
3092
+ </label>
3093
+ </RadioGroup>
3094
+ ) : null}
3095
+ <DialogFooter>
3096
+ <button
3097
+ type="button"
3098
+ onClick={() => {
3099
+ setConfirmDeleteLayerId(null);
3100
+ setDeleteMode("move");
3101
+ setDeleteTargetLayerId("");
3102
+ }}
3103
+ className="rounded border border-border px-3 py-1.5 text-sm hover:bg-accent"
3104
+ >
3105
+ 取消
3106
+ </button>
3107
+ <button
3108
+ type="button"
3109
+ onClick={() => {
3110
+ if (!deletingLayer) return;
3111
+ const result = deleteLayer(deletingLayer.id, {
3112
+ mode: deletingLayerMode,
3113
+ targetLayerId: deleteTargetLayerId || undefined,
3114
+ });
3115
+ if (!result.ok) {
3116
+ showActionHint(result.reason);
3117
+ return;
3118
+ }
3119
+ setConfirmDeleteLayerId(null);
3120
+ setDeleteMode("move");
3121
+ setDeleteTargetLayerId("");
3122
+ }}
3123
+ className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
3124
+ >
3125
+ 确认删除
3126
+ </button>
3127
+ </DialogFooter>
3128
+ </DialogContent>
3129
+ </Dialog>
3130
+ <Dialog
3131
+ open={titleIconPreviewOpen}
3132
+ onOpenChange={setTitleIconPreviewOpen}
3133
+ >
3134
+ <DialogContent
3135
+ className="sm:max-w-[860px]"
3136
+ overlayClassName="bg-transparent pointer-events-none"
3137
+ >
3138
+ <DialogHeader>
3139
+ <DialogTitle>Title 图标预览</DialogTitle>
3140
+ <DialogDescription>用于预览页标签页 icon 的当前图片</DialogDescription>
3141
+ </DialogHeader>
3142
+ <div className="flex items-center justify-end gap-2">
3143
+ <button
3144
+ type="button"
3145
+ onClick={() => setTitleIconZoom((z) => Math.max(0.5, Number((z - 0.1).toFixed(2))))}
3146
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-accent"
3147
+ >
3148
+ -
3149
+ </button>
3150
+ <span className="w-14 text-center text-xs text-muted-foreground">
3151
+ {(titleIconZoom * 100).toFixed(0)}%
3152
+ </span>
3153
+ <button
3154
+ type="button"
3155
+ onClick={() => setTitleIconZoom((z) => Math.min(8, Number((z + 0.1).toFixed(2))))}
3156
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-accent"
3157
+ >
3158
+ +
3159
+ </button>
3160
+ <button
3161
+ type="button"
3162
+ onClick={() => setTitleIconZoom(1.6)}
3163
+ className="rounded border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
3164
+ >
3165
+ 重置
3166
+ </button>
3167
+ </div>
3168
+ {titleIconDataUrl ? (
3169
+ <div
3170
+ className="max-h-[78vh] overflow-auto rounded border border-border bg-muted/20 p-4"
3171
+ onWheel={(e) => {
3172
+ e.preventDefault();
3173
+ const delta = e.deltaY > 0 ? -0.1 : 0.1;
3174
+ setTitleIconZoom((z) => {
3175
+ const next = Number((z + delta).toFixed(2));
3176
+ return Math.min(8, Math.max(0.5, next));
3177
+ });
3178
+ }}
3179
+ >
3180
+ <img
3181
+ src={titleIconDataUrl}
3182
+ alt="title 图标大图预览"
3183
+ className="mx-auto rounded border border-border/70 object-contain"
3184
+ style={{
3185
+ width: 200,
3186
+ height: 200,
3187
+ maxWidth: "none",
3188
+ maxHeight: "none",
3189
+ transform: `scale(${titleIconZoom})`,
3190
+ transformOrigin: "center center",
3191
+ }}
3192
+ />
3193
+ </div>
3194
+ ) : null}
3195
+ </DialogContent>
3196
+ </Dialog>
3197
+ <BlueprintMetaDialog
3198
+ open={blueprintMetaDialogOpen}
3199
+ mode={blueprintMetaDialogMode}
3200
+ initialMeta={blueprintMeta}
3201
+ onOpenChange={setBlueprintMetaDialogOpen}
3202
+ onConfirm={(meta) => {
3203
+ void handleBlueprintMetaConfirm(meta);
3204
+ }}
3205
+ />
3206
+ {pendingBlueprintNodeSwitch ? (
3207
+ <BlueprintNodeSwitchTaskDialog
3208
+ open
3209
+ fromNodeId={pendingBlueprintNodeSwitch.fromNodeId}
3210
+ toNodeId={pendingBlueprintNodeSwitch.toNodeId}
3211
+ onOpenChange={(open) => {
3212
+ if (!open) stayOnCurrentBlueprintNode();
3213
+ }}
3214
+ onKeepTaskAndSwitch={keepBlueprintTaskAndSwitch}
3215
+ onCancelTaskAndSwitch={cancelBlueprintTaskAndSwitch}
3216
+ />
3217
+ ) : null}
3218
+ <Toaster />
3219
+ </div>
3220
+ );
3221
+ }