@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,803 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import Moveable from "react-moveable";
4
+
5
+ import type { PanelElement } from "../types";
6
+ import { uniformViewportZoom } from "../viewportZoom";
7
+ import { notifyPreviewLayoutChanged } from "../utils/panelStateIO";
8
+
9
+ function LockGlyph({ className = "h-3.5 w-3.5" }: { className?: string }) {
10
+ return (
11
+ <svg
12
+ viewBox="0 0 24 24"
13
+ fill="none"
14
+ stroke="currentColor"
15
+ strokeWidth="1.9"
16
+ strokeLinecap="round"
17
+ strokeLinejoin="round"
18
+ className={className}
19
+ aria-hidden="true"
20
+ >
21
+ <rect x="4.5" y="10.5" width="15" height="10" rx="2.5" />
22
+ <path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
23
+ <circle cx="12" cy="15.5" r="1.2" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ export type MoveableLayerProps = {
29
+ zoomX: number;
30
+ zoomY: number;
31
+ /** 带 scale 的画布根节点(data-panel-canvas),与节点同一坐标系 */
32
+ canvasContainer: HTMLElement | null;
33
+ /** 滚动视口,用于拖拽/框选指针坐标 */
34
+ dragContainer?: HTMLElement | null;
35
+ selectedTargets: HTMLElement[];
36
+ elementsById: Map<string, PanelElement>;
37
+ updateElement: (
38
+ id: string,
39
+ patch: Partial<PanelElement>,
40
+ options?: { batchId?: string; meta?: Record<string, unknown> }
41
+ ) => void;
42
+ /** 画布平移/缩放等视口变化时递增或变化,用于触发控制框重算 */
43
+ refreshToken?: number | string;
44
+ /** InfiniteViewer 虚拟滚动位置(无原生 scroll 事件) */
45
+ viewportScroll?: { left: number; top: number };
46
+ };
47
+
48
+ function getGridSlotLayout(grid: PanelElement) {
49
+ const rows = Math.max(1, Math.floor(grid.gridRows ?? 2));
50
+ const cols = Math.max(1, Math.floor(grid.gridCols ?? 3));
51
+ const gap = Math.max(0, grid.gridGap ?? 8);
52
+ const padding = Math.max(0, grid.gridPadding ?? 10);
53
+ const innerWidth = Math.max(1, grid.width - padding * 2);
54
+ const innerHeight = Math.max(1, grid.height - padding * 2);
55
+ const cellWidth = Math.max(1, (innerWidth - gap * (cols - 1)) / cols);
56
+ const cellHeight = Math.max(1, (innerHeight - gap * (rows - 1)) / rows);
57
+ const slots: Array<{ index: number; row: number; col: number; centerX: number; centerY: number; x: number; y: number }> = [];
58
+ for (let r = 0; r < rows; r++) {
59
+ for (let c = 0; c < cols; c++) {
60
+ const x = grid.x + padding + c * (cellWidth + gap);
61
+ const y = grid.y + padding + r * (cellHeight + gap);
62
+ slots.push({
63
+ index: r * cols + c,
64
+ row: r,
65
+ col: c,
66
+ x,
67
+ y,
68
+ centerX: x + cellWidth / 2,
69
+ centerY: y + cellHeight / 2,
70
+ });
71
+ }
72
+ }
73
+ return { slots, cellWidth, cellHeight, rows, cols, gap };
74
+ }
75
+
76
+ function inferSpanBySize(size: number, cellSize: number, gap: number, maxSpan: number) {
77
+ const safeMax = Math.max(1, Math.floor(maxSpan));
78
+ const unit = Math.max(1, cellSize + gap);
79
+ const ratio = (Math.max(1, size) + gap) / unit;
80
+ const whole = Math.floor(ratio);
81
+ const fraction = ratio - whole;
82
+ const promoted = fraction >= 0.72 ? whole + 1 : Math.max(1, whole);
83
+ return Math.max(1, Math.min(safeMax, promoted));
84
+ }
85
+
86
+ function getGridSpanCells(
87
+ rows: number,
88
+ cols: number,
89
+ slotIndex: number,
90
+ colSpan: number,
91
+ rowSpan: number
92
+ ) {
93
+ const total = rows * cols;
94
+ const safeIndex = Math.max(0, Math.min(total - 1, Math.floor(slotIndex || 0)));
95
+ const startRow = Math.floor(safeIndex / cols);
96
+ const startCol = safeIndex % cols;
97
+ const safeColSpan = Math.max(1, Math.min(cols - startCol, Math.floor(colSpan || 1)));
98
+ const safeRowSpan = Math.max(1, Math.min(rows - startRow, Math.floor(rowSpan || 1)));
99
+ const indices: number[] = [];
100
+ for (let r = startRow; r < startRow + safeRowSpan; r++) {
101
+ for (let c = startCol; c < startCol + safeColSpan; c++) {
102
+ indices.push(r * cols + c);
103
+ }
104
+ }
105
+ return {
106
+ safeIndex,
107
+ startRow,
108
+ startCol,
109
+ colSpan: safeColSpan,
110
+ rowSpan: safeRowSpan,
111
+ indices,
112
+ };
113
+ }
114
+
115
+ function getOccupiedSlotSet(
116
+ elementsById: Map<string, PanelElement>,
117
+ gridId: string,
118
+ selfId: string,
119
+ layerId?: string
120
+ ) {
121
+ const occupied = new Set<number>();
122
+ const grid = elementsById.get(gridId);
123
+ if (!grid || grid.materialType !== "grid") return occupied;
124
+ const { rows, cols } = getGridSlotLayout(grid);
125
+ for (const el of elementsById.values()) {
126
+ if (el.id === selfId) continue;
127
+ if (layerId && el.layerId !== layerId) continue;
128
+ if (el.parentGridId !== gridId) continue;
129
+ if (el.gridSlotIndex === undefined) continue;
130
+ const span = getGridSpanCells(
131
+ rows,
132
+ cols,
133
+ el.gridSlotIndex,
134
+ el.gridColSpan ?? 1,
135
+ el.gridRowSpan ?? 1
136
+ );
137
+ span.indices.forEach((idx) => occupied.add(idx));
138
+ }
139
+ return occupied;
140
+ }
141
+
142
+ function isDescendantGrid(
143
+ elementsById: Map<string, PanelElement>,
144
+ possibleDescendantGridId: string,
145
+ ancestorGridId: string
146
+ ) {
147
+ let current = elementsById.get(possibleDescendantGridId);
148
+ const visited = new Set<string>();
149
+ while (current?.parentGridId) {
150
+ if (visited.has(current.id)) return false;
151
+ visited.add(current.id);
152
+ if (current.parentGridId === ancestorGridId) return true;
153
+ current = elementsById.get(current.parentGridId);
154
+ }
155
+ return false;
156
+ }
157
+
158
+ export function MoveableLayer({
159
+ zoomX,
160
+ zoomY,
161
+ canvasContainer,
162
+ dragContainer,
163
+ selectedTargets,
164
+ elementsById,
165
+ updateElement,
166
+ refreshToken,
167
+ viewportScroll,
168
+ }: MoveableLayerProps) {
169
+ const moveableRef = useRef<any>(null);
170
+ const targets = useMemo(() => {
171
+ const validTargets = selectedTargets.filter(
172
+ (target) =>
173
+ !!target &&
174
+ target.isConnected &&
175
+ !!target.closest("[data-panel-canvas]")
176
+ );
177
+ return validTargets.length ? validTargets : null;
178
+ }, [selectedTargets]);
179
+
180
+ const getId = useCallback((target: HTMLElement) => {
181
+ const el = target.closest<HTMLElement>(".rv-selectable");
182
+ return el?.dataset.elementId ?? null;
183
+ }, []);
184
+ const selectedIds = useMemo(
185
+ () =>
186
+ (targets ?? [])
187
+ .map((target) => getId(target))
188
+ .filter((id): id is string => !!id),
189
+ [getId, targets]
190
+ );
191
+ const lockedSelected = useMemo(
192
+ () => selectedIds.filter((id) => elementsById.get(id)?.locked),
193
+ [elementsById, selectedIds]
194
+ );
195
+ const hasLockedSelected = lockedSelected.length > 0;
196
+ const movableTargets = useMemo(() => {
197
+ if (!targets) return null;
198
+ const unlocked = targets.filter((target) => {
199
+ const id = getId(target);
200
+ if (!id) return false;
201
+ return !elementsById.get(id)?.locked;
202
+ });
203
+ return unlocked.length ? unlocked : null;
204
+ }, [elementsById, getId, targets]);
205
+ const movableIds = useMemo(
206
+ () =>
207
+ (movableTargets ?? [])
208
+ .map((target) => getId(target))
209
+ .filter((id): id is string => !!id),
210
+ [getId, movableTargets]
211
+ );
212
+ const moveableRenderKey = useMemo(() => movableIds.join(","), [movableIds]);
213
+ const canvasScale = useMemo(
214
+ () => uniformViewportZoom({ x: zoomX, y: zoomY }),
215
+ [zoomX, zoomY]
216
+ );
217
+ /** 控制框在 scale 画布内渲染,用倒数保持屏幕像素尺寸 */
218
+ const moveableControlZoom = useMemo(
219
+ () => 1 / Math.max(0.0001, canvasScale),
220
+ [canvasScale]
221
+ );
222
+ const pointerRoot = dragContainer ?? canvasContainer;
223
+ const resolveSingleEventId = useCallback(
224
+ (target: HTMLElement | undefined | null) => {
225
+ const id = target ? getId(target) : null;
226
+ if (id) return id;
227
+ // 过滤锁定后只剩一个可移动节点时,Moveable 某些事件 target 可能不是节点本身
228
+ if (movableIds.length === 1) return movableIds[0] ?? null;
229
+ return null;
230
+ },
231
+ [getId, movableIds]
232
+ );
233
+ const toCanvasDeltaX = useCallback(
234
+ (value: number) => value / Math.max(0.0001, canvasScale),
235
+ [canvasScale]
236
+ );
237
+ const toCanvasDeltaY = useCallback(
238
+ (value: number) => value / Math.max(0.0001, canvasScale),
239
+ [canvasScale]
240
+ );
241
+ const readClientPoint = useCallback((eventLike: any) => {
242
+ const x =
243
+ typeof eventLike?.clientX === "number"
244
+ ? eventLike.clientX
245
+ : typeof eventLike?.inputEvent?.clientX === "number"
246
+ ? eventLike.inputEvent.clientX
247
+ : null;
248
+ const y =
249
+ typeof eventLike?.clientY === "number"
250
+ ? eventLike.clientY
251
+ : typeof eventLike?.inputEvent?.clientY === "number"
252
+ ? eventLike.inputEvent.clientY
253
+ : null;
254
+ if (x === null || y === null) return null;
255
+ return { x, y };
256
+ }, []);
257
+ const readTargetCanvasSize = useCallback(
258
+ (target: HTMLElement | null | undefined, fallback: { width: number; height: number }) => {
259
+ const rect = target?.getBoundingClientRect?.();
260
+ if (rect && rect.width > 0 && rect.height > 0) {
261
+ return {
262
+ width: Math.max(1, toCanvasDeltaX(rect.width)),
263
+ height: Math.max(1, toCanvasDeltaY(rect.height)),
264
+ };
265
+ }
266
+ return { width: Math.max(1, fallback.width), height: Math.max(1, fallback.height) };
267
+ },
268
+ [toCanvasDeltaX, toCanvasDeltaY]
269
+ );
270
+ const [lockBadgeScreen, setLockBadgeScreen] = useState<{ x: number; y: number } | null>(
271
+ null
272
+ );
273
+ useEffect(() => {
274
+ const id = lockedSelected[0];
275
+ if (!id || !canvasContainer) {
276
+ setLockBadgeScreen(null);
277
+ return;
278
+ }
279
+ const target = selectedTargets.find(
280
+ (node) => node.closest<HTMLElement>(".rv-selectable")?.dataset.elementId === id
281
+ );
282
+ if (!target) {
283
+ setLockBadgeScreen(null);
284
+ return;
285
+ }
286
+ const sync = () => {
287
+ const rect = target.getBoundingClientRect();
288
+ const rootRect = canvasContainer.getBoundingClientRect();
289
+ setLockBadgeScreen({
290
+ x: rect.left - rootRect.left,
291
+ y: rect.top - rootRect.top,
292
+ });
293
+ };
294
+ sync();
295
+ const ro = new ResizeObserver(sync);
296
+ ro.observe(target);
297
+ ro.observe(canvasContainer);
298
+ canvasContainer.addEventListener("scroll", sync, { passive: true });
299
+ return () => {
300
+ ro.disconnect();
301
+ canvasContainer.removeEventListener("scroll", sync);
302
+ };
303
+ }, [canvasContainer, lockedSelected, selectedTargets, viewportScroll, zoomX, zoomY]);
304
+
305
+ const updateRectNextFrame = useCallback(() => {
306
+ // InfiniteViewer 用 transform 平移,需等视口与节点布局提交后再 updateRect
307
+ requestAnimationFrame(() => {
308
+ requestAnimationFrame(() => {
309
+ moveableRef.current?.updateRect?.();
310
+ });
311
+ });
312
+ }, []);
313
+ const getGridDirectChildren = useCallback(
314
+ (gridId: string) => {
315
+ const result: PanelElement[] = [];
316
+ for (const el of elementsById.values()) {
317
+ if (el.parentGridId !== gridId) continue;
318
+ result.push(el);
319
+ }
320
+ return result;
321
+ },
322
+ [elementsById]
323
+ );
324
+ const getSnapPatch = useCallback(
325
+ (movingId: string, x: number, y: number, width: number, height: number): Partial<PanelElement> => {
326
+ const moving = elementsById.get(movingId);
327
+ if (!moving) return {};
328
+ const centerX = x + width / 2;
329
+ const centerY = y + height / 2;
330
+ let nearest:
331
+ | {
332
+ gridId: string;
333
+ slotIndex: number;
334
+ slotX: number;
335
+ slotY: number;
336
+ width: number;
337
+ height: number;
338
+ colSpan: number;
339
+ rowSpan: number;
340
+ distance: number;
341
+ }
342
+ | null = null;
343
+ for (const [id, el] of elementsById.entries()) {
344
+ if (id === movingId || el.materialType !== "grid") continue;
345
+ if (el.layerId !== moving.layerId) continue;
346
+ if (
347
+ moving.materialType === "grid" &&
348
+ isDescendantGrid(elementsById, id, movingId)
349
+ ) {
350
+ // 防止把父 grid 吸附到自己的子孙 grid,形成循环父子关系
351
+ continue;
352
+ }
353
+ const { slots, cellWidth, cellHeight, rows, cols, gap } = getGridSlotLayout(el);
354
+ const occupiedSlots = getOccupiedSlotSet(elementsById, el.id, movingId, moving.layerId);
355
+ const threshold = Math.max(8, el.gridSnapThreshold ?? 36);
356
+ // 跨格优先采用显式 span,同时根据当前节点尺寸推断期望 span,避免“视觉占两格但逻辑仍是一格”
357
+ const inferredColSpan = inferSpanBySize(width, cellWidth, gap, cols);
358
+ const inferredRowSpan = inferSpanBySize(height, cellHeight, gap, rows);
359
+ // span 应该由当前尺寸主导,允许从大格回退到小格
360
+ const movingColSpan = Math.max(1, inferredColSpan);
361
+ const movingRowSpan = Math.max(1, inferredRowSpan);
362
+ const insideGridBounds =
363
+ centerX >= el.x &&
364
+ centerX <= el.x + Math.max(1, el.width) &&
365
+ centerY >= el.y &&
366
+ centerY <= el.y + Math.max(1, el.height);
367
+ for (const slot of slots) {
368
+ const spanCells = getGridSpanCells(
369
+ rows,
370
+ cols,
371
+ slot.index,
372
+ movingColSpan,
373
+ movingRowSpan
374
+ );
375
+ // 只接受能完整容纳目标 span 的槽位,避免“想占满但被起点裁剪”。
376
+ if (spanCells.colSpan !== movingColSpan || spanCells.rowSpan !== movingRowSpan) {
377
+ continue;
378
+ }
379
+ const blocked = spanCells.indices.some((idx) => occupiedSlots.has(idx));
380
+ if (blocked) continue;
381
+ const distance = Math.hypot(slot.centerX - centerX, slot.centerY - centerY);
382
+ // 当节点中心已经进入网格区域时,允许吸附到任意空槽位(不受阈值限制)
383
+ if (!insideGridBounds && distance > threshold) continue;
384
+ if (!nearest || distance < nearest.distance) {
385
+ const spanWidth = spanCells.colSpan * cellWidth + (spanCells.colSpan - 1) * gap;
386
+ const spanHeight = spanCells.rowSpan * cellHeight + (spanCells.rowSpan - 1) * gap;
387
+ nearest = {
388
+ gridId: el.id,
389
+ slotIndex: spanCells.safeIndex,
390
+ slotX: slot.x,
391
+ slotY: slot.y,
392
+ width: spanWidth,
393
+ height: spanHeight,
394
+ colSpan: spanCells.colSpan,
395
+ rowSpan: spanCells.rowSpan,
396
+ distance,
397
+ };
398
+ }
399
+ }
400
+ }
401
+ if (!nearest) {
402
+ if (moving.parentGridId || moving.gridSlotIndex !== undefined) {
403
+ return { parentGridId: undefined, gridSlotIndex: undefined };
404
+ }
405
+ return {};
406
+ }
407
+ return {
408
+ x: nearest.slotX,
409
+ y: nearest.slotY,
410
+ parentGridId: nearest.gridId,
411
+ gridSlotIndex: nearest.slotIndex,
412
+ gridColSpan: nearest.colSpan,
413
+ gridRowSpan: nearest.rowSpan,
414
+ width: nearest.width,
415
+ height: nearest.height,
416
+ };
417
+ },
418
+ [elementsById]
419
+ );
420
+
421
+ useEffect(() => {
422
+ if (!targets) return;
423
+ updateRectNextFrame();
424
+ }, [targets, updateRectNextFrame]);
425
+
426
+ useEffect(() => {
427
+ if (!targets) return;
428
+ updateRectNextFrame();
429
+ }, [refreshToken, targets, updateRectNextFrame]);
430
+
431
+ useEffect(() => {
432
+ if (!targets) return;
433
+ updateRectNextFrame();
434
+ }, [targets, updateRectNextFrame, viewportScroll?.left, viewportScroll?.top]);
435
+
436
+ useEffect(() => {
437
+ if (!targets) return;
438
+ updateRectNextFrame();
439
+ }, [targets, updateRectNextFrame, zoomX, zoomY]);
440
+
441
+ if (!canvasContainer) return null;
442
+
443
+ const lockBadgeOnly =
444
+ lockBadgeScreen && hasLockedSelected && !movableTargets ? (
445
+ <div
446
+ className="pointer-events-none absolute z-[80] inline-flex select-none items-center gap-1 rounded border border-border bg-background/95 px-1.5 py-0.5 text-[11px] text-foreground shadow-sm"
447
+ style={{ left: lockBadgeScreen.x + 6, top: lockBadgeScreen.y - 22 }}
448
+ title="节点已锁定"
449
+ >
450
+ <LockGlyph />
451
+ <span>已锁定</span>
452
+ </div>
453
+ ) : null;
454
+
455
+ if (!targets) {
456
+ return lockBadgeOnly ? createPortal(lockBadgeOnly, canvasContainer) : null;
457
+ }
458
+
459
+ if (!movableTargets) {
460
+ return lockBadgeOnly ? createPortal(lockBadgeOnly, canvasContainer) : null;
461
+ }
462
+
463
+ const layer = (
464
+ <>
465
+ <Moveable
466
+ key={moveableRenderKey}
467
+ ref={moveableRef}
468
+ // Moveable 支持单个 HTMLElement 或 HTMLElement[]
469
+ target={movableTargets as unknown as HTMLElement[]}
470
+ rootContainer={canvasContainer}
471
+ container={canvasContainer}
472
+ dragContainer={pointerRoot ?? canvasContainer}
473
+ className="rv-moveable-layer"
474
+ zoom={moveableControlZoom}
475
+ // 允许在控制框内部区域拖动(不必必须点到某个节点本体)
476
+ dragArea
477
+ draggable
478
+ resizable
479
+ rotatable
480
+ origin={false}
481
+ throttleDrag={0}
482
+ throttleResize={0}
483
+ throttleRotate={0}
484
+ onDragStart={(e: any) => {
485
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
486
+ if (!id) return;
487
+ const data = elementsById.get(id);
488
+ if (!data) return;
489
+ e.set([0, 0]);
490
+ e.datas.__startX = data.x;
491
+ e.datas.__startY = data.y;
492
+ const point = readClientPoint(e);
493
+ e.datas.__startClientX = point?.x ?? null;
494
+ e.datas.__startClientY = point?.y ?? null;
495
+ }}
496
+ onDrag={(e: any) => {
497
+ if (!e?.target?.style) return;
498
+ if (e.datas.__startX === undefined || e.datas.__startY === undefined) return;
499
+ const sx = e.datas.__startX ?? 0;
500
+ const sy = e.datas.__startY ?? 0;
501
+ const point = readClientPoint(e);
502
+ const hasClient =
503
+ typeof e.datas.__startClientX === "number" &&
504
+ typeof e.datas.__startClientY === "number" &&
505
+ !!point;
506
+ const tx = hasClient
507
+ ? toCanvasDeltaX(point.x - e.datas.__startClientX)
508
+ : toCanvasDeltaX(e.beforeTranslate?.[0] ?? 0);
509
+ const ty = hasClient
510
+ ? toCanvasDeltaY(point.y - e.datas.__startClientY)
511
+ : toCanvasDeltaY(e.beforeTranslate?.[1] ?? 0);
512
+ e.target.style.left = `${sx + tx}px`;
513
+ e.target.style.top = `${sy + ty}px`;
514
+ }}
515
+ onDragGroupStart={(e: any) => {
516
+ e.events.forEach((ev: any) => {
517
+ const id = ev.target ? getId(ev.target) : null;
518
+ if (!id) return;
519
+ const data = elementsById.get(id);
520
+ if (!data) return;
521
+ ev.set([0, 0]);
522
+ ev.datas.__startX = data.x;
523
+ ev.datas.__startY = data.y;
524
+ const point = readClientPoint(ev) ?? readClientPoint(e);
525
+ ev.datas.__startClientX = point?.x ?? null;
526
+ ev.datas.__startClientY = point?.y ?? null;
527
+ });
528
+ }}
529
+ onDragGroup={(e: any) => {
530
+ e.events.forEach((ev: any) => {
531
+ if (!ev?.target?.style) return;
532
+ const sx = ev.datas.__startX ?? 0;
533
+ const sy = ev.datas.__startY ?? 0;
534
+ const point = readClientPoint(ev) ?? readClientPoint(e);
535
+ const hasClient =
536
+ typeof ev.datas.__startClientX === "number" &&
537
+ typeof ev.datas.__startClientY === "number" &&
538
+ !!point;
539
+ const tx = hasClient
540
+ ? toCanvasDeltaX(point.x - ev.datas.__startClientX)
541
+ : toCanvasDeltaX(ev.beforeTranslate?.[0] ?? 0);
542
+ const ty = hasClient
543
+ ? toCanvasDeltaY(point.y - ev.datas.__startClientY)
544
+ : toCanvasDeltaY(ev.beforeTranslate?.[1] ?? 0);
545
+ ev.target.style.left = `${sx + tx}px`;
546
+ ev.target.style.top = `${sy + ty}px`;
547
+ });
548
+ }}
549
+ onResizeStart={(e: any) => {
550
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
551
+ if (!id) return;
552
+ const data = elementsById.get(id);
553
+ if (!data) return;
554
+ e.setOrigin(["%", "%"]);
555
+ e.dragStart?.set([0, 0]);
556
+ e.datas.__startX = data.x;
557
+ e.datas.__startY = data.y;
558
+ e.datas.__startClientX = e.inputEvent?.clientX ?? null;
559
+ e.datas.__startClientY = e.inputEvent?.clientY ?? null;
560
+ }}
561
+ onResize={(e: any) => {
562
+ if (!e?.target?.style) return;
563
+ if (e.datas.__startX === undefined || e.datas.__startY === undefined) return;
564
+ e.target.style.width = `${e.width}px`;
565
+ e.target.style.height = `${e.height}px`;
566
+ const sx = e.datas.__startX ?? 0;
567
+ const sy = e.datas.__startY ?? 0;
568
+ // resize 场景下位移应以 moveable 的 drag 偏移为准,避免 client 坐标换算导致漂移
569
+ const tx = toCanvasDeltaX(e.drag.beforeTranslate?.[0] ?? 0);
570
+ const ty = toCanvasDeltaY(e.drag.beforeTranslate?.[1] ?? 0);
571
+ e.target.style.left = `${sx + tx}px`;
572
+ e.target.style.top = `${sy + ty}px`;
573
+ notifyPreviewLayoutChanged();
574
+ }}
575
+ onResizeGroupStart={(e: any) => {
576
+ e.events.forEach((ev: any) => {
577
+ const id = ev.target ? getId(ev.target) : null;
578
+ if (!id) return;
579
+ const data = elementsById.get(id);
580
+ if (!data) return;
581
+ ev.setOrigin(["%", "%"]);
582
+ ev.dragStart?.set([0, 0]);
583
+ ev.datas.__startX = data.x;
584
+ ev.datas.__startY = data.y;
585
+ const input = ev.inputEvent ?? e.inputEvent;
586
+ ev.datas.__startClientX = input?.clientX ?? null;
587
+ ev.datas.__startClientY = input?.clientY ?? null;
588
+ });
589
+ }}
590
+ onResizeGroup={(e: any) => {
591
+ e.events.forEach((ev: any) => {
592
+ if (!ev?.target?.style) return;
593
+ ev.target.style.width = `${ev.width}px`;
594
+ ev.target.style.height = `${ev.height}px`;
595
+ const sx = ev.datas.__startX ?? 0;
596
+ const sy = ev.datas.__startY ?? 0;
597
+ const tx = toCanvasDeltaX(ev.drag.beforeTranslate?.[0] ?? 0);
598
+ const ty = toCanvasDeltaY(ev.drag.beforeTranslate?.[1] ?? 0);
599
+ ev.target.style.left = `${sx + tx}px`;
600
+ ev.target.style.top = `${sy + ty}px`;
601
+ });
602
+ notifyPreviewLayoutChanged();
603
+ }}
604
+ onRotateStart={(e: any) => {
605
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
606
+ if (!id) return;
607
+ const data = elementsById.get(id);
608
+ if (!data) return;
609
+ e.set(data.rotate ?? 0);
610
+ }}
611
+ onRotate={(e: any) => {
612
+ if (!e?.target?.style) return;
613
+ e.target.style.transform = `rotate(${e.beforeRotate}deg)`;
614
+ }}
615
+ onRotateGroup={(e: any) => {
616
+ e.events.forEach((ev: any) => {
617
+ if (!ev?.target?.style) return;
618
+ ev.target.style.transform = `rotate(${ev.beforeRotate}deg)`;
619
+ });
620
+ }}
621
+ onDragEnd={(e: any) => {
622
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
623
+ if (!id) return;
624
+ const data = elementsById.get(id);
625
+ if (!data) return;
626
+ const sx = e.datas.__startX ?? data.x;
627
+ const sy = e.datas.__startY ?? data.y;
628
+ const point = readClientPoint(e);
629
+ const hasClient =
630
+ typeof e.datas.__startClientX === "number" &&
631
+ typeof e.datas.__startClientY === "number" &&
632
+ !!point;
633
+ const tx = hasClient
634
+ ? toCanvasDeltaX(point.x - e.datas.__startClientX)
635
+ : toCanvasDeltaX(e.lastEvent?.beforeTranslate?.[0] ?? 0);
636
+ const ty = hasClient
637
+ ? toCanvasDeltaY(point.y - e.datas.__startClientY)
638
+ : toCanvasDeltaY(e.lastEvent?.beforeTranslate?.[1] ?? 0);
639
+ const nextX = sx + tx;
640
+ const nextY = sy + ty;
641
+ const size = readTargetCanvasSize(e.target as HTMLElement | null, {
642
+ width: data.width,
643
+ height: data.height,
644
+ });
645
+ const patch = getSnapPatch(id, nextX, nextY, size.width, size.height);
646
+ updateElement(id, { x: nextX, y: nextY, ...patch });
647
+ if (data.materialType === "grid") {
648
+ const dx = nextX - data.x;
649
+ const dy = nextY - data.y;
650
+ if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) {
651
+ const children = getGridDirectChildren(id);
652
+ const batchId = `move-grid-children-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
653
+ children.forEach((child) => {
654
+ updateElement(
655
+ child.id,
656
+ { x: child.x + dx, y: child.y + dy },
657
+ { batchId, meta: { type: "node.group-drag" } }
658
+ );
659
+ });
660
+ }
661
+ }
662
+ updateRectNextFrame();
663
+ }}
664
+ onDragGroupEnd={(e: any) => {
665
+ const batchId = `move-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
666
+ const selectedSet = new Set(
667
+ e.events
668
+ .map((ev: any) => (ev.target ? getId(ev.target) : null))
669
+ .filter((id: string | null): id is string => !!id)
670
+ );
671
+ const gridDeltaMap = new Map<string, { dx: number; dy: number }>();
672
+ e.events.forEach((ev: any) => {
673
+ const id = ev.target ? getId(ev.target) : null;
674
+ if (!id) return;
675
+ const data = elementsById.get(id);
676
+ if (!data) return;
677
+ const sx = ev.datas.__startX ?? data.x;
678
+ const sy = ev.datas.__startY ?? data.y;
679
+ const point = readClientPoint(ev) ?? readClientPoint(e);
680
+ const hasClient =
681
+ typeof ev.datas.__startClientX === "number" &&
682
+ typeof ev.datas.__startClientY === "number" &&
683
+ !!point;
684
+ const tx = hasClient
685
+ ? toCanvasDeltaX(point.x - ev.datas.__startClientX)
686
+ : toCanvasDeltaX(ev.lastEvent?.beforeTranslate?.[0] ?? 0);
687
+ const ty = hasClient
688
+ ? toCanvasDeltaY(point.y - ev.datas.__startClientY)
689
+ : toCanvasDeltaY(ev.lastEvent?.beforeTranslate?.[1] ?? 0);
690
+ const nextX = sx + tx;
691
+ const nextY = sy + ty;
692
+ const size = readTargetCanvasSize(ev.target as HTMLElement | null, {
693
+ width: data.width,
694
+ height: data.height,
695
+ });
696
+ const patch = getSnapPatch(id, nextX, nextY, size.width, size.height);
697
+ updateElement(
698
+ id,
699
+ { x: nextX, y: nextY, ...patch },
700
+ { batchId, meta: { type: "node.group-drag" } }
701
+ );
702
+ if (data.materialType === "grid") {
703
+ const dx = nextX - data.x;
704
+ const dy = nextY - data.y;
705
+ if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) {
706
+ gridDeltaMap.set(id, { dx, dy });
707
+ }
708
+ }
709
+ });
710
+ for (const [gridId, delta] of gridDeltaMap.entries()) {
711
+ const children = getGridDirectChildren(gridId);
712
+ children.forEach((child) => {
713
+ if (selectedSet.has(child.id)) return;
714
+ updateElement(
715
+ child.id,
716
+ { x: child.x + delta.dx, y: child.y + delta.dy },
717
+ { batchId, meta: { type: "node.group-drag" } }
718
+ );
719
+ });
720
+ }
721
+ updateRectNextFrame();
722
+ }}
723
+ onResizeEnd={(e: any) => {
724
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
725
+ if (!id) return;
726
+ const data = elementsById.get(id);
727
+ if (!data) return;
728
+ const width = e.lastEvent?.width ?? data.width;
729
+ const height = e.lastEvent?.height ?? data.height;
730
+ const tx = toCanvasDeltaX(e.lastEvent?.drag?.beforeTranslate?.[0] ?? 0);
731
+ const ty = toCanvasDeltaY(e.lastEvent?.drag?.beforeTranslate?.[1] ?? 0);
732
+ const sx = e.datas.__startX ?? data.x;
733
+ const sy = e.datas.__startY ?? data.y;
734
+ const nextX = sx + tx;
735
+ const nextY = sy + ty;
736
+ const size = readTargetCanvasSize(e.target as HTMLElement | null, { width, height });
737
+ const snapPatch = getSnapPatch(id, nextX, nextY, size.width, size.height);
738
+ updateElement(id, { width, height, x: nextX, y: nextY, ...snapPatch });
739
+ updateRectNextFrame();
740
+ }}
741
+ onResizeGroupEnd={(e: any) => {
742
+ const batchId = `resize-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
743
+ e.events.forEach((ev: any) => {
744
+ const id = ev.target ? getId(ev.target) : null;
745
+ if (!id) return;
746
+ const data = elementsById.get(id);
747
+ if (!data) return;
748
+ const width = ev.lastEvent?.width ?? data.width;
749
+ const height = ev.lastEvent?.height ?? data.height;
750
+ const tx = toCanvasDeltaX(ev.lastEvent?.drag?.beforeTranslate?.[0] ?? 0);
751
+ const ty = toCanvasDeltaY(ev.lastEvent?.drag?.beforeTranslate?.[1] ?? 0);
752
+ const sx = ev.datas.__startX ?? data.x;
753
+ const sy = ev.datas.__startY ?? data.y;
754
+ const nextX = sx + tx;
755
+ const nextY = sy + ty;
756
+ const size = readTargetCanvasSize(ev.target as HTMLElement | null, { width, height });
757
+ const snapPatch = getSnapPatch(id, nextX, nextY, size.width, size.height);
758
+ updateElement(
759
+ id,
760
+ { width, height, x: nextX, y: nextY, ...snapPatch },
761
+ { batchId, meta: { type: "node.group-resize" } }
762
+ );
763
+ });
764
+ updateRectNextFrame();
765
+ }}
766
+ onRotateEnd={(e: any) => {
767
+ const id = resolveSingleEventId(e.target as HTMLElement | null);
768
+ if (!id) return;
769
+ const data = elementsById.get(id);
770
+ if (!data) return;
771
+ const rotate = e.lastEvent?.beforeRotate ?? data.rotate ?? 0;
772
+ updateElement(id, { rotate });
773
+ updateRectNextFrame();
774
+ }}
775
+ onRotateGroupEnd={(e: any) => {
776
+ const batchId = `rotate-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
777
+ e.events.forEach((ev: any) => {
778
+ const id = ev.target ? getId(ev.target) : null;
779
+ if (!id) return;
780
+ const data = elementsById.get(id);
781
+ if (!data) return;
782
+ const rotate = ev.lastEvent?.beforeRotate ?? data.rotate ?? 0;
783
+ updateElement(id, { rotate }, { batchId, meta: { type: "node.group-rotate" } });
784
+ });
785
+ updateRectNextFrame();
786
+ }}
787
+ />
788
+ {hasLockedSelected && lockBadgeScreen ? (
789
+ <div
790
+ className="pointer-events-none absolute z-[80] inline-flex select-none items-center gap-1 rounded border border-border bg-background/95 px-1.5 py-0.5 text-[11px] text-foreground shadow-sm"
791
+ style={{ left: lockBadgeScreen.x + 6, top: lockBadgeScreen.y - 22 }}
792
+ title="部分节点已锁定,已自动排除"
793
+ >
794
+ <LockGlyph />
795
+ <span>锁定节点已排除</span>
796
+ </div>
797
+ ) : null}
798
+ </>
799
+ );
800
+
801
+ return createPortal(layer, canvasContainer);
802
+ }
803
+