@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.
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/aix-ppc64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ia32.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-loong64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-mips64el.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ppc64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-riscv64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-s390x.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/netbsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/openbsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/sunos-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-ia32.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm-eabi.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-x64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-s390x-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-gnu.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-musl.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-arm64-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-ia32-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-x64-msvc.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/eslint.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/fsevents.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/pnpm.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/react-dom.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/react-is.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-arm64.json +1 -0
- package/.pnpm-cache/metadata/registry.npmjs.org/typescript.json +1 -0
- package/.turbo/turbo-lint.log +4 -0
- package/dist/styles.css +2624 -0
- package/env.d.ts +11 -0
- package/operation-guide.md +275 -0
- package/package.json +43 -0
- package/postcss.config.cjs +7 -0
- package/readme.md +21 -0
- package/src/core/createAction.ts +5 -0
- package/src/core/createView.ts +28 -0
- package/src/core/instructions.tsx +57 -0
- package/src/core/nodePool.ts +127 -0
- package/src/core/signal.ts +130 -0
- package/src/core/template.tsx +25 -0
- package/src/index.ts +7 -0
- package/src/panel/ReactViewOnlinePreview.tsx +331 -0
- package/src/panel/ReactViewPanel.tsx +3221 -0
- package/src/panel/components/ElementsLayer.tsx +891 -0
- package/src/panel/components/MaterialSidebar.tsx +1142 -0
- package/src/panel/components/MoveableLayer.tsx +803 -0
- package/src/panel/components/PanelCanvas.tsx +660 -0
- package/src/panel/components/PanelConfigSidebar.tsx +3703 -0
- package/src/panel/components/PanelRulers.tsx +89 -0
- package/src/panel/components/SelectLayer.tsx +106 -0
- package/src/panel/components/ViewElementScopePanel.tsx +79 -0
- package/src/panel/components/WorkspaceConfigSidebar.tsx +158 -0
- package/src/panel/components/WorkspaceProjectNav.tsx +220 -0
- package/src/panel/components/WorkspaceStageSplit.tsx +179 -0
- package/src/panel/components/scope-config/ScopeConfigContext.tsx +170 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +250 -0
- package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +59 -0
- package/src/panel/constants/messages.ts +34 -0
- package/src/panel/constants/zIndex.ts +6 -0
- package/src/panel/hooks/usePanelElements.ts +1108 -0
- package/src/panel/hooks/useWorkspaceProjects.ts +278 -0
- package/src/panel/index.tsx +3 -0
- package/src/panel/library/workspace-project-cache.ts +23 -0
- package/src/panel/library/workspace-project-db.ts +112 -0
- package/src/panel/library/workspace-project-sync.ts +41 -0
- package/src/panel/library/workspace-snapshot.ts +27 -0
- package/src/panel/scope/view-scope-store.ts +55 -0
- package/src/panel/types.ts +125 -0
- package/src/panel/utils/chartOptionBuilder.ts +316 -0
- package/src/panel/utils/gridPlacement.ts +189 -0
- package/src/panel/utils/mappingLayerOps.ts +142 -0
- package/src/panel/utils/panelElementDefaults.ts +161 -0
- package/src/panel/utils/panelElementNodes.ts +35 -0
- package/src/panel/utils/panelStateIO.ts +124 -0
- package/src/panel/utils/scope-autocomplete.ts +114 -0
- package/src/panel/utils/scope-field-labels.ts +45 -0
- package/src/panel/utils/scope-template-warnings.ts +135 -0
- package/src/panel/utils/scope-template.ts +75 -0
- package/src/panel/utils/updateElementDraft.ts +221 -0
- package/src/panel/viewportZoom.ts +26 -0
- package/src/tailwind.css +58 -0
- package/src/types/react-infinite-viewer.d.ts +39 -0
- package/tailwind.config.ts +7 -0
- 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, "&")
|
|
1554
|
+
.replace(/</g, "<")
|
|
1555
|
+
.replace(/>/g, ">")
|
|
1556
|
+
.replace(/"/g, """)
|
|
1557
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|