@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,660 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import InfiniteViewer from "react-infinite-viewer";
3
+ import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
4
+
5
+ import {
6
+ clampViewportZoom,
7
+ clampViewportZoomXY,
8
+ uniformViewportZoom,
9
+ type ViewportZoom,
10
+ } from "../viewportZoom";
11
+
12
+ export type PanelCanvasProps = {
13
+ zoom: ViewportZoom;
14
+ onZoomChange: (next: ViewportZoom) => void;
15
+ contentSize?: { width: number; height: number } | null;
16
+ onScrollChange?: (pos: { left: number; top: number }) => void;
17
+ children: React.ReactNode;
18
+ className?: string;
19
+ canvasRef?: React.Ref<HTMLDivElement>;
20
+ onCanvasMouseDownCapture?: React.MouseEventHandler<HTMLDivElement>;
21
+ onCanvasClickCapture?: React.MouseEventHandler<HTMLDivElement>;
22
+ onCanvasContextMenuCapture?: React.MouseEventHandler<HTMLDivElement>;
23
+ onDropMaterial?: (payload: { materialId: string; x: number; y: number }) => void;
24
+ /** 渲染在 InfiniteViewer 内、画布 scale 层之外(如 Moveable 控制框) */
25
+ viewportOverlay?: React.ReactNode;
26
+ };
27
+
28
+ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
29
+ (
30
+ {
31
+ zoom,
32
+ onZoomChange,
33
+ contentSize = null,
34
+ onScrollChange,
35
+ children,
36
+ className,
37
+ canvasRef,
38
+ onCanvasMouseDownCapture,
39
+ onCanvasClickCapture,
40
+ onCanvasContextMenuCapture,
41
+ onDropMaterial,
42
+ viewportOverlay,
43
+ },
44
+ scrollRef
45
+ ) => {
46
+ const viewportRef = useRef<HTMLDivElement | null>(null);
47
+ const viewerRef = useRef<any>(null);
48
+ const transformRef = useRef<any>(null);
49
+ const canvasElRef = useRef<HTMLDivElement | null>(null);
50
+ const syncingZoomRef = useRef(false);
51
+ const didAutoCenterRef = useRef(false);
52
+ const [isPanning, setIsPanning] = useState(false);
53
+ const lastScrollRef = useRef({ left: 0, top: 0 });
54
+ const zoomRef = useRef(zoom);
55
+ const viewportSizeRef = useRef({ width: 0, height: 0 });
56
+ const resizeZoomRafRef = useRef<number | null>(null);
57
+ const resizeLayoutAnchorRef = useRef<{
58
+ viewportWidth: number;
59
+ viewportHeight: number;
60
+ zoomX: number;
61
+ zoomY: number;
62
+ scrollLeft: number;
63
+ scrollTop: number;
64
+ } | null>(null);
65
+ /** 上一次已应用的视口尺寸与分轴 zoom(每次 resize / 滚轮缩放后更新,保证开合蓝图可逆) */
66
+ const layoutBaselineRef = useRef({
67
+ viewportWidth: 0,
68
+ viewportHeight: 0,
69
+ zoomX: 1,
70
+ zoomY: 1,
71
+ });
72
+ const [viewport, setViewport] = useState({ width: 0, height: 0 });
73
+ /** 固定世界画布尺寸,不随 zoom / 当前视口变化,避免缩放与 DOM 尺寸竞态导致节点错位 */
74
+ const [worldCanvasSize, setWorldCanvasSize] = useState<{
75
+ width: number;
76
+ height: number;
77
+ } | null>(null);
78
+
79
+ const commitScroll = useCallback(
80
+ (left: number, top: number) => {
81
+ const next = { left, top };
82
+ lastScrollRef.current = next;
83
+ onScrollChange?.(next);
84
+ },
85
+ [onScrollChange]
86
+ );
87
+
88
+ useEffect(() => {
89
+ zoomRef.current = zoom;
90
+ }, [zoom]);
91
+
92
+ useEffect(() => {
93
+ viewportSizeRef.current = viewport;
94
+ }, [viewport]);
95
+
96
+ useEffect(() => {
97
+ if (contentSize) return;
98
+ if (viewport.width < 8 || viewport.height < 8) return;
99
+ setWorldCanvasSize((prev) => {
100
+ if (prev) return prev;
101
+ const size = { width: viewport.width, height: viewport.height };
102
+ layoutBaselineRef.current = {
103
+ viewportWidth: viewport.width,
104
+ viewportHeight: viewport.height,
105
+ zoomX: zoomRef.current.x,
106
+ zoomY: zoomRef.current.y,
107
+ };
108
+ resizeLayoutAnchorRef.current = {
109
+ viewportWidth: viewport.width,
110
+ viewportHeight: viewport.height,
111
+ zoomX: zoomRef.current.x,
112
+ zoomY: zoomRef.current.y,
113
+ scrollLeft: lastScrollRef.current.left,
114
+ scrollTop: lastScrollRef.current.top,
115
+ };
116
+ return size;
117
+ });
118
+ }, [contentSize, viewport.height, viewport.width]);
119
+
120
+ const panRef = useRef<{
121
+ active: boolean;
122
+ moved: boolean;
123
+ startClientX: number;
124
+ startClientY: number;
125
+ startLeft: number;
126
+ startTop: number;
127
+ }>({
128
+ active: false,
129
+ moved: false,
130
+ startClientX: 0,
131
+ startClientY: 0,
132
+ startLeft: 0,
133
+ startTop: 0,
134
+ });
135
+ const panHandlersRef = useRef<{
136
+ move: ((ev: MouseEvent) => void) | null;
137
+ up: ((ev: MouseEvent) => void) | null;
138
+ }>({ move: null, up: null });
139
+
140
+ const clearWindowPanListeners = useCallback(() => {
141
+ if (panHandlersRef.current.move) {
142
+ window.removeEventListener("mousemove", panHandlersRef.current.move, true);
143
+ panHandlersRef.current.move = null;
144
+ }
145
+ if (panHandlersRef.current.up) {
146
+ window.removeEventListener("mouseup", panHandlersRef.current.up, true);
147
+ panHandlersRef.current.up = null;
148
+ }
149
+ }, []);
150
+
151
+ useEffect(() => clearWindowPanListeners, [clearWindowPanListeners]);
152
+
153
+ useEffect(() => {
154
+ const el = viewportRef.current;
155
+ if (!el) return;
156
+
157
+ const onWheelZoom = (e: WheelEvent) => {
158
+ const api = transformRef.current;
159
+ if (!api) return;
160
+ const viewer = viewerRef.current as any;
161
+ if (!viewer) return;
162
+ const target = e.target as HTMLElement | null;
163
+ if (
164
+ target?.closest("input, textarea, select, [contenteditable='true']") ||
165
+ target?.closest("[role='dialog']")
166
+ ) {
167
+ return;
168
+ }
169
+ e.preventDefault();
170
+ const rect = el.getBoundingClientRect();
171
+ const pointerX = e.clientX - rect.left;
172
+ const pointerY = e.clientY - rect.top;
173
+ const zx = zoomRef.current.x;
174
+ const zy = zoomRef.current.y;
175
+ const currentScrollLeft = Number(lastScrollRef.current.left ?? 0);
176
+ const currentScrollTop = Number(lastScrollRef.current.top ?? 0);
177
+
178
+ const direction = e.deltaY > 0 ? -1 : 1;
179
+ const zoomStep = direction > 0 ? 1.08 : 0.92;
180
+ const nextZoom = clampViewportZoomXY({
181
+ x: zx * zoomStep,
182
+ y: zy * zoomStep,
183
+ });
184
+ if (Math.abs(nextZoom.x - zx) < 0.0001 && Math.abs(nextZoom.y - zy) < 0.0001) {
185
+ return;
186
+ }
187
+
188
+ const worldX = (pointerX + currentScrollLeft) / Math.max(0.0001, zx);
189
+ const worldY = (pointerY + currentScrollTop) / Math.max(0.0001, zy);
190
+ const nextScrollLeft = worldX * nextZoom.x - pointerX;
191
+ const nextScrollTop = worldY * nextZoom.y - pointerY;
192
+
193
+ syncingZoomRef.current = true;
194
+ viewer.scrollTo(nextScrollLeft, nextScrollTop);
195
+ commitScroll(nextScrollLeft, nextScrollTop);
196
+ const { width: vw, height: vh } = viewportSizeRef.current;
197
+ resizeLayoutAnchorRef.current = {
198
+ viewportWidth: vw,
199
+ viewportHeight: vh,
200
+ zoomX: nextZoom.x,
201
+ zoomY: nextZoom.y,
202
+ scrollLeft: nextScrollLeft,
203
+ scrollTop: nextScrollTop,
204
+ };
205
+ layoutBaselineRef.current = {
206
+ viewportWidth: vw,
207
+ viewportHeight: vh,
208
+ zoomX: nextZoom.x,
209
+ zoomY: nextZoom.y,
210
+ };
211
+ zoomRef.current = nextZoom;
212
+ onZoomChange({
213
+ x: Number(nextZoom.x.toFixed(4)),
214
+ y: Number(nextZoom.y.toFixed(4)),
215
+ });
216
+ requestAnimationFrame(() => {
217
+ syncingZoomRef.current = false;
218
+ });
219
+ };
220
+
221
+ el.addEventListener("wheel", onWheelZoom, { passive: false, capture: true });
222
+ return () => {
223
+ el.removeEventListener("wheel", onWheelZoom, true);
224
+ };
225
+ }, [commitScroll, onZoomChange, viewport.width, viewport.height]);
226
+
227
+ const applyViewportResizeZoom = useCallback(
228
+ (w: number, h: number) => {
229
+ if (w < 8 || h < 8) return;
230
+
231
+ const viewer = viewerRef.current as {
232
+ scrollTo?: (x: number, y: number) => void;
233
+ } | null;
234
+ if (!viewer) return;
235
+
236
+ const baseline = layoutBaselineRef.current;
237
+ if (baseline.viewportWidth < 8 || baseline.viewportHeight < 8) {
238
+ layoutBaselineRef.current = {
239
+ viewportWidth: w,
240
+ viewportHeight: h,
241
+ zoomX: zoomRef.current.x,
242
+ zoomY: zoomRef.current.y,
243
+ };
244
+ resizeLayoutAnchorRef.current = {
245
+ viewportWidth: w,
246
+ viewportHeight: h,
247
+ zoomX: zoomRef.current.x,
248
+ zoomY: zoomRef.current.y,
249
+ scrollLeft: lastScrollRef.current.left,
250
+ scrollTop: lastScrollRef.current.top,
251
+ };
252
+ return;
253
+ }
254
+
255
+ if (
256
+ Math.abs(w - baseline.viewportWidth) < 2 &&
257
+ Math.abs(h - baseline.viewportHeight) < 2
258
+ ) {
259
+ return;
260
+ }
261
+
262
+ const factorX = w / baseline.viewportWidth;
263
+ const factorY = h / baseline.viewportHeight;
264
+ // 视口拉伸时用同一倍率,避免 scale(zoomX, zoomY) 非等比导致节点旋转变形
265
+ const factor = Math.sqrt(factorX * factorY);
266
+
267
+ const anchor = resizeLayoutAnchorRef.current ?? {
268
+ viewportWidth: w,
269
+ viewportHeight: h,
270
+ zoomX: zoomRef.current.x,
271
+ zoomY: zoomRef.current.y,
272
+ scrollLeft: lastScrollRef.current.left,
273
+ scrollTop: lastScrollRef.current.top,
274
+ };
275
+
276
+ const nextZoomValue = clampViewportZoom(baseline.zoomX * factor);
277
+ const worldX =
278
+ (anchor.scrollLeft + anchor.viewportWidth / 2) /
279
+ Math.max(0.0001, anchor.zoomX);
280
+ const worldY =
281
+ (anchor.scrollTop + anchor.viewportHeight / 2) /
282
+ Math.max(0.0001, anchor.zoomY);
283
+ const nextScrollLeft = worldX * nextZoomValue - w / 2;
284
+ const nextScrollTop = worldY * nextZoomValue - h / 2;
285
+ const nextZoom = { x: nextZoomValue, y: nextZoomValue };
286
+
287
+ syncingZoomRef.current = true;
288
+ viewer.scrollTo?.(nextScrollLeft, nextScrollTop);
289
+ commitScroll(nextScrollLeft, nextScrollTop);
290
+ resizeLayoutAnchorRef.current = {
291
+ viewportWidth: w,
292
+ viewportHeight: h,
293
+ zoomX: nextZoomValue,
294
+ zoomY: nextZoomValue,
295
+ scrollLeft: nextScrollLeft,
296
+ scrollTop: nextScrollTop,
297
+ };
298
+ layoutBaselineRef.current = {
299
+ viewportWidth: w,
300
+ viewportHeight: h,
301
+ zoomX: nextZoomValue,
302
+ zoomY: nextZoomValue,
303
+ };
304
+ zoomRef.current = nextZoom;
305
+ onZoomChange({
306
+ x: Number(nextZoomValue.toFixed(4)),
307
+ y: Number(nextZoomValue.toFixed(4)),
308
+ });
309
+ requestAnimationFrame(() => {
310
+ syncingZoomRef.current = false;
311
+ });
312
+ },
313
+ [commitScroll, onZoomChange]
314
+ );
315
+
316
+ const syncViewportElement = useCallback(
317
+ (el: HTMLDivElement | null) => {
318
+ viewportRef.current = el;
319
+ if (!scrollRef) return;
320
+ if (typeof scrollRef === "function") scrollRef(el);
321
+ else (scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
322
+ },
323
+ [scrollRef]
324
+ );
325
+
326
+ const syncCanvasElement = useCallback(
327
+ (el: HTMLDivElement | null) => {
328
+ canvasElRef.current = el;
329
+ if (!canvasRef) return;
330
+ if (typeof canvasRef === "function") canvasRef(el);
331
+ else (canvasRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
332
+ },
333
+ [canvasRef]
334
+ );
335
+
336
+ useEffect(() => {
337
+ const container = viewerRef.current?.getContainer?.() as HTMLDivElement | undefined;
338
+ syncViewportElement(container ?? null);
339
+ }, [syncViewportElement]);
340
+
341
+ useEffect(() => {
342
+ if (didAutoCenterRef.current) return;
343
+ const viewer = viewerRef.current as any;
344
+ const viewportEl = viewportRef.current;
345
+ if (!viewer || !viewportEl) return;
346
+
347
+ const id = requestAnimationFrame(() => {
348
+ const nodes = viewportEl.querySelectorAll<HTMLElement>(".rv-selectable");
349
+ if (!nodes.length) return;
350
+
351
+ let minX = Infinity;
352
+ let minY = Infinity;
353
+ let maxX = -Infinity;
354
+ let maxY = -Infinity;
355
+
356
+ nodes.forEach((node) => {
357
+ const left = node.offsetLeft;
358
+ const top = node.offsetTop;
359
+ const width = node.offsetWidth;
360
+ const height = node.offsetHeight;
361
+ minX = Math.min(minX, left);
362
+ minY = Math.min(minY, top);
363
+ maxX = Math.max(maxX, left + width);
364
+ maxY = Math.max(maxY, top + height);
365
+ });
366
+
367
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
368
+ const boundsWidth = Math.max(1, maxX - minX);
369
+ const boundsHeight = Math.max(1, maxY - minY);
370
+
371
+ const targetLeft = minX + boundsWidth / 2 - viewportEl.clientWidth / 2;
372
+ const targetTop = minY + boundsHeight / 2 - viewportEl.clientHeight / 2;
373
+ viewer.scrollTo(Math.round(targetLeft), Math.round(targetTop));
374
+ didAutoCenterRef.current = true;
375
+ });
376
+
377
+ return () => cancelAnimationFrame(id);
378
+ }, []);
379
+
380
+ useEffect(() => {
381
+ const el = viewportRef.current;
382
+ if (!el) return;
383
+
384
+ const update = () => {
385
+ const width = Math.round(el.clientWidth);
386
+ const height = Math.round(el.clientHeight);
387
+ setViewport((prev) =>
388
+ prev.width === width && prev.height === height ? prev : { width, height }
389
+ );
390
+ };
391
+ update();
392
+
393
+ if (typeof ResizeObserver !== "undefined") {
394
+ const ro = new ResizeObserver(update);
395
+ ro.observe(el);
396
+ return () => ro.disconnect();
397
+ }
398
+
399
+ window.addEventListener("resize", update);
400
+ return () => window.removeEventListener("resize", update);
401
+ }, []);
402
+
403
+ /** 操作区尺寸变化时同步缩放;基于上一次已应用的锚点,避免放大后滚动补偿错位 */
404
+ useEffect(() => {
405
+ if (contentSize) return;
406
+ if (viewport.width < 8 || viewport.height < 8) return;
407
+
408
+ if (resizeZoomRafRef.current != null) {
409
+ cancelAnimationFrame(resizeZoomRafRef.current);
410
+ }
411
+ resizeZoomRafRef.current = requestAnimationFrame(() => {
412
+ resizeZoomRafRef.current = null;
413
+ applyViewportResizeZoom(viewport.width, viewport.height);
414
+ });
415
+
416
+ return () => {
417
+ if (resizeZoomRafRef.current != null) {
418
+ cancelAnimationFrame(resizeZoomRafRef.current);
419
+ }
420
+ };
421
+ }, [
422
+ applyViewportResizeZoom,
423
+ contentSize,
424
+ viewport.height,
425
+ viewport.width,
426
+ ]);
427
+
428
+ useEffect(() => {
429
+ const el = viewportRef.current;
430
+ if (!el) return;
431
+ const onContextMenu = (e: MouseEvent) => {
432
+ // 只有发生了右键平移时,才拦截本次菜单;其余交给 shadcn ContextMenu
433
+ if (panRef.current.moved) {
434
+ e.preventDefault();
435
+ e.stopPropagation();
436
+ (e as any).stopImmediatePropagation?.();
437
+ panRef.current.moved = false;
438
+ }
439
+ };
440
+ const onMouseDown = (e: MouseEvent) => {
441
+ // 右键 或 mac 上 ctrl+左键
442
+ const isRightLike = e.button === 2 || (e.button === 0 && e.ctrlKey);
443
+ if (!isRightLike) return;
444
+ const viewer = viewerRef.current as any;
445
+ if (!viewer) return;
446
+
447
+ panRef.current.active = true;
448
+ panRef.current.moved = false;
449
+ panRef.current.startClientX = e.clientX;
450
+ panRef.current.startClientY = e.clientY;
451
+ panRef.current.startLeft = lastScrollRef.current.left;
452
+ panRef.current.startTop = lastScrollRef.current.top;
453
+
454
+ const move = (ev: MouseEvent) => {
455
+ if (!panRef.current.active) return;
456
+ const dx = ev.clientX - panRef.current.startClientX;
457
+ const dy = ev.clientY - panRef.current.startClientY;
458
+ const distance = Math.hypot(dx, dy);
459
+ if (!panRef.current.moved && distance > 10) {
460
+ panRef.current.moved = true;
461
+ setIsPanning(true);
462
+ }
463
+ if (!panRef.current.moved) return;
464
+ ev.preventDefault();
465
+ const nextLeft = panRef.current.startLeft - dx;
466
+ const nextTop = panRef.current.startTop - dy;
467
+ viewer.scrollTo(nextLeft, nextTop);
468
+ const left =
469
+ typeof viewer.getScrollLeft === "function"
470
+ ? viewer.getScrollLeft()
471
+ : nextLeft;
472
+ const top =
473
+ typeof viewer.getScrollTop === "function"
474
+ ? viewer.getScrollTop()
475
+ : nextTop;
476
+ commitScroll(left, top);
477
+ };
478
+
479
+ const up = (ev: MouseEvent) => {
480
+ if (!panRef.current.active) return;
481
+ if (panRef.current.moved) {
482
+ ev.preventDefault();
483
+ }
484
+ panRef.current.active = false;
485
+ setIsPanning(false);
486
+ clearWindowPanListeners();
487
+ };
488
+
489
+ panHandlersRef.current.move = move;
490
+ panHandlersRef.current.up = up;
491
+ window.addEventListener("mousemove", move, true);
492
+ window.addEventListener("mouseup", up, true);
493
+ };
494
+
495
+ el.addEventListener("contextmenu", onContextMenu, true);
496
+ el.addEventListener("mousedown", onMouseDown, true);
497
+ return () => {
498
+ el.removeEventListener("contextmenu", onContextMenu, true);
499
+ el.removeEventListener("mousedown", onMouseDown, true);
500
+ };
501
+ }, [clearWindowPanListeners, commitScroll]);
502
+
503
+ const resolvedContentSize = useMemo(() => {
504
+ if (contentSize) return contentSize;
505
+ if (worldCanvasSize) return worldCanvasSize;
506
+ return {
507
+ width: Math.max(0, viewport.width),
508
+ height: Math.max(0, viewport.height),
509
+ };
510
+ }, [contentSize, viewport.height, viewport.width, worldCanvasSize]);
511
+
512
+ const canvasScale = useMemo(() => uniformViewportZoom(zoom), [zoom.x, zoom.y]);
513
+
514
+ const style = useMemo<React.CSSProperties>(
515
+ () => ({
516
+ width: resolvedContentSize.width,
517
+ height: resolvedContentSize.height,
518
+ minWidth: "100%",
519
+ minHeight: "100%",
520
+ position: "relative",
521
+ transform: `scale(${canvasScale})`,
522
+ transformOrigin: "0 0",
523
+ }),
524
+ [canvasScale, resolvedContentSize.height, resolvedContentSize.width]
525
+ );
526
+
527
+ const hasMaterialPayload = useCallback((types: ArrayLike<string> | null | undefined) => {
528
+ if (!types) return false;
529
+ return Array.from(types).includes("application/x-arronqzy-material");
530
+ }, []);
531
+
532
+ const commitDropFromPoint = useCallback(
533
+ (clientX: number, clientY: number, dataTransfer: DataTransfer | null) => {
534
+ const raw = dataTransfer?.getData("application/x-arronqzy-material");
535
+ if (!raw) return;
536
+ try {
537
+ const material = JSON.parse(raw) as { id?: string };
538
+ if (!material?.id) return;
539
+ const viewportEl = viewportRef.current;
540
+ if (!viewportEl) return;
541
+ const viewportRect = viewportEl.getBoundingClientRect();
542
+ const localX = clientX - viewportRect.left;
543
+ const localY = clientY - viewportRect.top;
544
+ const isInsideViewport =
545
+ localX >= 0 &&
546
+ localY >= 0 &&
547
+ localX <= viewportRect.width &&
548
+ localY <= viewportRect.height;
549
+ if (!isInsideViewport) return;
550
+ const canvasEl = canvasElRef.current;
551
+ if (!canvasEl) return;
552
+ const canvasRect = canvasEl.getBoundingClientRect();
553
+ const x = (clientX - canvasRect.left) / Math.max(0.0001, zoomRef.current.x);
554
+ const y = (clientY - canvasRect.top) / Math.max(0.0001, zoomRef.current.y);
555
+ onDropMaterial?.({ materialId: material.id, x, y });
556
+ } catch {
557
+ // ignore invalid payload
558
+ }
559
+ },
560
+ [onDropMaterial]
561
+ );
562
+
563
+ useEffect(() => {
564
+ const el = viewportRef.current;
565
+ if (!el) return;
566
+
567
+ const onDragOver = (e: DragEvent) => {
568
+ if (!hasMaterialPayload(e.dataTransfer?.types)) return;
569
+ e.preventDefault();
570
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
571
+ };
572
+
573
+ const onDrop = (e: DragEvent) => {
574
+ if (!hasMaterialPayload(e.dataTransfer?.types)) return;
575
+ e.preventDefault();
576
+ commitDropFromPoint(e.clientX, e.clientY, e.dataTransfer ?? null);
577
+ };
578
+
579
+ el.addEventListener("dragover", onDragOver, true);
580
+ el.addEventListener("drop", onDrop, true);
581
+ return () => {
582
+ el.removeEventListener("dragover", onDragOver, true);
583
+ el.removeEventListener("drop", onDrop, true);
584
+ };
585
+ }, [commitDropFromPoint, hasMaterialPayload]);
586
+
587
+ return (
588
+ <InfiniteViewer
589
+ ref={viewerRef}
590
+ className={[
591
+ // 禁用原生滚动条外观(InfiniteViewer 自己的滚动条也会被下面的 props 关闭)
592
+ "relative h-full w-full overflow-hidden bg-background [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
593
+ isPanning ? "cursor-grabbing select-none" : "",
594
+ className ?? "",
595
+ ].join(" ")}
596
+ style={{
597
+ backgroundImage:
598
+ "linear-gradient(to right, hsl(var(--border) / 0.45) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border) / 0.45) 1px, transparent 1px)",
599
+ backgroundSize: "20px 20px",
600
+ }}
601
+ margin={0}
602
+ threshold={0}
603
+ // 只允许右键平移:禁用 InfiniteViewer 的默认鼠标拖拽(通常是左键)
604
+ useMouseDrag={false}
605
+ // 不显示滚动条 & 禁用滚轮滚动(类型定义未暴露这些 props,按 any 透传)
606
+ {...({
607
+ displayVerticalScroll: false,
608
+ displayHorizontalScroll: false,
609
+ useWheelScroll: false,
610
+ } as any)}
611
+ preventWheelClick
612
+ onScroll={(e: any) => {
613
+ const next = { left: e.scrollLeft ?? 0, top: e.scrollTop ?? 0 };
614
+ lastScrollRef.current = next;
615
+ onScrollChange?.(next);
616
+ }}
617
+ >
618
+ <TransformWrapper
619
+ ref={transformRef}
620
+ initialScale={1}
621
+ minScale={1}
622
+ maxScale={1}
623
+ centerOnInit={false}
624
+ limitToBounds={false}
625
+ panning={{ disabled: true }}
626
+ doubleClick={{ disabled: true }}
627
+ pinch={{ disabled: true }}
628
+ wheel={{ step: 0.008, disabled: true, wheelDisabled: true }}
629
+ centerZoomedOut={false}
630
+ >
631
+ <TransformComponent
632
+ // 始终占满操作容器,但不在这一层裁切放大后的内容
633
+ wrapperStyle={{ width: "100%", height: "100%", overflow: "visible" }}
634
+ contentStyle={{ width: "100%", height: "100%", overflow: "visible" }}
635
+ >
636
+ <div
637
+ ref={syncCanvasElement}
638
+ data-panel-canvas
639
+ style={style}
640
+ className=""
641
+ onMouseDownCapture={onCanvasMouseDownCapture}
642
+ onClickCapture={onCanvasClickCapture}
643
+ onContextMenuCapture={onCanvasContextMenuCapture}
644
+ >
645
+ {children}
646
+ {viewportOverlay ? (
647
+ <div className="rv-viewport-overlay pointer-events-none absolute inset-0 z-[70] overflow-visible [&_.moveable-control-box]:pointer-events-auto">
648
+ {viewportOverlay}
649
+ </div>
650
+ ) : null}
651
+ </div>
652
+ </TransformComponent>
653
+ </TransformWrapper>
654
+ </InfiniteViewer>
655
+ );
656
+ }
657
+ );
658
+
659
+ PanelCanvas.displayName = "PanelCanvas";
660
+