@arronqzy/react-view 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/aix-ppc64.json +1 -0
  2. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm.json +1 -0
  3. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm64.json +1 -0
  4. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-x64.json +1 -0
  5. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-arm64.json +1 -0
  6. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-x64.json +1 -0
  7. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-arm64.json +1 -0
  8. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-x64.json +1 -0
  9. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm.json +1 -0
  10. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm64.json +1 -0
  11. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ia32.json +1 -0
  12. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-loong64.json +1 -0
  13. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-mips64el.json +1 -0
  14. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ppc64.json +1 -0
  15. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-riscv64.json +1 -0
  16. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-s390x.json +1 -0
  17. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-x64.json +1 -0
  18. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/netbsd-x64.json +1 -0
  19. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/openbsd-x64.json +1 -0
  20. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/sunos-x64.json +1 -0
  21. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-arm64.json +1 -0
  22. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-ia32.json +1 -0
  23. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-x64.json +1 -0
  24. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm-eabi.json +1 -0
  25. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm64.json +1 -0
  26. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-arm64.json +1 -0
  27. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-x64.json +1 -0
  28. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-arm64.json +1 -0
  29. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-x64.json +1 -0
  30. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf.json +1 -0
  31. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf.json +1 -0
  32. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-gnu.json +1 -0
  33. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-musl.json +1 -0
  34. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu.json +1 -0
  35. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu.json +1 -0
  36. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu.json +1 -0
  37. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-musl.json +1 -0
  38. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-s390x-gnu.json +1 -0
  39. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-gnu.json +1 -0
  40. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-musl.json +1 -0
  41. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-arm64-msvc.json +1 -0
  42. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-ia32-msvc.json +1 -0
  43. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-x64-msvc.json +1 -0
  44. package/.pnpm-cache/metadata/registry.npmjs.org/eslint.json +1 -0
  45. package/.pnpm-cache/metadata/registry.npmjs.org/fsevents.json +1 -0
  46. package/.pnpm-cache/metadata/registry.npmjs.org/pnpm.json +1 -0
  47. package/.pnpm-cache/metadata/registry.npmjs.org/react-dom.json +1 -0
  48. package/.pnpm-cache/metadata/registry.npmjs.org/react-is.json +1 -0
  49. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-64.json +1 -0
  50. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-arm64.json +1 -0
  51. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-64.json +1 -0
  52. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-arm64.json +1 -0
  53. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-64.json +1 -0
  54. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-arm64.json +1 -0
  55. package/.pnpm-cache/metadata/registry.npmjs.org/typescript.json +1 -0
  56. package/.turbo/turbo-lint.log +4 -0
  57. package/dist/styles.css +2624 -0
  58. package/env.d.ts +11 -0
  59. package/operation-guide.md +275 -0
  60. package/package.json +43 -0
  61. package/postcss.config.cjs +7 -0
  62. package/readme.md +21 -0
  63. package/src/core/createAction.ts +5 -0
  64. package/src/core/createView.ts +28 -0
  65. package/src/core/instructions.tsx +57 -0
  66. package/src/core/nodePool.ts +127 -0
  67. package/src/core/signal.ts +130 -0
  68. package/src/core/template.tsx +25 -0
  69. package/src/index.ts +7 -0
  70. package/src/panel/ReactViewOnlinePreview.tsx +331 -0
  71. package/src/panel/ReactViewPanel.tsx +3221 -0
  72. package/src/panel/components/ElementsLayer.tsx +891 -0
  73. package/src/panel/components/MaterialSidebar.tsx +1142 -0
  74. package/src/panel/components/MoveableLayer.tsx +803 -0
  75. package/src/panel/components/PanelCanvas.tsx +660 -0
  76. package/src/panel/components/PanelConfigSidebar.tsx +3703 -0
  77. package/src/panel/components/PanelRulers.tsx +89 -0
  78. package/src/panel/components/SelectLayer.tsx +106 -0
  79. package/src/panel/components/ViewElementScopePanel.tsx +79 -0
  80. package/src/panel/components/WorkspaceConfigSidebar.tsx +158 -0
  81. package/src/panel/components/WorkspaceProjectNav.tsx +220 -0
  82. package/src/panel/components/WorkspaceStageSplit.tsx +179 -0
  83. package/src/panel/components/scope-config/ScopeConfigContext.tsx +170 -0
  84. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +250 -0
  85. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +59 -0
  86. package/src/panel/constants/messages.ts +34 -0
  87. package/src/panel/constants/zIndex.ts +6 -0
  88. package/src/panel/hooks/usePanelElements.ts +1108 -0
  89. package/src/panel/hooks/useWorkspaceProjects.ts +278 -0
  90. package/src/panel/index.tsx +3 -0
  91. package/src/panel/library/workspace-project-cache.ts +23 -0
  92. package/src/panel/library/workspace-project-db.ts +112 -0
  93. package/src/panel/library/workspace-project-sync.ts +41 -0
  94. package/src/panel/library/workspace-snapshot.ts +27 -0
  95. package/src/panel/scope/view-scope-store.ts +55 -0
  96. package/src/panel/types.ts +125 -0
  97. package/src/panel/utils/chartOptionBuilder.ts +316 -0
  98. package/src/panel/utils/gridPlacement.ts +189 -0
  99. package/src/panel/utils/mappingLayerOps.ts +142 -0
  100. package/src/panel/utils/panelElementDefaults.ts +161 -0
  101. package/src/panel/utils/panelElementNodes.ts +35 -0
  102. package/src/panel/utils/panelStateIO.ts +124 -0
  103. package/src/panel/utils/scope-autocomplete.ts +114 -0
  104. package/src/panel/utils/scope-field-labels.ts +45 -0
  105. package/src/panel/utils/scope-template-warnings.ts +135 -0
  106. package/src/panel/utils/scope-template.ts +75 -0
  107. package/src/panel/utils/updateElementDraft.ts +221 -0
  108. package/src/panel/viewportZoom.ts +26 -0
  109. package/src/tailwind.css +58 -0
  110. package/src/types/react-infinite-viewer.d.ts +39 -0
  111. package/tailwind.config.ts +7 -0
  112. package/tsconfig.json +14 -0
@@ -0,0 +1,1142 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardHeader,
6
+ Collapsible,
7
+ CollapsibleContent,
8
+ CollapsibleTrigger,
9
+ Empty,
10
+ EmptyDescription,
11
+ EmptyIcon,
12
+ EmptyTitle,
13
+ Input,
14
+ Switch,
15
+ Tabs,
16
+ TabsContent,
17
+ TabsList,
18
+ TabsTrigger,
19
+ Tooltip,
20
+ TooltipContent,
21
+ TooltipProvider,
22
+ TooltipTrigger,
23
+ } from "@arronqzy/ui";
24
+ import type { PanelElement, ReferenceCopyMode } from "../types";
25
+ import type { PanelLayer } from "../types";
26
+ import { PANEL_MESSAGES } from "../constants/messages";
27
+ import {
28
+ concreteGridParentIdForLayer,
29
+ logicalGridParentIdFromConcrete,
30
+ } from "../utils/mappingLayerOps";
31
+
32
+ type MaterialCategoryId = "charts" | "basic" | "media";
33
+
34
+ type MaterialItem = {
35
+ id: string;
36
+ title: string;
37
+ };
38
+
39
+ type MaterialCategory = {
40
+ id: MaterialCategoryId;
41
+ title: string;
42
+ items: MaterialItem[];
43
+ };
44
+
45
+ /** 节点树中网格子节点顺序:与画布槽位一致,嵌套任意深度一致 */
46
+ function compareGridTreeChildOrder(a: PanelElement, b: PanelElement): number {
47
+ const ai = typeof a.gridSlotIndex === "number" ? a.gridSlotIndex : 0;
48
+ const bi = typeof b.gridSlotIndex === "number" ? b.gridSlotIndex : 0;
49
+ if (ai !== bi) return ai - bi;
50
+ return a.id.localeCompare(b.id);
51
+ }
52
+
53
+ const MATERIAL_LABEL_MAP: Record<string, string> = {
54
+ bar: "柱状图",
55
+ line: "折线图",
56
+ pie: "饼图",
57
+ area: "面积图",
58
+ scatter: "散点图",
59
+ radar: "雷达图",
60
+ gauge: "仪表盘",
61
+ funnel: "漏斗图",
62
+ text: "文本",
63
+ rect: "矩形",
64
+ grid: "网格布局",
65
+ image: "图片",
66
+ video: "视频",
67
+ audio: "音频",
68
+ reference: "引用组件",
69
+ geometry: "几何",
70
+ };
71
+
72
+ function LockGlyph({ className = "h-3.5 w-3.5" }: { className?: string }) {
73
+ return (
74
+ <svg
75
+ viewBox="0 0 24 24"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ strokeWidth="1.9"
79
+ strokeLinecap="round"
80
+ strokeLinejoin="round"
81
+ className={className}
82
+ aria-hidden="true"
83
+ >
84
+ <rect x="4.5" y="10.5" width="15" height="10" rx="2.5" />
85
+ <path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
86
+ <circle cx="12" cy="15.5" r="1.2" />
87
+ </svg>
88
+ );
89
+ }
90
+
91
+ function IconCopy({ className = "h-3.5 w-3.5" }: { className?: string }) {
92
+ return (
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" className={className}>
94
+ <rect x="9" y="9" width="10" height="10" rx="2" />
95
+ <rect x="5" y="5" width="10" height="10" rx="2" />
96
+ </svg>
97
+ );
98
+ }
99
+
100
+ function IconDelete({ className = "h-3.5 w-3.5" }: { className?: string }) {
101
+ return (
102
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" className={className}>
103
+ <path d="M4 7h16" />
104
+ <path d="M9 7V5h6v2" />
105
+ <path d="M7 7l1 12h8l1-12" />
106
+ <path d="M10 11v5M14 11v5" />
107
+ </svg>
108
+ );
109
+ }
110
+
111
+ function IconShallowCopy({ className = "h-3.5 w-3.5" }: { className?: string }) {
112
+ return (
113
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" className={className}>
114
+ <rect x="8.5" y="8.5" width="10" height="10" rx="2" />
115
+ <rect x="5.5" y="5.5" width="10" height="10" rx="2" />
116
+ <path d="M7.5 16.5 16.5 7.5" strokeDasharray="2 2" />
117
+ </svg>
118
+ );
119
+ }
120
+
121
+ function IconDeepCopy({ className = "h-3.5 w-3.5" }: { className?: string }) {
122
+ return (
123
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" className={className}>
124
+ <rect x="4.5" y="4.5" width="15" height="4.2" rx="1.6" />
125
+ <rect x="4.5" y="9.9" width="15" height="4.2" rx="1.6" />
126
+ <rect x="4.5" y="15.3" width="15" height="4.2" rx="1.6" />
127
+ </svg>
128
+ );
129
+ }
130
+
131
+ function MaterialPreview({ id }: { id: string }) {
132
+ const common =
133
+ "relative aspect-[4/3] w-full shrink-0 overflow-hidden rounded-md border border-border bg-gradient-to-br from-card to-muted/40";
134
+
135
+ if (id === "bar") {
136
+ return (
137
+ <div className={common}>
138
+ <div className="absolute inset-0 grid grid-cols-5 items-end gap-1 px-2 pb-2 pt-1.5">
139
+ <div className="h-2 rounded-sm bg-primary/55" />
140
+ <div className="h-4 rounded-sm bg-primary/65" />
141
+ <div className="h-7 rounded-sm bg-primary/80" />
142
+ <div className="h-5 rounded-sm bg-primary/70" />
143
+ <div className="h-3 rounded-sm bg-primary/60" />
144
+ </div>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ if (id === "line" || id === "area") {
150
+ return (
151
+ <div className={common}>
152
+ <svg viewBox="0 0 80 60" className="h-full w-full">
153
+ {id === "area" ? (
154
+ <polygon
155
+ points="6,42 18,28 34,34 50,20 66,24 74,18 74,56 6,56"
156
+ className="fill-primary/30"
157
+ />
158
+ ) : null}
159
+ <polyline
160
+ points="6,42 18,28 34,34 50,20 66,24 74,18"
161
+ fill="none"
162
+ stroke="hsl(var(--primary))"
163
+ strokeWidth="3"
164
+ strokeLinecap="round"
165
+ strokeLinejoin="round"
166
+ />
167
+ </svg>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ if (id === "pie") {
173
+ return (
174
+ <div className={common}>
175
+ <svg viewBox="0 0 80 60" className="h-full w-full">
176
+ <circle cx="40" cy="30" r="16" className="fill-primary/20" />
177
+ <path d="M40 30 L40 14 A16 16 0 0 1 55 39 Z" className="fill-primary/75" />
178
+ <path d="M40 30 L55 39 A16 16 0 0 1 27 43 Z" className="fill-primary/45" />
179
+ </svg>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ if (id === "scatter") {
185
+ return (
186
+ <div className={common}>
187
+ <svg viewBox="0 0 80 60" className="h-full w-full">
188
+ <circle cx="14" cy="42" r="2.5" className="fill-primary/80" />
189
+ <circle cx="24" cy="34" r="2.5" className="fill-primary/70" />
190
+ <circle cx="36" cy="28" r="2.5" className="fill-primary/75" />
191
+ <circle cx="48" cy="20" r="2.5" className="fill-primary/65" />
192
+ <circle cx="62" cy="14" r="2.5" className="fill-primary/85" />
193
+ </svg>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ if (id === "radar") {
199
+ return (
200
+ <div className={common}>
201
+ <svg viewBox="0 0 80 60" className="h-full w-full">
202
+ <polygon points="40,10 58,20 54,40 26,40 22,20" className="fill-primary/20 stroke-primary/60" />
203
+ <polygon points="40,16 52,23 49,36 31,36 28,24" className="fill-primary/45 stroke-primary/80" />
204
+ </svg>
205
+ </div>
206
+ );
207
+ }
208
+
209
+ if (id === "gauge") {
210
+ return (
211
+ <div className={common}>
212
+ <svg viewBox="0 0 80 60" className="h-full w-full">
213
+ <path d="M12 44a28 28 0 0 1 56 0" className="fill-none stroke-primary/35" strokeWidth="6" />
214
+ <path d="M12 44a28 28 0 0 1 40-24" className="fill-none stroke-primary/80" strokeWidth="6" />
215
+ <line x1="40" y1="44" x2="54" y2="28" className="stroke-primary" strokeWidth="2" />
216
+ </svg>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ if (id === "funnel") {
222
+ return (
223
+ <div className={common}>
224
+ <svg viewBox="0 0 80 60" className="h-full w-full">
225
+ <polygon points="10,10 70,10 58,22 22,22" className="fill-primary/80" />
226
+ <polygon points="22,24 58,24 50,34 30,34" className="fill-primary/65" />
227
+ <polygon points="30,36 50,36 44,44 36,44" className="fill-primary/50" />
228
+ </svg>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ if (id === "text") {
234
+ return (
235
+ <div className={common}>
236
+ <div className="space-y-1.5 px-2 py-2">
237
+ <div className="h-2 w-10 rounded bg-primary/70" />
238
+ <div className="h-1.5 w-full rounded bg-muted-foreground/45" />
239
+ <div className="h-1.5 w-11/12 rounded bg-muted-foreground/45" />
240
+ <div className="h-1.5 w-8/12 rounded bg-muted-foreground/45" />
241
+ </div>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ if (id === "rect") {
247
+ return (
248
+ <div className={common}>
249
+ <div className="absolute inset-2 rounded-md border-2 border-primary/80 bg-primary/15" />
250
+ </div>
251
+ );
252
+ }
253
+
254
+ if (id === "geometry") {
255
+ return (
256
+ <div className={common}>
257
+ <svg viewBox="0 0 80 60" className="h-full w-full">
258
+ <circle cx="22" cy="18" r="8" className="fill-primary/75" />
259
+ <rect x="36" y="10" width="16" height="16" rx="3" className="fill-primary/55" />
260
+ <polygon points="62,10 70,26 54,26" className="fill-primary/65" />
261
+ <polygon points="24,36 34,42 24,48 14,42" className="fill-primary/50" />
262
+ <polygon points="50,36 54,42 50,48 42,48 38,42 42,36" className="fill-primary/70" />
263
+ </svg>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ if (id === "reference") {
269
+ return (
270
+ <div className={common}>
271
+ <div className="absolute inset-2 rounded-md border border-dashed border-primary/70 bg-primary/10" />
272
+ <div className="absolute inset-0 flex items-center justify-center text-[10px] text-primary/80">
273
+ 引用组件
274
+ </div>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ if (id === "image") {
280
+ return (
281
+ <div className={common}>
282
+ <div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-muted/60 to-card" />
283
+ <svg viewBox="0 0 80 60" className="absolute inset-0 h-full w-full">
284
+ <circle cx="16" cy="14" r="5" className="fill-primary/70" />
285
+ <polygon points="0,60 24,34 38,50 52,30 80,60" className="fill-primary/35" />
286
+ <polygon points="0,60 18,42 30,54 44,36 62,60" className="fill-primary/55" />
287
+ </svg>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ if (id === "video") {
293
+ return (
294
+ <div className={common}>
295
+ <div className="absolute inset-0 bg-primary/12" />
296
+ <div className="absolute left-2 right-2 top-2 h-1.5 rounded bg-primary/35" />
297
+ <div className="absolute inset-0 flex items-center justify-center">
298
+ <div className="rounded-full bg-background/80 p-1">
299
+ <div className="h-0 w-0 border-b-[7px] border-l-[11px] border-t-[7px] border-b-transparent border-l-primary/85 border-t-transparent" />
300
+ </div>
301
+ </div>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ if (id === "audio") {
307
+ return (
308
+ <div className={common}>
309
+ <div className="absolute inset-0 flex items-center justify-center gap-1">
310
+ <div className="h-3 w-1.5 rounded bg-primary/50" />
311
+ <div className="h-6 w-1.5 rounded bg-primary/75" />
312
+ <div className="h-8 w-1.5 rounded bg-primary/90" />
313
+ <div className="h-5 w-1.5 rounded bg-primary/70" />
314
+ <div className="h-4 w-1.5 rounded bg-primary/55" />
315
+ </div>
316
+ </div>
317
+ );
318
+ }
319
+ if (id === "grid") {
320
+ return (
321
+ <div className={common}>
322
+ <div className="absolute inset-0 grid grid-cols-3 grid-rows-2 gap-1.5 p-2">
323
+ {Array.from({ length: 6 }).map((_, idx) => (
324
+ <div key={idx} className="rounded-sm border border-dashed border-primary/55 bg-primary/10" />
325
+ ))}
326
+ </div>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ return <div className={common} />;
332
+ }
333
+
334
+ const defaultCategories: MaterialCategory[] = [
335
+ {
336
+ id: "charts",
337
+ title: "图表",
338
+ items: [
339
+ { id: "bar", title: "柱状图" },
340
+ { id: "line", title: "折线图" },
341
+ { id: "pie", title: "饼图" },
342
+ { id: "area", title: "面积图" },
343
+ { id: "scatter", title: "散点图" },
344
+ { id: "radar", title: "雷达图" },
345
+ { id: "gauge", title: "仪表盘" },
346
+ { id: "funnel", title: "漏斗图" },
347
+ ],
348
+ },
349
+ {
350
+ id: "basic",
351
+ title: "基础",
352
+ items: [
353
+ { id: "text", title: "文本" },
354
+ { id: "geometry", title: "几何" },
355
+ { id: "grid", title: "网格布局" },
356
+ { id: "image", title: "图片" },
357
+ { id: "reference", title: "引用组件" },
358
+ ],
359
+ },
360
+ {
361
+ id: "media",
362
+ title: "媒体",
363
+ items: [
364
+ { id: "video", title: "视频" },
365
+ { id: "audio", title: "音频" },
366
+ ],
367
+ },
368
+ ];
369
+
370
+ export type MaterialSidebarProps = {
371
+ className?: string;
372
+ onDragMaterialStart?: (material: MaterialItem) => void;
373
+ layers?: PanelLayer[];
374
+ allElements?: PanelElement[];
375
+ selectedIds?: string[];
376
+ onSelectNode?: (nodeId: string, layerId: string) => void;
377
+ onNodeContextMenu?: (payload: { nodeId: string; layerId: string; x: number; y: number }) => void;
378
+ onDeleteNode?: (nodeId: string) => void;
379
+ onCopyNode?: (nodeId: string, mode?: ReferenceCopyMode) => void;
380
+ onMoveNodeToLayer?: (nodeId: string, targetLayerId: string) => void;
381
+ };
382
+
383
+ export function MaterialSidebar({
384
+ className,
385
+ onDragMaterialStart,
386
+ layers = [],
387
+ allElements = [],
388
+ selectedIds = [],
389
+ onSelectNode,
390
+ onNodeContextMenu,
391
+ onDeleteNode,
392
+ onCopyNode,
393
+ onMoveNodeToLayer,
394
+ }: MaterialSidebarProps) {
395
+ const categories = useMemo(() => defaultCategories, []);
396
+ const [leftTab, setLeftTab] = useState<"materials" | "tree">("materials");
397
+ const [activeCategoryId, setActiveCategoryId] =
398
+ useState<MaterialCategoryId>("charts");
399
+ const [keyword, setKeyword] = useState("");
400
+ const [treeKeyword, setTreeKeyword] = useState("");
401
+ const [referenceOnlyTree, setReferenceOnlyTree] = useState(false);
402
+ const [draggingTreeNodeId, setDraggingTreeNodeId] = useState<string | null>(null);
403
+ const [dragOverLayerId, setDragOverLayerId] = useState<string | null>(null);
404
+ const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({
405
+ root: true,
406
+ });
407
+
408
+ const activeCategory = useMemo(
409
+ () => categories.find((c) => c.id === activeCategoryId) ?? categories[0],
410
+ [activeCategoryId, categories]
411
+ );
412
+ const normalizedKeyword = keyword.trim().toLowerCase();
413
+ const isSearching = normalizedKeyword.length > 0;
414
+ const normalizedTreeKeyword = treeKeyword.trim().toLowerCase();
415
+ const isTreeSearching = normalizedTreeKeyword.length > 0;
416
+
417
+ const matchedItems = useMemo(() => {
418
+ if (!isSearching) return [];
419
+ const result: Array<MaterialItem & { categoryTitle: string }> = [];
420
+ categories.forEach((category) => {
421
+ category.items.forEach((item) => {
422
+ const haystack = `${item.title} ${item.id} ${category.title}`.toLowerCase();
423
+ if (haystack.includes(normalizedKeyword)) {
424
+ result.push({ ...item, categoryTitle: category.title });
425
+ }
426
+ });
427
+ });
428
+ return result;
429
+ }, [categories, isSearching, normalizedKeyword]);
430
+
431
+ const themedScrollbarClass =
432
+ "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";
433
+
434
+ const elementsByLayer = useMemo(() => {
435
+ const map = new Map<string, PanelElement[]>();
436
+ for (const layer of layers) map.set(layer.id, []);
437
+ for (const el of allElements) {
438
+ const list = map.get(el.layerId) ?? [];
439
+ list.push(el);
440
+ map.set(el.layerId, list);
441
+ }
442
+ return map;
443
+ }, [allElements, layers]);
444
+ const elementsById = useMemo(() => {
445
+ const map = new Map<string, PanelElement>();
446
+ for (const el of allElements) map.set(el.id, el);
447
+ return map;
448
+ }, [allElements]);
449
+ const layerById = useMemo(() => {
450
+ const map = new Map<string, PanelLayer>();
451
+ for (const layer of layers) map.set(layer.id, layer);
452
+ return map;
453
+ }, [layers]);
454
+
455
+ /**
456
+ * 节点树展示用的「本图层父网格 id」:
457
+ * parentGridId 若指向其它图层的同源克隆网格,仍解析到当前图层上对应的网格,层级与映射同步语义一致。
458
+ */
459
+ const effectiveGridParentByElementId = useMemo(() => {
460
+ const byId = new Map<string, PanelElement>();
461
+ for (const el of allElements) byId.set(el.id, el);
462
+ const map = new Map<string, string | undefined>();
463
+ for (const el of allElements) {
464
+ const pg = el.parentGridId;
465
+ if (!pg) {
466
+ map.set(el.id, undefined);
467
+ continue;
468
+ }
469
+ const parent = byId.get(pg);
470
+ if (parent?.layerId === el.layerId && parent.materialType === "grid") {
471
+ map.set(el.id, pg);
472
+ continue;
473
+ }
474
+ const logical = logicalGridParentIdFromConcrete(pg, byId);
475
+ if (logical !== undefined) {
476
+ const concrete = concreteGridParentIdForLayer(logical, el.layerId, allElements);
477
+ map.set(el.id, concrete ?? undefined);
478
+ continue;
479
+ }
480
+ map.set(el.id, undefined);
481
+ }
482
+ return map;
483
+ }, [allElements]);
484
+
485
+ /** 网格 → 直接子节点(槽位顺序),键为展示用有效父网格 id */
486
+ const childrenByGridByLayer = useMemo(() => {
487
+ const outer = new Map<string, Map<string, PanelElement[]>>();
488
+ for (const el of allElements) {
489
+ const gridParentId = effectiveGridParentByElementId.get(el.id);
490
+ if (!gridParentId) continue;
491
+ let inner = outer.get(el.layerId);
492
+ if (!inner) {
493
+ inner = new Map();
494
+ outer.set(el.layerId, inner);
495
+ }
496
+ const list = inner.get(gridParentId) ?? [];
497
+ list.push(el);
498
+ inner.set(gridParentId, list);
499
+ }
500
+ return outer;
501
+ }, [allElements, effectiveGridParentByElementId]);
502
+
503
+ const getNodeDisplayName = (node: PanelElement) => {
504
+ const customName = node.name?.trim();
505
+ return customName || node.chart?.title || MATERIAL_LABEL_MAP[node.materialType ?? ""] || node.id;
506
+ };
507
+
508
+ const isExpanded = (key: string, defaultValue = false) =>
509
+ expandedKeys[key] ?? defaultValue;
510
+
511
+ const setExpanded = (key: string, next: boolean) => {
512
+ setExpandedKeys((prev) => ({ ...prev, [key]: next }));
513
+ };
514
+
515
+ const getNodeChildren = (node: PanelElement, sourceOverride?: PanelElement[]) => {
516
+ const isRef = node.materialType === "reference";
517
+ const isGrid = node.materialType === "grid";
518
+ // 必须用节点自身所在图层查找子节点;外层传入的 layerId 在深嵌套/引用子树中会错位,导致子节点不显示在对应网格下
519
+ const gridChildren = isGrid
520
+ ? [...(childrenByGridByLayer.get(node.layerId)?.get(node.id) ?? [])].sort(
521
+ compareGridTreeChildOrder
522
+ )
523
+ : [];
524
+ return isRef
525
+ ? node.refCopyMode === "deep"
526
+ ? node.refSnapshot ?? sourceOverride ?? []
527
+ : node.refLayerId
528
+ ? elementsByLayer.get(node.refLayerId) ?? []
529
+ : []
530
+ : gridChildren;
531
+ };
532
+
533
+ const nodeMatchesTreeSearch = (
534
+ node: PanelElement,
535
+ visited: Set<string>,
536
+ sourceOverride?: PanelElement[]
537
+ ): boolean => {
538
+ if (!isTreeSearching) return true;
539
+ const selfText = `${getNodeDisplayName(node)} ${node.materialType ?? ""} ${node.id}`.toLowerCase();
540
+ if (selfText.includes(normalizedTreeKeyword)) return true;
541
+ if (visited.has(node.id)) return false;
542
+ const nextVisited = new Set(visited);
543
+ nextVisited.add(node.id);
544
+ const children = getNodeChildren(node, sourceOverride);
545
+ return children.some((child) =>
546
+ nodeMatchesTreeSearch(child, nextVisited, node.refSnapshot)
547
+ );
548
+ };
549
+
550
+ const renderTreeNode = (
551
+ node: PanelElement,
552
+ level: number,
553
+ path: string,
554
+ visited: Set<string>,
555
+ sourceOverride?: PanelElement[]
556
+ ) => {
557
+ const selected = selectedIds.includes(node.id);
558
+ if (!nodeMatchesTreeSearch(node, visited, sourceOverride)) return null;
559
+ const isRef = node.materialType === "reference";
560
+ const refMode = node.refCopyMode ?? "shallow";
561
+ const isDeepRef = isRef && refMode === "deep";
562
+ const children = getNodeChildren(node, sourceOverride);
563
+ const nodeHomeLayer = layerById.get(node.layerId);
564
+ const hasChildren = children.length > 0;
565
+ const nextVisited = new Set(visited);
566
+ nextVisited.add(node.id);
567
+ const nodeKey = `node:${path}`;
568
+ return (
569
+ <Collapsible
570
+ key={nodeKey}
571
+ open={isExpanded(nodeKey, true)}
572
+ onOpenChange={(open) => setExpanded(nodeKey, open)}
573
+ >
574
+ <div
575
+ className={[
576
+ "mb-1 flex items-center gap-1 rounded py-1",
577
+ selected ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:bg-accent/60",
578
+ ].join(" ")}
579
+ style={{ paddingLeft: 6 + level * 14, paddingRight: 6 }}
580
+ >
581
+ {hasChildren ? (
582
+ <CollapsibleTrigger asChild>
583
+ <button
584
+ type="button"
585
+ className="flex h-7 w-7 items-center justify-center rounded text-sm hover:bg-accent"
586
+ >
587
+ {isExpanded(nodeKey, true) ? "▾" : "▸"}
588
+ </button>
589
+ </CollapsibleTrigger>
590
+ ) : (
591
+ <span className="inline-flex h-7 w-7 items-center justify-center text-sm opacity-40">•</span>
592
+ )}
593
+ <button
594
+ type="button"
595
+ className="min-w-0 flex-1 truncate text-left"
596
+ onClick={() => onSelectNode?.(node.id, node.layerId)}
597
+ onContextMenu={(e) => {
598
+ e.preventDefault();
599
+ e.stopPropagation();
600
+ onNodeContextMenu?.({
601
+ nodeId: node.id,
602
+ layerId: node.layerId,
603
+ x: e.clientX,
604
+ y: e.clientY,
605
+ });
606
+ }}
607
+ title={getNodeDisplayName(node)}
608
+ draggable={!node.locked}
609
+ onDragStart={(e) => {
610
+ e.stopPropagation();
611
+ if (node.locked) {
612
+ e.preventDefault();
613
+ return;
614
+ }
615
+ setDraggingTreeNodeId(node.id);
616
+ e.dataTransfer.setData(
617
+ "application/x-arronqzy-tree-node",
618
+ JSON.stringify({ nodeId: node.id, sourceLayerId: node.layerId })
619
+ );
620
+ e.dataTransfer.effectAllowed = "move";
621
+ }}
622
+ onDragEnd={() => {
623
+ setDraggingTreeNodeId(null);
624
+ setDragOverLayerId(null);
625
+ }}
626
+ >
627
+ {getNodeDisplayName(node)}
628
+ </button>
629
+ {nodeHomeLayer?.isMapping ? (
630
+ <span
631
+ className="inline-flex shrink-0 items-center rounded-md border-2 border-violet-600 bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-950 shadow-sm dark:border-violet-400 dark:bg-violet-500/35 dark:text-violet-50 dark:shadow-[0_0_14px_-3px_rgba(167,139,250,0.65)]"
632
+ title={
633
+ nodeHomeLayer.mappingBaseLayerId
634
+ ? `映射图层「${nodeHomeLayer.name}」· 基准图层:${
635
+ layerById.get(nodeHomeLayer.mappingBaseLayerId)?.name ??
636
+ nodeHomeLayer.mappingBaseLayerId
637
+ }`
638
+ : `映射图层「${nodeHomeLayer.name}」`
639
+ }
640
+ >
641
+ 映射图层节点
642
+ </span>
643
+ ) : null}
644
+ {node.mappingSourceNodeId ? (
645
+ <span
646
+ className="inline-flex shrink-0 items-center rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary"
647
+ title={`同源节点 ${node.mappingSourceNodeId}${
648
+ node.mappingSourceLayerId
649
+ ? ` · ${layerById.get(node.mappingSourceLayerId)?.name ?? node.mappingSourceLayerId}`
650
+ : ""
651
+ }`}
652
+ >
653
+ 同源
654
+ </span>
655
+ ) : null}
656
+ {node.locked ? (
657
+ <span
658
+ className="inline-flex h-5 w-5 items-center justify-center rounded border border-border/80 bg-background/80 text-muted-foreground"
659
+ title="节点已锁定"
660
+ >
661
+ <LockGlyph />
662
+ </span>
663
+ ) : null}
664
+ {isRef ? (
665
+ <>
666
+ <span
667
+ className={[
668
+ "rounded border px-1 text-[10px]",
669
+ isDeepRef
670
+ ? "border-violet-500/50 bg-violet-500/15 text-violet-300"
671
+ : "border-sky-500/40 bg-sky-500/10 text-sky-300",
672
+ ].join(" ")}
673
+ title={isDeepRef ? "深拷贝引用(冻结快照)" : "浅拷贝引用(跟随源变化)"}
674
+ >
675
+ {isDeepRef ? "深拷贝" : "浅拷贝"}
676
+ </span>
677
+ <Tooltip>
678
+ <TooltipTrigger asChild>
679
+ <button
680
+ type="button"
681
+ className="inline-flex h-5 w-5 items-center justify-center rounded border border-border hover:bg-accent"
682
+ onClick={(e) => {
683
+ e.stopPropagation();
684
+ onCopyNode?.(node.id, "shallow");
685
+ }}
686
+ aria-label="浅拷贝"
687
+ >
688
+ <IconShallowCopy />
689
+ </button>
690
+ </TooltipTrigger>
691
+ <TooltipContent>浅拷贝</TooltipContent>
692
+ </Tooltip>
693
+ <Tooltip>
694
+ <TooltipTrigger asChild>
695
+ <button
696
+ type="button"
697
+ className="inline-flex h-5 w-5 items-center justify-center rounded border border-border hover:bg-accent"
698
+ onClick={(e) => {
699
+ e.stopPropagation();
700
+ onCopyNode?.(node.id, "deep");
701
+ }}
702
+ aria-label="深拷贝"
703
+ >
704
+ <IconDeepCopy />
705
+ </button>
706
+ </TooltipTrigger>
707
+ <TooltipContent>深拷贝</TooltipContent>
708
+ </Tooltip>
709
+ </>
710
+ ) : (
711
+ <Tooltip>
712
+ <TooltipTrigger asChild>
713
+ <button
714
+ type="button"
715
+ className="inline-flex h-5 w-5 items-center justify-center rounded border border-border hover:bg-accent"
716
+ onClick={(e) => {
717
+ e.stopPropagation();
718
+ onCopyNode?.(node.id);
719
+ }}
720
+ aria-label="复制节点"
721
+ >
722
+ <IconCopy />
723
+ </button>
724
+ </TooltipTrigger>
725
+ <TooltipContent>复制节点</TooltipContent>
726
+ </Tooltip>
727
+ )}
728
+ <Tooltip>
729
+ <TooltipTrigger asChild>
730
+ <button
731
+ type="button"
732
+ disabled={node.locked}
733
+ className="inline-flex h-5 w-5 items-center justify-center rounded border border-border hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
734
+ onClick={(e) => {
735
+ e.stopPropagation();
736
+ if (node.locked) return;
737
+ onDeleteNode?.(node.id);
738
+ }}
739
+ aria-label={node.locked ? "锁定节点不可删除" : "删除节点"}
740
+ >
741
+ <IconDelete />
742
+ </button>
743
+ </TooltipTrigger>
744
+ <TooltipContent>{node.locked ? "锁定节点不可删除" : "删除节点"}</TooltipContent>
745
+ </Tooltip>
746
+ </div>
747
+ {hasChildren ? (
748
+ <CollapsibleContent>
749
+ {children.map((child) => {
750
+ const childPath = `${path}->${child.id}`;
751
+ if (nextVisited.has(child.id)) {
752
+ return (
753
+ <div
754
+ key={`cycle:${childPath}`}
755
+ className="py-1 text-[10px] text-muted-foreground/80"
756
+ style={{ paddingLeft: 6 + (level + 1) * 14 }}
757
+ title="检测到循环引用,已停止向下展开"
758
+ >
759
+ {getNodeDisplayName(child)}(循环引用)
760
+ </div>
761
+ );
762
+ }
763
+ return renderTreeNode(child, level + 1, childPath, nextVisited, node.refSnapshot);
764
+ })}
765
+ </CollapsibleContent>
766
+ ) : null}
767
+ </Collapsible>
768
+ );
769
+ };
770
+
771
+ const hasRefInSubtree = (node: PanelElement, visited: Set<string>): boolean => {
772
+ if (node.materialType === "reference") return true;
773
+ if (node.materialType !== "reference") return false;
774
+ if (visited.has(node.id)) return false;
775
+ const nextVisited = new Set(visited);
776
+ nextVisited.add(node.id);
777
+ const children =
778
+ node.refCopyMode === "deep"
779
+ ? node.refSnapshot ?? []
780
+ : node.refLayerId
781
+ ? elementsByLayer.get(node.refLayerId) ?? []
782
+ : [];
783
+ return children.some((child) => hasRefInSubtree(child, nextVisited));
784
+ };
785
+
786
+ return (
787
+ <TooltipProvider delayDuration={120}>
788
+ <aside
789
+ className={[
790
+ "grid h-full w-full border-r border-border bg-muted/30 text-foreground",
791
+ className ?? "",
792
+ ].join(" ")}
793
+ style={{ gridTemplateRows: "auto 1fr" }}
794
+ >
795
+ <div className="border-b border-border bg-background/80 px-3 py-2 backdrop-blur-sm">
796
+ <Input
797
+ value={keyword}
798
+ onChange={(e) => setKeyword(e.target.value)}
799
+ placeholder="搜索物料(名称 / 分类)"
800
+ className="h-8 text-xs"
801
+ />
802
+ </div>
803
+
804
+ <Tabs
805
+ value={leftTab}
806
+ onValueChange={(v) => setLeftTab(v as "materials" | "tree")}
807
+ className="flex min-h-0 h-full flex-col"
808
+ >
809
+ <div className="border-b border-border px-2 pb-2 pt-2">
810
+ <TabsList className="grid w-full grid-cols-2">
811
+ <TabsTrigger value="materials">物料</TabsTrigger>
812
+ <TabsTrigger value="tree">节点树</TabsTrigger>
813
+ </TabsList>
814
+ </div>
815
+ <TabsContent value="materials" className="mt-0 min-h-0 flex-1 overflow-hidden">
816
+ <div className="grid h-full min-h-0 grid-cols-[110px_1fr]">
817
+ <div className={`overflow-auto border-r border-border ${themedScrollbarClass}`}>
818
+ {categories.map((c) => {
819
+ const active = c.id === activeCategoryId;
820
+ return (
821
+ <button
822
+ key={c.id}
823
+ type="button"
824
+ onClick={() => setActiveCategoryId(c.id)}
825
+ className={[
826
+ "w-full cursor-pointer border-b border-border/40 px-2.5 py-2.5 text-left text-xs",
827
+ active
828
+ ? "bg-accent text-accent-foreground"
829
+ : "text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground",
830
+ ].join(" ")}
831
+ >
832
+ {c.title}
833
+ </button>
834
+ );
835
+ })}
836
+ </div>
837
+
838
+ <div className={`overflow-auto ${themedScrollbarClass}`}>
839
+ <div className="px-2.5 py-2.5 text-xs font-semibold">
840
+ {isSearching ? `搜索结果(${matchedItems.length})` : activeCategory.title}
841
+ </div>
842
+ <div className="grid gap-2 px-2.5 pb-3">
843
+ {(isSearching ? matchedItems : activeCategory.items).map((it) => (
844
+ <button
845
+ key={isSearching ? `${it.id}-${(it as any).categoryTitle}` : it.id}
846
+ type="button"
847
+ draggable
848
+ className="cursor-pointer rounded-xl border border-border bg-card px-2 py-2 text-left text-xs text-card-foreground hover:bg-accent/60"
849
+ onDragStart={(e) => {
850
+ e.dataTransfer.setData(
851
+ "application/x-arronqzy-material",
852
+ JSON.stringify({ id: it.id, title: it.title })
853
+ );
854
+ e.dataTransfer.effectAllowed = "copy";
855
+ onDragMaterialStart?.(it);
856
+ }}
857
+ >
858
+ <div className="flex flex-col items-stretch gap-2">
859
+ <MaterialPreview id={it.id} />
860
+ <div className="min-w-0">
861
+ <div className="truncate">{it.title}</div>
862
+ {isSearching ? (
863
+ <div className="mt-1 text-[11px] text-muted-foreground">
864
+ {(it as any).categoryTitle}
865
+ </div>
866
+ ) : null}
867
+ </div>
868
+ </div>
869
+ </button>
870
+ ))}
871
+ {isSearching && matchedItems.length === 0 ? (
872
+ <Empty className="py-5">
873
+ <EmptyIcon className="h-8 w-8">
874
+ <svg
875
+ viewBox="0 0 24 24"
876
+ className="h-4 w-4"
877
+ fill="none"
878
+ stroke="currentColor"
879
+ strokeWidth="1.8"
880
+ aria-hidden="true"
881
+ >
882
+ <circle cx="11" cy="11" r="7" />
883
+ <path d="m20 20-3.5-3.5" />
884
+ </svg>
885
+ </EmptyIcon>
886
+ <EmptyTitle className="text-xs">未匹配到物料</EmptyTitle>
887
+ <EmptyDescription className="text-[11px]">
888
+ 尝试更换关键词,或切换分类后再搜索。
889
+ </EmptyDescription>
890
+ </Empty>
891
+ ) : null}
892
+ </div>
893
+ </div>
894
+ </div>
895
+ </TabsContent>
896
+ <TabsContent value="tree" className="mt-0 min-h-0 flex-1 overflow-hidden">
897
+ <div className={`h-full overflow-auto px-2 py-2 text-xs ${themedScrollbarClass}`}>
898
+ <div className="mb-2">
899
+ <Input
900
+ value={treeKeyword}
901
+ onChange={(e) => setTreeKeyword(e.target.value)}
902
+ placeholder="搜索节点(名称 / 类型 / ID)"
903
+ className="h-8 text-xs"
904
+ />
905
+ </div>
906
+ <div className="mb-2 flex items-center justify-between rounded border border-border bg-card px-2 py-1.5">
907
+ <span className="text-[11px] text-muted-foreground">仅看引用子树</span>
908
+ <Switch
909
+ checked={referenceOnlyTree}
910
+ onCheckedChange={setReferenceOnlyTree}
911
+ aria-label="仅看引用子树"
912
+ />
913
+ </div>
914
+ <Collapsible
915
+ open={isExpanded("root", true)}
916
+ onOpenChange={(open) => setExpanded("root", open)}
917
+ className="rounded border border-border bg-card"
918
+ >
919
+ <div className="flex items-center gap-1 px-2 py-1.5 font-medium">
920
+ <CollapsibleTrigger asChild>
921
+ <button
922
+ type="button"
923
+ className="flex h-7 w-7 items-center justify-center rounded text-sm hover:bg-accent"
924
+ >
925
+ {isExpanded("root", true) ? "▾" : "▸"}
926
+ </button>
927
+ </CollapsibleTrigger>
928
+ <span>root</span>
929
+ </div>
930
+ <CollapsibleContent className="space-y-1 border-t border-border/60 py-1">
931
+ {layers.map((layer) => {
932
+ const layerNodes = elementsByLayer.get(layer.id) ?? [];
933
+ const draggingNode = draggingTreeNodeId ? elementsById.get(draggingTreeNodeId) ?? null : null;
934
+ const draggingSourceLayer = draggingNode
935
+ ? (layerById.get(draggingNode.layerId) ?? null)
936
+ : null;
937
+ const dropBlockReason = !draggingNode
938
+ ? ""
939
+ : draggingNode.locked
940
+ ? PANEL_MESSAGES.nodeMoveLocked
941
+ : draggingSourceLayer?.locked
942
+ ? PANEL_MESSAGES.nodeMoveSourceLayerLocked
943
+ : layer.locked
944
+ ? PANEL_MESSAGES.nodeMoveTargetLayerLocked
945
+ : draggingNode.layerId === layer.id
946
+ ? PANEL_MESSAGES.nodeMoveSameLayer
947
+ : "";
948
+ const canDropIntoLayer = Boolean(draggingNode) && !dropBlockReason;
949
+ const isCurrentDropLayer = dragOverLayerId === layer.id;
950
+ const rootNodes = layerNodes
951
+ .filter((node) => !effectiveGridParentByElementId.get(node.id))
952
+ .filter((node) => !referenceOnlyTree || hasRefInSubtree(node, new Set<string>()))
953
+ .filter((node) => nodeMatchesTreeSearch(node, new Set<string>()));
954
+ if (isTreeSearching && rootNodes.length === 0) return null;
955
+ const layerKey = `layer:${layer.id}`;
956
+ return (
957
+ <Collapsible
958
+ key={layer.id}
959
+ open={isExpanded(layerKey, true)}
960
+ onOpenChange={(open) => setExpanded(layerKey, open)}
961
+ >
962
+ <Card
963
+ className={[
964
+ "mb-2 overflow-hidden transition-shadow",
965
+ layer.isMapping
966
+ ? "border-2 border-violet-500/70 bg-gradient-to-br from-violet-500/14 via-violet-600/10 to-fuchsia-500/12 shadow-[inset_0_1px_0_0_rgba(139,92,246,0.22)] dark:border-violet-400/65 dark:from-violet-500/20 dark:via-violet-950/35 dark:to-fuchsia-950/25 dark:shadow-[inset_0_0_0_1px_rgba(167,139,250,0.12),0_0_20px_-8px_rgba(139,92,246,0.35)]"
967
+ : "",
968
+ isCurrentDropLayer
969
+ ? canDropIntoLayer
970
+ ? "ring-2 ring-primary/45 ring-offset-2 ring-offset-background"
971
+ : "ring-2 ring-destructive/45 ring-offset-2 ring-offset-background"
972
+ : "",
973
+ ]
974
+ .filter(Boolean)
975
+ .join(" ")}
976
+ >
977
+ <CardHeader className="flex flex-row flex-wrap items-center gap-1 space-y-0 p-2.5 pb-1.5">
978
+ {isCurrentDropLayer ? (
979
+ <span
980
+ className={[
981
+ "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px]",
982
+ canDropIntoLayer
983
+ ? "bg-primary/15 text-primary"
984
+ : "bg-destructive/15 text-destructive",
985
+ ].join(" ")}
986
+ title={canDropIntoLayer ? "目标图层" : dropBlockReason}
987
+ >
988
+ ➜
989
+ </span>
990
+ ) : null}
991
+ <CollapsibleTrigger asChild>
992
+ <button
993
+ type="button"
994
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-sm text-muted-foreground hover:bg-accent"
995
+ >
996
+ {isExpanded(layerKey, true) ? "▾" : "▸"}
997
+ </button>
998
+ </CollapsibleTrigger>
999
+ <span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
1000
+ {layer.name}
1001
+ <span className="font-normal text-muted-foreground">
1002
+ ({rootNodes.length})
1003
+ </span>
1004
+ </span>
1005
+ {layer.isMapping ? (
1006
+ <span
1007
+ className="shrink-0 rounded-md border-2 border-violet-600 bg-violet-500/22 px-1.5 py-0.5 text-[10px] font-semibold text-violet-950 shadow-sm dark:border-violet-400 dark:bg-violet-500/35 dark:text-violet-50 dark:shadow-[0_0_12px_-2px_rgba(167,139,250,0.55)]"
1008
+ title={
1009
+ layer.mappingBaseLayerId
1010
+ ? `映射图层 · 基准图层:${
1011
+ layerById.get(layer.mappingBaseLayerId)?.name ??
1012
+ layer.mappingBaseLayerId
1013
+ }`
1014
+ : "映射图层"
1015
+ }
1016
+ >
1017
+ 映射图层
1018
+ </span>
1019
+ ) : null}
1020
+ {isCurrentDropLayer ? (
1021
+ <span
1022
+ className={[
1023
+ "ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px]",
1024
+ canDropIntoLayer
1025
+ ? "bg-primary/15 text-primary"
1026
+ : "bg-destructive/15 text-destructive",
1027
+ ].join(" ")}
1028
+ >
1029
+ {canDropIntoLayer ? "将移动到该图层" : dropBlockReason}
1030
+ </span>
1031
+ ) : null}
1032
+ </CardHeader>
1033
+ <CardContent className="space-y-1.5 p-2.5 pt-0">
1034
+ <div
1035
+ className={[
1036
+ "rounded-md border border-dashed px-2 py-1.5 text-[10px] transition-colors",
1037
+ isCurrentDropLayer
1038
+ ? canDropIntoLayer
1039
+ ? "border-primary/50 bg-primary/10 text-primary"
1040
+ : "border-destructive/50 bg-destructive/10 text-destructive"
1041
+ : "border-border/50 bg-muted/20 text-muted-foreground hover:border-border hover:bg-muted/35",
1042
+ ].join(" ")}
1043
+ onDragOver={(e) => {
1044
+ const hasNodeData =
1045
+ e.dataTransfer.types.includes("application/x-arronqzy-tree-node");
1046
+ if (!hasNodeData) return;
1047
+ if (canDropIntoLayer) {
1048
+ e.preventDefault();
1049
+ e.dataTransfer.dropEffect = "move";
1050
+ } else {
1051
+ e.dataTransfer.dropEffect = "none";
1052
+ }
1053
+ if (dragOverLayerId !== layer.id) setDragOverLayerId(layer.id);
1054
+ }}
1055
+ onDragLeave={() => {
1056
+ if (dragOverLayerId === layer.id) setDragOverLayerId(null);
1057
+ }}
1058
+ onDrop={(e) => {
1059
+ const payload = e.dataTransfer.getData("application/x-arronqzy-tree-node");
1060
+ if (!payload) return;
1061
+ e.preventDefault();
1062
+ try {
1063
+ const data = JSON.parse(payload) as {
1064
+ nodeId?: string;
1065
+ sourceLayerId?: string;
1066
+ };
1067
+ if (!data.nodeId || !layer.id) return;
1068
+ if (!canDropIntoLayer) return;
1069
+ if (data.sourceLayerId === layer.id) return;
1070
+ onMoveNodeToLayer?.(data.nodeId, layer.id);
1071
+ } catch {
1072
+ // ignore invalid payload
1073
+ } finally {
1074
+ setDraggingTreeNodeId(null);
1075
+ setDragOverLayerId(null);
1076
+ }
1077
+ }}
1078
+ title="拖拽节点到此图层"
1079
+ >
1080
+ {draggingTreeNodeId
1081
+ ? canDropIntoLayer
1082
+ ? "释放以移动到该图层"
1083
+ : dropBlockReason || "不可移动到该图层"
1084
+ : "拖拽节点到该图层"}
1085
+ </div>
1086
+ <CollapsibleContent>
1087
+ {rootNodes.length === 0 ? (
1088
+ <div className="rounded border border-border/40 bg-muted/15 py-2 pl-3 text-[11px] text-muted-foreground">
1089
+ 空图层
1090
+ </div>
1091
+ ) : (
1092
+ rootNodes.map((node) =>
1093
+ renderTreeNode(node, 2, `${layer.id}/${node.id}`, new Set<string>())
1094
+ )
1095
+ )}
1096
+ </CollapsibleContent>
1097
+ </CardContent>
1098
+ </Card>
1099
+ </Collapsible>
1100
+ );
1101
+ })}
1102
+ {isTreeSearching &&
1103
+ layers.every((layer) => {
1104
+ const layerNodes = elementsByLayer.get(layer.id) ?? [];
1105
+ const rootNodes = layerNodes
1106
+ .filter((node) => !effectiveGridParentByElementId.get(node.id))
1107
+ .filter((node) => !referenceOnlyTree || hasRefInSubtree(node, new Set<string>()))
1108
+ .filter((node) => nodeMatchesTreeSearch(node, new Set<string>()));
1109
+ return rootNodes.length === 0;
1110
+ }) ? (
1111
+ <div className="mx-2 my-2">
1112
+ <Empty className="py-5">
1113
+ <EmptyIcon className="h-8 w-8">
1114
+ <svg
1115
+ viewBox="0 0 24 24"
1116
+ className="h-4 w-4"
1117
+ fill="none"
1118
+ stroke="currentColor"
1119
+ strokeWidth="1.8"
1120
+ aria-hidden="true"
1121
+ >
1122
+ <rect x="4" y="4" width="16" height="16" rx="2.5" />
1123
+ <path d="M8 10h8M8 14h5" />
1124
+ </svg>
1125
+ </EmptyIcon>
1126
+ <EmptyTitle className="text-xs">未匹配到节点</EmptyTitle>
1127
+ <EmptyDescription className="text-[11px]">
1128
+ 试试节点名称、类型或 ID 关键词。
1129
+ </EmptyDescription>
1130
+ </Empty>
1131
+ </div>
1132
+ ) : null}
1133
+ </CollapsibleContent>
1134
+ </Collapsible>
1135
+ </div>
1136
+ </TabsContent>
1137
+ </Tabs>
1138
+ </aside>
1139
+ </TooltipProvider>
1140
+ );
1141
+ }
1142
+