@arronqzy/react-view 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +11 -11
  2. package/readme.md +6 -1
  3. package/src/panel/I18nRoot.tsx +14 -2
  4. package/src/panel/ReactViewOnlinePreview.tsx +29 -18
  5. package/src/panel/ReactViewPanel.tsx +154 -1006
  6. package/src/panel/components/PanelConfigSidebar.tsx +297 -3712
  7. package/src/panel/components/PanelHistoryPopover.tsx +163 -0
  8. package/src/panel/components/PanelLayerDock.tsx +412 -0
  9. package/src/panel/components/PanelMenubar.tsx +149 -0
  10. package/src/panel/components/PanelWorkspaceDialogs.tsx +271 -0
  11. package/src/panel/components/config/PanelConfigAudioSection.tsx +334 -0
  12. package/src/panel/components/config/PanelConfigChartSection.tsx +799 -0
  13. package/src/panel/components/config/PanelConfigGeometrySection.tsx +233 -0
  14. package/src/panel/components/config/PanelConfigGridChildSpan.tsx +70 -0
  15. package/src/panel/components/config/PanelConfigGridSection.tsx +118 -0
  16. package/src/panel/components/config/PanelConfigMultiSelect.tsx +1291 -0
  17. package/src/panel/components/config/PanelConfigNodeInfo.tsx +169 -0
  18. package/src/panel/components/config/PanelConfigReferenceSection.tsx +99 -0
  19. package/src/panel/components/config/PanelConfigScene3dSection.tsx +6 -2
  20. package/src/panel/components/config/PanelConfigStyleSections.tsx +249 -0
  21. package/src/panel/components/config/PanelConfigTextSection.tsx +268 -0
  22. package/src/panel/components/config/PanelConfigVideoSection.tsx +133 -0
  23. package/src/panel/components/config/PanelConfigViewportSection.tsx +139 -0
  24. package/src/panel/components/config/helpers.ts +85 -0
  25. package/src/panel/hooks/useWorkspaceProjects.ts +112 -34
  26. package/src/panel/icons.tsx +160 -0
  27. package/src/panel/index.tsx +15 -1
  28. package/src/panel/library/parse-workspace-data.ts +142 -0
  29. package/src/panel/library/workspace-project-cache.ts +13 -4
  30. package/src/panel/library/workspace-project-db.ts +24 -13
  31. package/src/panel/library/workspace-project-sync.ts +17 -6
  32. package/src/panel/utils/outputScale.ts +9 -4
  33. package/src/panel/utils/panelSelection.ts +57 -0
  34. package/src/panel/utils/panelStateIO.ts +18 -0
@@ -65,7 +65,7 @@ import {
65
65
  type BlueprintMetaDraft,
66
66
  } from "@arronqzy/react-blueprint";
67
67
  import type { LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
68
- import { abortClockNode, collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE } from "@arronqzy/blueprint-dsl";
68
+ import { abortClockNode, appStorageKey, collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE } from "@arronqzy/blueprint-dsl";
69
69
  import { WorkspaceStageSplit } from "./components/WorkspaceStageSplit";
70
70
  import {
71
71
  WorkspaceConfigSidebar,
@@ -84,55 +84,35 @@ import { getPanelMessages } from "./constants/messages";
84
84
  import { PANEL_Z_INDEX } from "./constants/zIndex";
85
85
  import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
86
86
  import {
87
- AlertDialog,
88
- AlertDialogAction,
89
- AlertDialogCancel,
90
- AlertDialogContent,
91
- AlertDialogDescription,
92
- AlertDialogFooter,
93
- AlertDialogHeader,
94
- AlertDialogTitle,
87
+ IconBackward,
88
+ IconBringFront,
89
+ IconClose,
90
+ IconForward,
91
+ IconImage,
92
+ IconLayers,
93
+ IconRedo,
94
+ IconSendBack,
95
+ IconUndo,
96
+ } from "./icons";
97
+ import {
98
+ getSelectedTargetsFromIds,
99
+ shouldClearSelectionOnBlank,
100
+ } from "./utils/panelSelection";
101
+ import { PanelMenubar } from "./components/PanelMenubar";
102
+ import { PanelHistoryPopover } from "./components/PanelHistoryPopover";
103
+ import { PanelLayerDock } from "./components/PanelLayerDock";
104
+ import { PanelWorkspaceDialogs } from "./components/PanelWorkspaceDialogs";
105
+ import {
95
106
  Button,
96
- Checkbox,
97
- Dialog,
98
- DialogContent,
99
- DialogDescription,
100
- DialogFooter,
101
- DialogHeader,
102
- DialogTitle,
103
107
  DropdownMenu,
104
108
  DropdownMenuContent,
105
109
  DropdownMenuItem,
106
110
  DropdownMenuTrigger,
107
- Empty,
108
- EmptyDescription,
109
- EmptyIcon,
110
- EmptyTitle,
111
111
  Input,
112
- Menubar,
113
- MenubarCheckboxItem,
114
- MenubarContent,
115
- MenubarItem,
116
- MenubarMenu,
117
- MenubarRadioGroup,
118
- MenubarRadioItem,
119
- MenubarSeparator,
120
- MenubarTrigger,
121
- RadioGroup,
122
- RadioGroupItem,
123
112
  ResizableHandle,
124
113
  ResizablePanel,
125
114
  ResizablePanelGroup,
126
- Select,
127
- SelectContent,
128
- SelectItem,
129
- SelectTrigger,
130
- SelectValue,
131
115
  Switch,
132
- Tabs,
133
- TabsContent,
134
- TabsList,
135
- TabsTrigger,
136
116
  Toaster,
137
117
  Tooltip,
138
118
  TooltipContent,
@@ -142,242 +122,33 @@ import {
142
122
  useTheme,
143
123
  } from "@arronqzy/ui";
144
124
 
145
- function IconPlus() {
146
- return (
147
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
148
- <path d="M12 5v14M5 12h14" />
149
- </svg>
150
- );
151
- }
152
-
153
- function IconMerge() {
154
- return (
155
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
156
- <path d="M7 7h4a4 4 0 0 1 4 4v6" />
157
- <path d="m12 14 3 3 3-3" />
158
- <path d="M7 17h2" />
159
- </svg>
160
- );
161
- }
162
-
163
- function IconLock({ locked }: { locked: boolean }) {
164
- return locked ? (
165
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
166
- <rect x="4" y="11" width="16" height="9" rx="2" />
167
- <path d="M8 11V8a4 4 0 0 1 8 0v3" />
168
- </svg>
169
- ) : (
170
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
171
- <rect x="4" y="11" width="16" height="9" rx="2" />
172
- <path d="M16 11V8a4 4 0 0 0-7-2.5" />
173
- </svg>
174
- );
175
- }
176
-
177
- function IconEdit() {
178
- return (
179
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
180
- <path d="M12 20h9" />
181
- <path d="m16.5 3.5 4 4L8 20l-5 1 1-5Z" />
182
- </svg>
183
- );
184
- }
185
-
186
- function IconTrash() {
187
- return (
188
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
189
- <path d="M3 6h18" />
190
- <path d="M8 6V4h8v2" />
191
- <path d="M19 6l-1 14H6L5 6" />
192
- </svg>
193
- );
194
- }
195
-
196
- function IconCheck() {
197
- return (
198
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
199
- <path d="m5 12 5 5L20 7" />
200
- </svg>
201
- );
202
- }
203
-
204
- function IconClose() {
205
- return (
206
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
207
- <path d="M6 6l12 12M18 6 6 18" />
208
- </svg>
209
- );
210
- }
211
-
212
- function IconChevron({ expanded }: { expanded: boolean }) {
213
- return (
214
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
215
- {expanded ? <path d="m6 14 6-6 6 6" /> : <path d="m6 10 6 6 6-6" />}
216
- </svg>
217
- );
218
- }
219
-
220
- function IconUndo() {
221
- return (
222
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
223
- <path d="M9 14 4 9l5-5" />
224
- <path d="M20 20a9 9 0 0 0-9-9H4" />
225
- </svg>
226
- );
227
- }
228
-
229
- function IconRedo() {
230
- return (
231
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
232
- <path d="m15 14 5-5-5-5" />
233
- <path d="M4 20a9 9 0 0 1 9-9h7" />
234
- </svg>
235
- );
236
- }
237
-
238
- function IconHistory() {
239
- return (
240
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
241
- <path d="M3 12a9 9 0 1 0 3-6.7" />
242
- <path d="M3 4v4h4" />
243
- <path d="M12 7v5l3 2" />
244
- </svg>
245
- );
246
- }
247
-
248
- function IconBringFront() {
249
- return (
250
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
251
- <path d="M7 17h10M12 17V7" />
252
- <path d="m8.5 9 3.5-3.5L15.5 9" />
253
- </svg>
254
- );
255
- }
256
-
257
- function IconSendBack() {
258
- return (
259
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
260
- <path d="M7 7h10M12 17V7" />
261
- <path d="m15.5 15-3.5 3.5L8.5 15" />
262
- </svg>
263
- );
264
- }
265
-
266
- function IconForward() {
267
- return (
268
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
269
- <path d="M12 18V6" />
270
- <path d="m8.5 9 3.5-3.5L15.5 9" />
271
- </svg>
272
- );
273
- }
274
-
275
- function IconBackward() {
276
- return (
277
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
278
- <path d="M12 6v12" />
279
- <path d="m15.5 15-3.5 3.5L8.5 15" />
280
- </svg>
281
- );
282
- }
283
-
284
- function IconLayers() {
285
- return (
286
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
287
- <path d="m12 4 8 4-8 4-8-4 8-4Z" />
288
- <path d="m4 12 8 4 8-4" />
289
- <path d="m4 16 8 4 8-4" />
290
- </svg>
291
- );
292
- }
293
-
294
- function IconImage() {
295
- return (
296
- <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
297
- <rect x="3" y="5" width="18" height="14" rx="2" />
298
- <circle cx="9" cy="10" r="1.5" />
299
- <path d="m21 16-5.5-5.5L7 19" />
300
- </svg>
301
- );
302
- }
303
-
304
- function formatRelativeTime(
305
- timestamp: number,
306
- now: number,
307
- t: (key: string, params?: Record<string, string | number>) => string
308
- ): string {
309
- const diff = Math.max(0, now - timestamp);
310
- const sec = Math.floor(diff / 1000);
311
- if (sec < 60) return t("common.secondsAgo", { sec });
312
- const min = Math.floor(sec / 60);
313
- if (min < 60) return t("common.minutesAgo", { min });
314
- const hour = Math.floor(min / 60);
315
- if (hour < 24) return t("common.hoursAgo", { hour });
316
- const day = Math.floor(hour / 24);
317
- return t("common.daysAgo", { day });
318
- }
319
-
320
- function getSelectedTargetsFromIds(
321
- container: HTMLElement | null,
322
- ids: string[],
323
- ) {
324
- if (!container) return [];
325
- const targets: HTMLElement[] = [];
326
- for (const id of ids) {
327
- const el = container.querySelector<HTMLElement>(
328
- `[data-element-id="${id}"]`,
329
- );
330
- if (el) targets.push(el);
331
- }
332
- return targets;
333
- }
334
-
335
- function shouldClearSelectionOnBlank(target: HTMLElement | null) {
336
- if (!target) return false;
337
- if (
338
- target.closest(".rv-selectable") ||
339
- target.closest(".moveable-control-box") ||
340
- target.closest(".moveable-group") ||
341
- target.closest(".moveable-line") ||
342
- target.closest(".moveable-control") ||
343
- target.closest(".moveable-direction")
344
- ) {
345
- return false;
346
- }
347
- if (
348
- target.closest("button") ||
349
- target.closest("input") ||
350
- target.closest("select") ||
351
- target.closest("textarea") ||
352
- target.closest("label") ||
353
- target.closest("a") ||
354
- target.closest("[role='menuitem']") ||
355
- target.closest("[data-radix-popper-content-wrapper]")
356
- ) {
357
- return false;
358
- }
359
- return true;
360
- }
361
-
362
125
  export type ReactViewPanelProps = {
363
126
  initialZoom?: number;
364
127
  className?: string;
365
- /** 外部传入的完整工作区数据,挂载后自动渲染 */
128
+ /** 外部传入的完整工作区数据,挂载后按这份数据完整渲染;空则显示空白,不自动打开 IndexedDB 记录 */
366
129
  initialWorkspace?: WorkspaceProjectRecord | null;
367
130
  /** 界面语言;省略时按 localStorage / 浏览器语言解析 */
368
131
  locale?: Locale | null;
369
132
  onLocaleChange?: (locale: Locale) => void;
133
+ /**
134
+ * 隔离本实例的 IndexedDB / localStorage / BroadcastChannel。
135
+ * 同一页面挂多个 App 时传入不同值,避免工作区、蓝图库、预览缓存互相覆盖。
136
+ * 省略或空字符串保持历史全局库名。
137
+ */
138
+ nameSpace?: string | null;
370
139
  };
371
140
 
372
141
  function ReactViewPanelInner({
373
142
  initialZoom = 1,
374
143
  className,
375
144
  initialWorkspace = null,
145
+ nameSpace = null,
376
146
  }: ReactViewPanelProps) {
377
147
  const { t, locale, setLocale } = useI18n();
378
148
  const messages = useMemo(() => getPanelMessages(t), [t]);
379
- const THEME_STORAGE_KEY = "panel:theme";
380
- const TITLE_ICON_STORAGE_KEY = "panel:titleIconDataUrl";
149
+ const THEME_STORAGE_KEY = appStorageKey("panel:theme", nameSpace);
150
+ const TITLE_ICON_STORAGE_KEY = appStorageKey("panel:titleIconDataUrl", nameSpace);
151
+ const FONT_SIZE_STORAGE_KEY = appStorageKey("panel:fontSize", nameSpace);
381
152
  const { setTheme } = useTheme();
382
153
  const themedScrollbarClass =
383
154
  "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";
@@ -483,7 +254,7 @@ function ReactViewPanelInner({
483
254
  const [titleIconZoom, setTitleIconZoom] = useState(1.6);
484
255
  const [pendingPreviewLayerId, setPendingPreviewLayerId] = useState<string | null>(null);
485
256
  const [panelFontSize, setPanelFontSize] = useState<"sm" | "md" | "lg">("md");
486
- const [outputScale, setOutputScale] = useState(() => readOutputScale());
257
+ const [outputScale, setOutputScale] = useState(() => readOutputScale(nameSpace));
487
258
  const [blueprintOpen, setBlueprintOpen] = useState(false);
488
259
  const [assistantOpen, setAssistantOpen] = useState(false);
489
260
  const [blueprintGraph, setBlueprintGraph] = useState(() => BlueprintGraph.empty());
@@ -577,35 +348,26 @@ function ReactViewPanelInner({
577
348
 
578
349
  useEffect(() => {
579
350
  try {
580
- const stored = localStorage.getItem("panel:fontSize");
351
+ const stored = localStorage.getItem(FONT_SIZE_STORAGE_KEY);
581
352
  if (stored === "sm" || stored === "md" || stored === "lg") {
582
353
  setPanelFontSize(stored);
583
354
  }
584
355
  } catch {
585
356
  // ignore storage errors
586
357
  }
587
- }, []);
358
+ }, [FONT_SIZE_STORAGE_KEY]);
588
359
 
589
360
  useEffect(() => {
590
361
  try {
591
- localStorage.setItem("panel:fontSize", panelFontSize);
362
+ localStorage.setItem(FONT_SIZE_STORAGE_KEY, panelFontSize);
592
363
  } catch {
593
364
  // ignore storage errors
594
365
  }
595
- }, [panelFontSize]);
366
+ }, [FONT_SIZE_STORAGE_KEY, panelFontSize]);
596
367
 
597
368
  useEffect(() => {
598
- writeOutputScale(outputScale);
599
- }, [outputScale]);
600
-
601
- useEffect(() => {
602
- try {
603
- const stored = localStorage.getItem(TITLE_ICON_STORAGE_KEY) ?? "";
604
- if (stored) setTitleIconDataUrl(stored);
605
- } catch {
606
- // ignore storage errors
607
- }
608
- }, [TITLE_ICON_STORAGE_KEY]);
369
+ writeOutputScale(outputScale, nameSpace);
370
+ }, [nameSpace, outputScale]);
609
371
 
610
372
  useEffect(() => {
611
373
  try {
@@ -865,13 +627,13 @@ function ReactViewPanelInner({
865
627
 
866
628
  const resolveLibraryBlueprint = useCallback<LibraryBlueprintResolver>(
867
629
  async (libraryBlueprintId) => {
868
- const record = await getBlueprintLibraryRecord(libraryBlueprintId);
630
+ const record = await getBlueprintLibraryRecord(libraryBlueprintId, nameSpace);
869
631
  if (!record) return null;
870
- const items = await listBlueprintLibrary();
632
+ const items = await listBlueprintLibrary(nameSpace);
871
633
  const nameById = new Map(items.map((item) => [item.id, item.name]));
872
634
  return documentToRunnableGraph(record.document, { libraryNameById: nameById });
873
635
  },
874
- []
636
+ [nameSpace]
875
637
  );
876
638
 
877
639
  const handleBlueprintExecutionBlocked = useCallback((message: string) => {
@@ -893,6 +655,7 @@ function ReactViewPanelInner({
893
655
  libraryNameById: blueprintLibraryNameById,
894
656
  onExecutionBlocked: handleBlueprintExecutionBlocked,
895
657
  onViewScopeUpdate: handleViewScopeUpdate,
658
+ nameSpace,
896
659
  });
897
660
 
898
661
  const { selectLifecycleNode, selectedLifecycleNodeId: debugLifecycleNodeId } =
@@ -994,9 +757,9 @@ function ReactViewPanelInner({
994
757
  );
995
758
 
996
759
  const refreshBlueprintLibrary = useCallback(async () => {
997
- const items = await listBlueprintLibrary();
760
+ const items = await listBlueprintLibrary(nameSpace);
998
761
  setBlueprintLibraryItems(items);
999
- }, []);
762
+ }, [nameSpace]);
1000
763
 
1001
764
  useEffect(() => {
1002
765
  void refreshBlueprintLibrary();
@@ -1004,7 +767,7 @@ function ReactViewPanelInner({
1004
767
 
1005
768
  const loadBlueprintFromLibrary = useCallback(
1006
769
  async (id: string) => {
1007
- const record = await getBlueprintLibraryRecord(id);
770
+ const record = await getBlueprintLibraryRecord(id, nameSpace);
1008
771
  if (!record) {
1009
772
  toast({ title: t("panel.messages.blueprintNotFound") });
1010
773
  void refreshBlueprintLibrary();
@@ -1023,7 +786,7 @@ function ReactViewPanelInner({
1023
786
  setBlueprintOpen(true);
1024
787
  });
1025
788
  },
1026
- [refreshBlueprintLibrary, t]
789
+ [nameSpace, refreshBlueprintLibrary, t]
1027
790
  );
1028
791
 
1029
792
  const snapshotWorkspaceBlueprint = useCallback(() => {
@@ -1078,7 +841,7 @@ function ReactViewPanelInner({
1078
841
  const syncBlueprintToLibrary = useCallback(async () => {
1079
842
  if (!activeBlueprintLibraryId) return;
1080
843
  await runBusyTask(t("common.syncingBlueprint"), async () => {
1081
- const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId);
844
+ const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId, nameSpace);
1082
845
  if (!existing) {
1083
846
  toast({ title: t("panel.messages.blueprintNotFound") });
1084
847
  void refreshBlueprintLibrary();
@@ -1094,7 +857,7 @@ function ReactViewPanelInner({
1094
857
  meta: blueprintMeta,
1095
858
  source: existing.source,
1096
859
  });
1097
- await putBlueprintLibraryRecord(record);
860
+ await putBlueprintLibraryRecord(record, nameSpace);
1098
861
  setBlueprintSyncedDocument(blueprintGraph.document);
1099
862
  await refreshBlueprintLibrary();
1100
863
  toast({ title: messages.blueprintSynced(record.name) });
@@ -1105,6 +868,7 @@ function ReactViewPanelInner({
1105
868
  blueprintMeta,
1106
869
  refreshBlueprintLibrary,
1107
870
  t,
871
+ nameSpace,
1108
872
  ]);
1109
873
 
1110
874
  const saveBlueprintToLibrary = useCallback(
@@ -1115,7 +879,7 @@ function ReactViewPanelInner({
1115
879
  let recordId = activeBlueprintLibraryId ?? undefined;
1116
880
  let createdAt: number | undefined;
1117
881
  if (activeBlueprintLibraryId) {
1118
- const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId);
882
+ const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId, nameSpace);
1119
883
  if (existing?.source === "saved") {
1120
884
  recordId = existing.id;
1121
885
  createdAt = existing.createdAt;
@@ -1133,14 +897,14 @@ function ReactViewPanelInner({
1133
897
  meta,
1134
898
  source: "saved",
1135
899
  });
1136
- await putBlueprintLibraryRecord(record);
900
+ await putBlueprintLibraryRecord(record, nameSpace);
1137
901
  setActiveBlueprintLibraryId(record.id);
1138
902
  setBlueprintSyncedDocument(blueprintGraph.document);
1139
903
  await refreshBlueprintLibrary();
1140
904
  toast({ title: t("panel.messages.blueprintSavedLocal") });
1141
905
  });
1142
906
  },
1143
- [activeBlueprintLibraryId, blueprintGraph.document, refreshBlueprintLibrary, t]
907
+ [activeBlueprintLibraryId, blueprintGraph.document, nameSpace, refreshBlueprintLibrary, t]
1144
908
  );
1145
909
 
1146
910
  const openBlueprintMetaDialog = useCallback((mode: "export" | "save") => {
@@ -1167,7 +931,7 @@ function ReactViewPanelInner({
1167
931
 
1168
932
  const handleRenameBlueprintLibraryItem = useCallback(
1169
933
  async (id: string, name: string) => {
1170
- const updated = await updateBlueprintLibraryMeta(id, { name });
934
+ const updated = await updateBlueprintLibraryMeta(id, { name }, nameSpace);
1171
935
  if (!updated) {
1172
936
  toast({ title: t("panel.messages.renameFailed") });
1173
937
  void refreshBlueprintLibrary();
@@ -1179,12 +943,12 @@ function ReactViewPanelInner({
1179
943
  }
1180
944
  toast({ title: t("panel.messages.blueprintRenamed") });
1181
945
  },
1182
- [activeBlueprintLibraryId, refreshBlueprintLibrary]
946
+ [activeBlueprintLibraryId, nameSpace, refreshBlueprintLibrary]
1183
947
  );
1184
948
 
1185
949
  const handleDeleteBlueprintLibraryItem = useCallback(
1186
950
  async (id: string) => {
1187
- await deleteBlueprintLibraryRecord(id);
951
+ await deleteBlueprintLibraryRecord(id, nameSpace);
1188
952
  await refreshBlueprintLibrary();
1189
953
  if (activeBlueprintLibraryId === id) {
1190
954
  setActiveBlueprintLibraryId(null);
@@ -1192,7 +956,7 @@ function ReactViewPanelInner({
1192
956
  }
1193
957
  toast({ title: t("panel.messages.blueprintDeletedFromLibrary") });
1194
958
  },
1195
- [activeBlueprintLibraryId, refreshBlueprintLibrary]
959
+ [activeBlueprintLibraryId, nameSpace, refreshBlueprintLibrary]
1196
960
  );
1197
961
 
1198
962
  const openBlueprintExportDialog = useCallback(() => {
@@ -1207,7 +971,7 @@ function ReactViewPanelInner({
1207
971
  const text = await file.text();
1208
972
  const payload = parseBlueprintImportFile(await parseJsonText(text));
1209
973
  const record = libraryRecordFromImport(payload);
1210
- await putBlueprintLibraryRecord(record);
974
+ await putBlueprintLibraryRecord(record, nameSpace);
1211
975
  await refreshBlueprintLibrary();
1212
976
  if (!activeBlueprintLibraryId) {
1213
977
  snapshotWorkspaceBlueprint();
@@ -1224,6 +988,7 @@ function ReactViewPanelInner({
1224
988
  [
1225
989
  activeBlueprintLibraryId,
1226
990
  loadBlueprintFromLibrary,
991
+ nameSpace,
1227
992
  refreshBlueprintLibrary,
1228
993
  snapshotWorkspaceBlueprint,
1229
994
  t,
@@ -1267,6 +1032,7 @@ function ReactViewPanelInner({
1267
1032
  panelRevision: `${historyCursor}|${allElements.length}`,
1268
1033
  onProjectApplied: handleWorkspaceProjectApplied,
1269
1034
  initialWorkspace,
1035
+ nameSpace,
1270
1036
  });
1271
1037
 
1272
1038
  useEffect(() => {
@@ -1276,11 +1042,11 @@ function ReactViewPanelInner({
1276
1042
  allElements,
1277
1043
  layers,
1278
1044
  activeLayerId,
1279
- outputScale: readOutputScale(),
1045
+ outputScale: readOutputScale(nameSpace),
1280
1046
  });
1281
1047
  });
1282
1048
  return () => registerPreviewSnapshotProvider(null);
1283
- }, [activeLayerId, allElements, canvasEl, layers]);
1049
+ }, [activeLayerId, allElements, canvasEl, layers, nameSpace]);
1284
1050
 
1285
1051
  const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
1286
1052
  graph: blueprintGraph,
@@ -2069,84 +1835,32 @@ function ReactViewPanelInner({
2069
1835
  }
2070
1836
  `}</style>
2071
1837
  <nav className="panel-font-root relative z-30 flex items-center gap-2 border-b border-border bg-background/95 px-2 text-foreground">
2072
- <Menubar className="h-8 border-0 bg-transparent p-0 shadow-none">
2073
- <MenubarMenu>
2074
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.file")}</MenubarTrigger>
2075
- <MenubarContent className="z-[10100]">
2076
- {/* <MenubarItem onClick={handlePreviewLayer}>{t("panel.workspace.previewDocTitle")}</MenubarItem> */}
2077
- <MenubarItem onClick={handleExport}>{t("panel.menubar.export")}</MenubarItem>
2078
- <MenubarItem onClick={() => importInputRef.current?.click()}>{t("panel.menubar.import")}</MenubarItem>
2079
- </MenubarContent>
2080
- </MenubarMenu>
2081
- <MenubarMenu>
2082
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.edit")}</MenubarTrigger>
2083
- <MenubarContent className="z-[10100]">
2084
- <MenubarItem disabled={!canUndo} onClick={undo}>{t("panel.menubar.undo")}</MenubarItem>
2085
- <MenubarItem disabled={!canRedo} onClick={redo}>{t("panel.menubar.redo")}</MenubarItem>
2086
- <MenubarSeparator />
2087
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsForward(selectedIds)}>{t("panel.menubar.bringForward")}</MenubarItem>
2088
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsBackward(selectedIds)}>{t("panel.menubar.sendBackward")}</MenubarItem>
2089
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsToFront(selectedIds)}>{t("panel.menubar.bringToFront")}</MenubarItem>
2090
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsToBack(selectedIds)}>{t("panel.menubar.sendToBack")}</MenubarItem>
2091
- </MenubarContent>
2092
- </MenubarMenu>
2093
- <MenubarMenu>
2094
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.view")}</MenubarTrigger>
2095
- <MenubarContent className="z-[10100]">
2096
- <MenubarItem onClick={() => adjustUniformZoom((z) => z - 0.1)}>{t("panel.menubar.zoomOut")}</MenubarItem>
2097
- <MenubarItem onClick={() => adjustUniformZoom((z) => z + 0.1)}>{t("panel.menubar.zoomIn")}</MenubarItem>
2098
- <MenubarSeparator />
2099
- <MenubarItem
2100
- onClick={() => {
2101
- applyTheme(!isDark);
2102
- }}
2103
- >
2104
- {isDark ? t("panel.menubar.switchToLight") : t("panel.menubar.switchToDark")}
2105
- </MenubarItem>
2106
- </MenubarContent>
2107
- </MenubarMenu>
2108
- <MenubarMenu>
2109
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.blueprint")}</MenubarTrigger>
2110
- <MenubarContent className="z-[10100]">
2111
- <MenubarItem onClick={openBlueprintExportDialog}>{t("panel.menubar.export")}</MenubarItem>
2112
- <MenubarItem onClick={() => blueprintImportInputRef.current?.click()}>
2113
- {t("panel.menubar.import")}
2114
- </MenubarItem>
2115
- </MenubarContent>
2116
- </MenubarMenu>
2117
- <MenubarMenu>
2118
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.settings")}</MenubarTrigger>
2119
- <MenubarContent className="z-[10100]">
2120
- <MenubarRadioGroup
2121
- value={panelFontSize}
2122
- onValueChange={(value) => setPanelFontSize(value as "sm" | "md" | "lg")}
2123
- >
2124
- <MenubarRadioItem value="sm">{t("panel.menubar.fontSmall")}</MenubarRadioItem>
2125
- <MenubarRadioItem value="md">{t("panel.menubar.fontMedium")}</MenubarRadioItem>
2126
- <MenubarRadioItem value="lg">{t("panel.menubar.fontLarge")}</MenubarRadioItem>
2127
- </MenubarRadioGroup>
2128
- <MenubarSeparator />
2129
- <div className="px-2 py-1.5 text-[11px] text-muted-foreground">
2130
- {t("panel.theme.language")}
2131
- </div>
2132
- <MenubarRadioGroup
2133
- value={locale}
2134
- onValueChange={(value) => setLocale(value as "zh-CN" | "en-US")}
2135
- >
2136
- <MenubarRadioItem value="zh-CN">{t("panel.theme.zhCN")}</MenubarRadioItem>
2137
- <MenubarRadioItem value="en-US">{t("panel.theme.enUS")}</MenubarRadioItem>
2138
- </MenubarRadioGroup>
2139
- <MenubarSeparator />
2140
- <MenubarCheckboxItem
2141
- checked={outputScale}
2142
- onCheckedChange={(checked) => setOutputScale(checked === true)}
2143
- title={t("panel.menubar.outputScaleHint")}
2144
- >
2145
- {t("panel.menubar.outputScale")}
2146
- </MenubarCheckboxItem>
2147
- </MenubarContent>
2148
- </MenubarMenu>
2149
- </Menubar>
1838
+ <PanelMenubar
1839
+ t={t}
1840
+ handleExport={handleExport}
1841
+ importInputRef={importInputRef}
1842
+ blueprintImportInputRef={blueprintImportInputRef}
1843
+ canUndo={canUndo}
1844
+ canRedo={canRedo}
1845
+ undo={undo}
1846
+ redo={redo}
1847
+ hasUnlockedSelection={hasUnlockedSelection}
1848
+ selectedIds={selectedIds}
1849
+ bringElementsForward={bringElementsForward}
1850
+ sendElementsBackward={sendElementsBackward}
1851
+ bringElementsToFront={bringElementsToFront}
1852
+ sendElementsToBack={sendElementsToBack}
1853
+ adjustUniformZoom={adjustUniformZoom}
1854
+ applyTheme={applyTheme}
1855
+ isDark={isDark}
1856
+ openBlueprintExportDialog={openBlueprintExportDialog}
1857
+ panelFontSize={panelFontSize}
1858
+ setPanelFontSize={setPanelFontSize}
1859
+ locale={locale}
1860
+ setLocale={setLocale}
1861
+ outputScale={outputScale}
1862
+ setOutputScale={setOutputScale}
1863
+ />
2150
1864
  <WorkspaceProjectNav
2151
1865
  projects={workspaceProjects.projects}
2152
1866
  activeProjectId={workspaceProjects.activeProjectId}
@@ -2351,122 +2065,19 @@ function ReactViewPanelInner({
2351
2065
  </TooltipTrigger>
2352
2066
  <TooltipContent className="z-[10000]">{t("panel.menubar.redoShortcut")}</TooltipContent>
2353
2067
  </Tooltip>
2354
- <Tooltip>
2355
- <TooltipTrigger asChild>
2356
- <div className="relative">
2357
- <button
2358
- type="button"
2359
- onClick={() => setIsHistoryPanelExpanded((prev) => !prev)}
2360
- className={[
2361
- "rounded border border-border p-1 hover:bg-accent",
2362
- isHistoryPanelExpanded ? "bg-accent/60" : "",
2363
- ].join(" ")}
2364
- aria-label={isHistoryPanelExpanded ? t("panel.history.collapse") : t("panel.history.expand")}
2365
- >
2366
- <IconHistory />
2367
- </button>
2368
- {isHistoryPanelExpanded ? (
2369
- <div
2370
- 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"
2371
- style={{ zIndex: PANEL_Z_INDEX.historyPopover }}
2372
- >
2373
- <div className="mb-1 flex items-center justify-between">
2374
- <span className="text-[11px] font-semibold text-muted-foreground">{t("panel.history.title")}</span>
2375
- <span className="text-[11px] text-muted-foreground">
2376
- {history.length > 0 ? `${historyCursor + 1}/${history.length}` : "0/0"}
2377
- </span>
2378
- </div>
2379
- <Input
2380
- value={historyKeyword}
2381
- onChange={(e) => setHistoryKeyword(e.target.value)}
2382
- placeholder={t("panel.history.searchPlaceholder")}
2383
- className="mb-2 h-7 text-xs"
2384
- />
2385
- <div
2386
- className={`max-h-52 space-y-1 overflow-auto pr-1 text-[11px] ${themedScrollbarClass}`}
2387
- >
2388
- {history.length === 0 ? (
2389
- <Empty className="py-4">
2390
- <EmptyIcon className="h-8 w-8">
2391
- <IconHistory />
2392
- </EmptyIcon>
2393
- <EmptyTitle className="text-xs">{t("panel.history.emptyTitle")}</EmptyTitle>
2394
- <EmptyDescription className="text-[11px]">
2395
- {t("panel.history.emptyDesc")}
2396
- </EmptyDescription>
2397
- </Empty>
2398
- ) : (
2399
- history
2400
- .slice(Math.max(0, history.length - 20))
2401
- .reverse()
2402
- .filter((item) =>
2403
- !normalizedHistoryKeyword
2404
- ? true
2405
- : item.label.toLowerCase().includes(normalizedHistoryKeyword)
2406
- )
2407
- .map((item) => (
2408
- <button
2409
- key={`${item.index}-${item.timestamp}`}
2410
- type="button"
2411
- className={[
2412
- "w-full rounded border px-2 py-1 text-left transition-colors",
2413
- item.active
2414
- ? "border-primary/50 bg-primary/10 text-foreground"
2415
- : "border-border/60 bg-background/70 text-muted-foreground hover:bg-accent/50",
2416
- ].join(" ")}
2417
- title={new Date(item.timestamp).toLocaleString()}
2418
- onClick={() => {
2419
- if (item.active) return;
2420
- goToHistory(item.index);
2421
- }}
2422
- >
2423
- <div className="flex items-center justify-between gap-2">
2424
- <span className="truncate">{item.label}</span>
2425
- <span className="shrink-0 text-[10px] text-muted-foreground">
2426
- {formatRelativeTime(item.timestamp, historyNow, t)}
2427
- </span>
2428
- </div>
2429
- </button>
2430
- ))
2431
- )}
2432
- {history.length > 0 &&
2433
- history
2434
- .slice(Math.max(0, history.length - 20))
2435
- .reverse()
2436
- .filter((item) =>
2437
- !normalizedHistoryKeyword
2438
- ? true
2439
- : item.label.toLowerCase().includes(normalizedHistoryKeyword)
2440
- ).length === 0 ? (
2441
- <Empty className="py-4">
2442
- <EmptyIcon className="h-8 w-8">
2443
- <svg
2444
- viewBox="0 0 24 24"
2445
- className="h-4 w-4"
2446
- fill="none"
2447
- stroke="currentColor"
2448
- strokeWidth="1.8"
2449
- aria-hidden="true"
2450
- >
2451
- <circle cx="11" cy="11" r="7" />
2452
- <path d="m20 20-3.5-3.5" />
2453
- </svg>
2454
- </EmptyIcon>
2455
- <EmptyTitle className="text-xs">{t("panel.history.noMatchTitle")}</EmptyTitle>
2456
- <EmptyDescription className="text-[11px]">
2457
- {t("panel.history.noMatchDesc")}
2458
- </EmptyDescription>
2459
- </Empty>
2460
- ) : null}
2461
- </div>
2462
- </div>
2463
- ) : null}
2464
- </div>
2465
- </TooltipTrigger>
2466
- <TooltipContent className="z-[10000]">
2467
- {isHistoryPanelExpanded ? t("panel.history.collapse") : t("panel.history.expand")}
2468
- </TooltipContent>
2469
- </Tooltip>
2068
+ <PanelHistoryPopover
2069
+ t={t}
2070
+ isHistoryPanelExpanded={isHistoryPanelExpanded}
2071
+ setIsHistoryPanelExpanded={setIsHistoryPanelExpanded}
2072
+ history={history}
2073
+ historyCursor={historyCursor}
2074
+ historyKeyword={historyKeyword}
2075
+ setHistoryKeyword={setHistoryKeyword}
2076
+ normalizedHistoryKeyword={normalizedHistoryKeyword}
2077
+ goToHistory={goToHistory}
2078
+ historyNow={historyNow}
2079
+ themedScrollbarClass={themedScrollbarClass}
2080
+ />
2470
2081
  </TooltipProvider>
2471
2082
  <DropdownMenu>
2472
2083
  <DropdownMenuTrigger asChild>
@@ -2982,328 +2593,36 @@ function ReactViewPanelInner({
2982
2593
  }
2983
2594
  />
2984
2595
 
2985
- <div className="border-t border-border bg-background/95 px-2 py-1.5">
2986
- <TooltipProvider delayDuration={120}>
2987
- <Tabs value={activeLayerId} onValueChange={setActiveLayer}>
2988
- <div className="mb-1 flex items-center gap-2">
2989
- <div className="text-[11px] font-semibold text-muted-foreground">{t("panel.layers.title")}</div>
2990
- <Tooltip>
2991
- <TooltipTrigger asChild>
2992
- <button
2993
- type="button"
2994
- onClick={() => setIsLayerPanelExpanded((v) => !v)}
2995
- aria-label={isLayerPanelExpanded ? t("panel.layers.collapseInputAria") : t("panel.layers.expandInputAria")}
2996
- className="rounded border border-border p-1 hover:bg-accent"
2997
- >
2998
- <IconChevron expanded={isLayerPanelExpanded} />
2999
- </button>
3000
- </TooltipTrigger>
3001
- <TooltipContent className="z-[10000]">
3002
- {isLayerPanelExpanded ? t("panel.layers.collapseInput") : t("panel.layers.expandInput")}
3003
- </TooltipContent>
3004
- </Tooltip>
3005
- <Tooltip>
3006
- <TooltipTrigger asChild>
3007
- <button
3008
- type="button"
3009
- onClick={addLayer}
3010
- aria-label={t("panel.layers.add")}
3011
- className="rounded border border-border p-1 hover:bg-accent"
3012
- >
3013
- <IconPlus />
3014
- </button>
3015
- </TooltipTrigger>
3016
- <TooltipContent className="z-[10000]">{t("panel.layers.add")}</TooltipContent>
3017
- </Tooltip>
3018
- <Tooltip>
3019
- <TooltipTrigger asChild>
3020
- <button
3021
- type="button"
3022
- onClick={() => {
3023
- if (!canMergeLayers) return;
3024
- setIsMergingLayers(true);
3025
- }}
3026
- aria-label={t("panel.layers.merge")}
3027
- disabled={!canMergeLayers}
3028
- className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
3029
- >
3030
- <IconMerge />
3031
- </button>
3032
- </TooltipTrigger>
3033
- <TooltipContent className="z-[10000]">
3034
- {canMergeLayers ? t("panel.layers.merge") : t("panel.layers.mergeNeedTwo")}
3035
- </TooltipContent>
3036
- </Tooltip>
3037
- </div>
3038
-
3039
- <TabsList
3040
- className={`h-auto w-full justify-start gap-1 overflow-x-auto bg-muted/40 p-1 ${themedScrollbarClass}`}
3041
- >
3042
- {layers.map((layer) => (
3043
- <TabsTrigger
3044
- key={layer.id}
3045
- value={layer.id}
3046
- className="flex min-w-[120px] items-center gap-1 px-2 py-1 text-xs"
3047
- >
3048
- <span className="truncate">{layer.name}</span>
3049
- {layer.isMapping ? (
3050
- <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
3051
- {t("panel.layers.mapping")}
3052
- </span>
3053
- ) : null}
3054
- {layer.isPrimary ? (
3055
- <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
3056
- {t("panel.layers.primary")}
3057
- </span>
3058
- ) : null}
3059
- {layer.locked ? <IconLock locked /> : null}
3060
- </TabsTrigger>
3061
- ))}
3062
- </TabsList>
3063
-
3064
- <TabsContent value={activeLayerId} className="mt-2 space-y-2">
3065
- {activeLayer ? (
3066
- <div className="flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs">
3067
- {editingLayerId === activeLayer.id ? (
3068
- <Input
3069
- value={editingLayerName}
3070
- onChange={(e) => setEditingLayerName(e.target.value)}
3071
- onKeyDown={(e) => {
3072
- if (e.key === "Enter") {
3073
- e.preventDefault();
3074
- renameLayer(editingLayerId, editingLayerName);
3075
- setEditingLayerId(null);
3076
- setEditingLayerName("");
3077
- return;
3078
- }
3079
- if (e.key === "Escape") {
3080
- e.preventDefault();
3081
- setEditingLayerId(null);
3082
- setEditingLayerName("");
3083
- }
3084
- }}
3085
- className="h-7 min-w-0 flex-1"
3086
- placeholder={t("panel.layers.namePlaceholder")}
3087
- autoFocus
3088
- />
3089
- ) : (
3090
- <span className="truncate font-medium">{activeLayer.name}</span>
3091
- )}
3092
- {activeLayer.isMapping ? (
3093
- <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
3094
- {t("panel.layers.mappingLayer")}
3095
- </span>
3096
- ) : null}
3097
- {activeLayer.isPrimary ? (
3098
- <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
3099
- {t("panel.layers.primaryLayer")}
3100
- </span>
3101
- ) : null}
3102
- <div className="flex-1" />
3103
- <Tooltip>
3104
- <TooltipTrigger asChild>
3105
- <button
3106
- type="button"
3107
- onClick={() => setPrimaryLayer(activeLayer.id)}
3108
- disabled={activeLayer.isPrimary}
3109
- aria-label={activeLayer.isPrimary ? t("panel.layers.isPrimary") : t("panel.layers.setPrimary")}
3110
- className={[
3111
- "rounded border px-2 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-60",
3112
- activeLayer.isPrimary
3113
- ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600"
3114
- : "border-border hover:bg-accent",
3115
- ].join(" ")}
3116
- >
3117
- {activeLayer.isPrimary ? t("panel.layers.isPrimary") : t("panel.layers.setPrimary")}
3118
- </button>
3119
- </TooltipTrigger>
3120
- <TooltipContent className="z-[10000]">
3121
- {activeLayer.isPrimary ? t("panel.layers.isPrimaryHint") : t("panel.layers.setPrimaryHint")}
3122
- </TooltipContent>
3123
- </Tooltip>
3124
- <Tooltip>
3125
- <TooltipTrigger asChild>
3126
- <button
3127
- type="button"
3128
- onClick={() => toggleLayerLock(activeLayer.id)}
3129
- disabled={!activeLayer.editable}
3130
- aria-label={activeLayer.locked ? t("panel.layers.unlock") : t("panel.layers.lock")}
3131
- className="rounded border border-border p-1 disabled:opacity-40"
3132
- >
3133
- <IconLock locked={activeLayer.locked} />
3134
- </button>
3135
- </TooltipTrigger>
3136
- <TooltipContent className="z-[10000]">
3137
- {activeLayer.locked ? t("panel.layers.unlock") : t("panel.layers.lock")}
3138
- </TooltipContent>
3139
- </Tooltip>
3140
- <Tooltip>
3141
- <TooltipTrigger asChild>
3142
- {editingLayerId === activeLayer.id ? (
3143
- <button
3144
- type="button"
3145
- onClick={() => {
3146
- renameLayer(editingLayerId, editingLayerName);
3147
- setEditingLayerId(null);
3148
- setEditingLayerName("");
3149
- }}
3150
- aria-label={t("panel.layers.saveName")}
3151
- className="rounded border border-border p-1 hover:bg-accent"
3152
- >
3153
- <IconCheck />
3154
- </button>
3155
- ) : (
3156
- <button
3157
- type="button"
3158
- onClick={() => {
3159
- setEditingLayerId(activeLayer.id);
3160
- setEditingLayerName(activeLayer.name);
3161
- }}
3162
- disabled={!activeLayer.editable}
3163
- aria-label={t("panel.layers.rename")}
3164
- className="rounded border border-border p-1 disabled:opacity-40"
3165
- >
3166
- <IconEdit />
3167
- </button>
3168
- )}
3169
- </TooltipTrigger>
3170
- <TooltipContent className="z-[10000]">
3171
- {editingLayerId === activeLayer.id ? t("panel.layers.saveName") : t("panel.layers.rename")}
3172
- </TooltipContent>
3173
- </Tooltip>
3174
- {editingLayerId === activeLayer.id ? (
3175
- <Tooltip>
3176
- <TooltipTrigger asChild>
3177
- <button
3178
- type="button"
3179
- onClick={() => {
3180
- setEditingLayerId(null);
3181
- setEditingLayerName("");
3182
- }}
3183
- aria-label={t("panel.layers.cancelEditName")}
3184
- className="rounded border border-border p-1 hover:bg-accent"
3185
- >
3186
- <IconClose />
3187
- </button>
3188
- </TooltipTrigger>
3189
- <TooltipContent className="z-[10000]">{t("panel.layers.cancelEdit")}</TooltipContent>
3190
- </Tooltip>
3191
- ) : null}
3192
- <Tooltip>
3193
- <TooltipTrigger asChild>
3194
- <button
3195
- type="button"
3196
- onClick={() => {
3197
- const blockReason = getLayerDeleteBlockReason(activeLayer.id);
3198
- if (blockReason) {
3199
- showActionHint(blockReason);
3200
- return;
3201
- }
3202
- setConfirmDeleteLayerId(activeLayer.id);
3203
- const firstTarget = layers.find((l) => l.id !== activeLayer.id);
3204
- setDeleteTargetLayerId(firstTarget?.id ?? "");
3205
- setDeleteMode(activeLayer.isMapping ? "remove" : "move");
3206
- }}
3207
- disabled={!activeLayer.editable || activeLayer.locked}
3208
- aria-label={
3209
- !activeLayer.editable
3210
- ? t("panel.layers.cannotDeleteDefault")
3211
- : activeLayer.locked
3212
- ? t("panel.layers.cannotDeleteLocked")
3213
- : t("panel.layers.delete")
3214
- }
3215
- className="rounded border border-border p-1 disabled:opacity-40"
3216
- >
3217
- <IconTrash />
3218
- </button>
3219
- </TooltipTrigger>
3220
- <TooltipContent className="z-[10000]">
3221
- {!activeLayer.editable
3222
- ? t("panel.layers.cannotDeleteDefault")
3223
- : activeLayer.locked
3224
- ? t("panel.layers.cannotDeleteLocked")
3225
- : t("panel.layers.delete")}
3226
- </TooltipContent>
3227
- </Tooltip>
3228
- </div>
3229
- ) : null}
3230
-
3231
- {isLayerPanelExpanded ? (
3232
- <div className="rounded border border-border bg-card px-2 py-1.5 text-xs">
3233
- <div className="mb-1 text-muted-foreground">{t("panel.layers.selectMergeLayers")}</div>
3234
- <div className="flex flex-wrap gap-2">
3235
- {layers.map((layer) => (
3236
- <label key={layer.id} className="flex items-center gap-1.5">
3237
- <Checkbox
3238
- checked={Boolean(layer.mergeSelected)}
3239
- disabled={layer.isMapping}
3240
- 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"
3241
- onCheckedChange={() => toggleLayerMergeSelected(layer.id)}
3242
- />
3243
- <span
3244
- className="max-w-[140px] truncate"
3245
- title={layer.isMapping ? t("panel.layers.mappingCannotMerge") : layer.name}
3246
- >
3247
- {layer.name}
3248
- {layer.isMapping ? t("panel.layers.cannotMergeSuffix") : ""}
3249
- </span>
3250
- </label>
3251
- ))}
3252
- </div>
3253
- </div>
3254
- ) : null}
3255
- </TabsContent>
3256
- </Tabs>
3257
-
3258
- {isLayerPanelExpanded && isMergingLayers ? (
3259
- <div className="mt-2 flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs">
3260
- <span className="text-muted-foreground">{t("panel.layers.mergedName")}</span>
3261
- <Input
3262
- value={mergeLayerName}
3263
- onChange={(e) => setMergeLayerName(e.target.value)}
3264
- className="h-7 min-w-0 flex-1"
3265
- placeholder={t("panel.layers.mergedNamePlaceholder")}
3266
- autoFocus
3267
- />
3268
- <Tooltip>
3269
- <TooltipTrigger asChild>
3270
- <button
3271
- type="button"
3272
- onClick={() => {
3273
- if (!canMergeLayers) return;
3274
- mergeSelectedLayers(mergeLayerName || undefined);
3275
- setIsMergingLayers(false);
3276
- setMergeLayerName("");
3277
- }}
3278
- aria-label={t("panel.layers.confirmMergeAria")}
3279
- disabled={!canMergeLayers}
3280
- className="rounded border border-border p-1 hover:bg-accent"
3281
- >
3282
- <IconCheck />
3283
- </button>
3284
- </TooltipTrigger>
3285
- <TooltipContent className="z-[10000]">{t("panel.layers.confirmMerge")}</TooltipContent>
3286
- </Tooltip>
3287
- <Tooltip>
3288
- <TooltipTrigger asChild>
3289
- <button
3290
- type="button"
3291
- onClick={() => {
3292
- setIsMergingLayers(false);
3293
- setMergeLayerName("");
3294
- }}
3295
- aria-label={t("panel.layers.cancelMergeAria")}
3296
- className="rounded border border-border p-1 hover:bg-accent"
3297
- >
3298
- <IconClose />
3299
- </button>
3300
- </TooltipTrigger>
3301
- <TooltipContent className="z-[10000]">{t("panel.layers.cancelMerge")}</TooltipContent>
3302
- </Tooltip>
3303
- </div>
3304
- ) : null}
3305
- </TooltipProvider>
3306
- </div>
2596
+ <PanelLayerDock
2597
+ t={t}
2598
+ isLayerPanelExpanded={isLayerPanelExpanded}
2599
+ setIsLayerPanelExpanded={setIsLayerPanelExpanded}
2600
+ addLayer={addLayer}
2601
+ canMergeLayers={canMergeLayers}
2602
+ setIsMergingLayers={setIsMergingLayers}
2603
+ isMergingLayers={isMergingLayers}
2604
+ layers={layers}
2605
+ activeLayerId={activeLayerId}
2606
+ setActiveLayer={setActiveLayer}
2607
+ themedScrollbarClass={themedScrollbarClass}
2608
+ activeLayer={activeLayer}
2609
+ editingLayerId={editingLayerId}
2610
+ setEditingLayerId={setEditingLayerId}
2611
+ editingLayerName={editingLayerName}
2612
+ setEditingLayerName={setEditingLayerName}
2613
+ renameLayer={renameLayer}
2614
+ setPrimaryLayer={setPrimaryLayer}
2615
+ toggleLayerLock={toggleLayerLock}
2616
+ getLayerDeleteBlockReason={getLayerDeleteBlockReason}
2617
+ showActionHint={showActionHint}
2618
+ setConfirmDeleteLayerId={setConfirmDeleteLayerId}
2619
+ setDeleteTargetLayerId={setDeleteTargetLayerId}
2620
+ setDeleteMode={setDeleteMode}
2621
+ toggleLayerMergeSelected={toggleLayerMergeSelected}
2622
+ mergeLayerName={mergeLayerName}
2623
+ setMergeLayerName={setMergeLayerName}
2624
+ mergeSelectedLayers={mergeSelectedLayers}
2625
+ />
3307
2626
  </div>
3308
2627
  </div>
3309
2628
  </ResizablePanel>
@@ -3367,199 +2686,27 @@ function ReactViewPanelInner({
3367
2686
  open={assistantOpen}
3368
2687
  onOpenChange={setAssistantOpen}
3369
2688
  />
3370
- <AlertDialog
3371
- open={mappingDeleteConfirmOpen}
3372
- onOpenChange={(open) => {
3373
- setMappingDeleteConfirmOpen(open);
3374
- if (!open) mappingDeleteProceedRef.current = null;
3375
- }}
3376
- >
3377
- <AlertDialogContent overlayClassName="bg-transparent pointer-events-none">
3378
- <AlertDialogHeader>
3379
- <AlertDialogTitle>{t("panel.layers.confirmDeleteTitle")}</AlertDialogTitle>
3380
- <AlertDialogDescription>
3381
- {t("panel.layers.confirmDeleteMappingBody", {
3382
- count: mappingDeleteImpactCount,
3383
- })}
3384
- </AlertDialogDescription>
3385
- </AlertDialogHeader>
3386
- <AlertDialogFooter>
3387
- <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
3388
- <AlertDialogAction
3389
- onClick={() => {
3390
- const fn = mappingDeleteProceedRef.current;
3391
- mappingDeleteProceedRef.current = null;
3392
- setMappingDeleteConfirmOpen(false);
3393
- fn?.();
3394
- }}
3395
- >
3396
- {t("panel.layers.continueDelete")}
3397
- </AlertDialogAction>
3398
- </AlertDialogFooter>
3399
- </AlertDialogContent>
3400
- </AlertDialog>
3401
- <Dialog
3402
- open={Boolean(deletingLayer)}
3403
- onOpenChange={(open) => {
3404
- if (open) return;
3405
- setConfirmDeleteLayerId(null);
3406
- setDeleteMode("move");
3407
- setDeleteTargetLayerId("");
3408
- }}
3409
- >
3410
- <DialogContent
3411
- className="sm:max-w-[520px]"
3412
- overlayClassName="bg-transparent pointer-events-none"
3413
- >
3414
- <DialogHeader>
3415
- <DialogTitle>{t("panel.layers.confirmDeleteLayerTitle")}</DialogTitle>
3416
- <DialogDescription>
3417
- {t("panel.layers.aboutToDeleteLayer", {
3418
- name: deletingLayer?.name ?? "-",
3419
- })}
3420
- </DialogDescription>
3421
- </DialogHeader>
3422
- {deletingLayer ? (
3423
- <RadioGroup
3424
- value={deletingLayerMode}
3425
- onValueChange={(value) => setDeleteMode(value as "move" | "remove")}
3426
- className="space-y-2 text-sm"
3427
- >
3428
- {!deletingLayer.isMapping ? (
3429
- <div className="flex items-center gap-3">
3430
- <label className="flex items-center gap-1.5">
3431
- <RadioGroupItem value="move" />
3432
- {t("panel.layers.migrateNodesTo")}
3433
- </label>
3434
- <Select
3435
- value={deleteTargetLayerId || "__none__"}
3436
- onValueChange={(value) =>
3437
- setDeleteTargetLayerId(value === "__none__" ? "" : value)
3438
- }
3439
- disabled={deletingLayerMode !== "move" || deleteTargetCandidates.length === 0}
3440
- >
3441
- <SelectTrigger className="h-8 w-[220px]">
3442
- <SelectValue placeholder={t("panel.layers.selectTargetLayer")} />
3443
- </SelectTrigger>
3444
- <SelectContent>
3445
- <SelectItem value="__none__">{t("panel.layers.pleaseSelectLayer")}</SelectItem>
3446
- {deleteTargetCandidates.map((l) => (
3447
- <SelectItem key={l.id} value={l.id}>
3448
- {l.name}
3449
- </SelectItem>
3450
- ))}
3451
- </SelectContent>
3452
- </Select>
3453
- </div>
3454
- ) : null}
3455
- <label className="flex items-center gap-1.5">
3456
- <RadioGroupItem value="remove" />
3457
- {deletingLayer.isMapping ? t("panel.layers.deleteMappingWithNodes") : t("panel.layers.deleteAllNodesToo")}
3458
- </label>
3459
- </RadioGroup>
3460
- ) : null}
3461
- <DialogFooter>
3462
- <button
3463
- type="button"
3464
- onClick={() => {
3465
- setConfirmDeleteLayerId(null);
3466
- setDeleteMode("move");
3467
- setDeleteTargetLayerId("");
3468
- }}
3469
- className="rounded border border-border px-3 py-1.5 text-sm hover:bg-accent"
3470
- >
3471
- {t("common.cancel")}
3472
- </button>
3473
- <button
3474
- type="button"
3475
- onClick={() => {
3476
- if (!deletingLayer) return;
3477
- const result = deleteLayer(deletingLayer.id, {
3478
- mode: deletingLayerMode,
3479
- targetLayerId: deleteTargetLayerId || undefined,
3480
- });
3481
- if (!result.ok) {
3482
- showActionHint(result.reason);
3483
- return;
3484
- }
3485
- setConfirmDeleteLayerId(null);
3486
- setDeleteMode("move");
3487
- setDeleteTargetLayerId("");
3488
- }}
3489
- className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
3490
- >
3491
- {t("panel.layers.confirmDeleteTitle")}
3492
- </button>
3493
- </DialogFooter>
3494
- </DialogContent>
3495
- </Dialog>
3496
- <Dialog
3497
- open={titleIconPreviewOpen}
3498
- onOpenChange={setTitleIconPreviewOpen}
3499
- >
3500
- <DialogContent
3501
- className="sm:max-w-[860px]"
3502
- overlayClassName="bg-transparent pointer-events-none"
3503
- >
3504
- <DialogHeader>
3505
- <DialogTitle>{t("panel.menubar.titleIconPreviewTitle")}</DialogTitle>
3506
- <DialogDescription>{t("panel.menubar.titleIconPreviewDesc")}</DialogDescription>
3507
- </DialogHeader>
3508
- <div className="flex items-center justify-end gap-2">
3509
- <button
3510
- type="button"
3511
- onClick={() => setTitleIconZoom((z) => Math.max(0.5, Number((z - 0.1).toFixed(2))))}
3512
- className="rounded border border-border px-2 py-1 text-xs hover:bg-accent"
3513
- >
3514
- -
3515
- </button>
3516
- <span className="w-14 text-center text-xs text-muted-foreground">
3517
- {(titleIconZoom * 100).toFixed(0)}%
3518
- </span>
3519
- <button
3520
- type="button"
3521
- onClick={() => setTitleIconZoom((z) => Math.min(8, Number((z + 0.1).toFixed(2))))}
3522
- className="rounded border border-border px-2 py-1 text-xs hover:bg-accent"
3523
- >
3524
- +
3525
- </button>
3526
- <button
3527
- type="button"
3528
- onClick={() => setTitleIconZoom(1.6)}
3529
- className="rounded border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
3530
- >
3531
- {t("common.reset")}
3532
- </button>
3533
- </div>
3534
- {titleIconDataUrl ? (
3535
- <div
3536
- className="max-h-[78vh] overflow-auto rounded border border-border bg-muted/20 p-4"
3537
- onWheel={(e) => {
3538
- e.preventDefault();
3539
- const delta = e.deltaY > 0 ? -0.1 : 0.1;
3540
- setTitleIconZoom((z) => {
3541
- const next = Number((z + delta).toFixed(2));
3542
- return Math.min(8, Math.max(0.5, next));
3543
- });
3544
- }}
3545
- >
3546
- <img
3547
- src={titleIconDataUrl}
3548
- alt={t("panel.menubar.titleIconPreviewAlt")}
3549
- className="mx-auto rounded border border-border/70 object-contain"
3550
- style={{
3551
- width: 200,
3552
- height: 200,
3553
- maxWidth: "none",
3554
- maxHeight: "none",
3555
- transform: `scale(${titleIconZoom})`,
3556
- transformOrigin: "center center",
3557
- }}
3558
- />
3559
- </div>
3560
- ) : null}
3561
- </DialogContent>
3562
- </Dialog>
2689
+ <PanelWorkspaceDialogs
2690
+ t={t}
2691
+ mappingDeleteConfirmOpen={mappingDeleteConfirmOpen}
2692
+ setMappingDeleteConfirmOpen={setMappingDeleteConfirmOpen}
2693
+ mappingDeleteProceedRef={mappingDeleteProceedRef}
2694
+ mappingDeleteImpactCount={mappingDeleteImpactCount}
2695
+ deletingLayer={deletingLayer}
2696
+ deletingLayerMode={deletingLayerMode}
2697
+ setConfirmDeleteLayerId={setConfirmDeleteLayerId}
2698
+ setDeleteMode={setDeleteMode}
2699
+ setDeleteTargetLayerId={setDeleteTargetLayerId}
2700
+ deleteTargetLayerId={deleteTargetLayerId}
2701
+ deleteTargetCandidates={deleteTargetCandidates}
2702
+ deleteLayer={deleteLayer}
2703
+ showActionHint={showActionHint}
2704
+ titleIconPreviewOpen={titleIconPreviewOpen}
2705
+ setTitleIconPreviewOpen={setTitleIconPreviewOpen}
2706
+ titleIconZoom={titleIconZoom}
2707
+ setTitleIconZoom={setTitleIconZoom}
2708
+ titleIconDataUrl={titleIconDataUrl}
2709
+ />
3563
2710
  <BlueprintMetaDialog
3564
2711
  open={blueprintMetaDialogOpen}
3565
2712
  mode={blueprintMetaDialogMode}
@@ -3589,11 +2736,12 @@ function ReactViewPanelInner({
3589
2736
  export function ReactViewPanel({
3590
2737
  locale,
3591
2738
  onLocaleChange,
2739
+ nameSpace,
3592
2740
  ...rest
3593
2741
  }: ReactViewPanelProps) {
3594
2742
  return (
3595
- <I18nRoot locale={locale} onLocaleChange={onLocaleChange}>
3596
- <ReactViewPanelInner {...rest} />
2743
+ <I18nRoot locale={locale} onLocaleChange={onLocaleChange} nameSpace={nameSpace}>
2744
+ <ReactViewPanelInner nameSpace={nameSpace} {...rest} />
3597
2745
  </I18nRoot>
3598
2746
  );
3599
2747
  }