@mhamz.01/easyflow-whiteboard 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/node/custom-node-overlay-layer.d.ts.map +1 -1
- package/dist/components/node/custom-node-overlay-layer.js +258 -197
- package/dist/hooks/usePan.d.ts.map +1 -1
- package/dist/hooks/usePan.js +59 -27
- package/dist/hooks/useZoom.d.ts +1 -1
- package/dist/hooks/useZoom.d.ts.map +1 -1
- package/dist/hooks/useZoom.js +81 -62
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAS9C,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC;IACxC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,UAAU,uBAAuB;IAC/B,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACxC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,YAAY,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACzE,qBAAqB,CAAC,EAAE,YAAY,EAAE,CAAC;IACvC,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAaD,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,EACzC,KAAK,EACL,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,UAAc,EACd,cAA+B,EAC/B,YAAmB,EACnB,qBAA0B,EAC1B,YAAY,GACb,EAAE,uBAAuB,2CAsgBzB"}
|
|
@@ -1,93 +1,108 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef
|
|
3
|
+
import { useState, useEffect, useRef } from "react";
|
|
4
4
|
import TaskNode from "./custom-node";
|
|
5
5
|
import DocumentNode from "./document-node";
|
|
6
|
-
// ── PERF: Memoized node wrappers ──────────────────────────────────────────────
|
|
7
|
-
// Prevents sibling nodes from re-rendering when only one node's position changes.
|
|
8
|
-
// Without memo, every setLocalTasks call during drag re-renders ALL nodes.
|
|
9
|
-
const MemoTaskNode = memo(TaskNode);
|
|
10
|
-
const MemoDocumentNode = memo(DocumentNode);
|
|
11
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
12
7
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
13
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
14
9
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
15
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
16
|
-
|
|
11
|
+
const [dragging, setDragging] = useState(null);
|
|
17
12
|
const dragStateRef = useRef({
|
|
18
|
-
isDragging: false,
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
isDragging: false,
|
|
14
|
+
itemIds: [],
|
|
15
|
+
startPositions: new Map(),
|
|
16
|
+
canvasObjectsStartPos: new Map(),
|
|
17
|
+
offsetX: 0,
|
|
18
|
+
offsetY: 0,
|
|
21
19
|
});
|
|
22
20
|
const rafIdRef = useRef(null);
|
|
23
21
|
const overlayRef = useRef(null);
|
|
24
|
-
// ──
|
|
25
|
-
const isHtmlDraggingRef = useRef(false);
|
|
26
|
-
// ── PERF: Always-fresh refs — rebuilt synchronously every render ──────────
|
|
27
|
-
// Eliminates ALL stale closure problems without any useCallback deps.
|
|
28
|
-
// nodePositionsRef: ground truth for positions at drag start
|
|
29
|
-
const nodePositionsRef = useRef(new Map());
|
|
30
|
-
nodePositionsRef.current = new Map([
|
|
31
|
-
...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
|
|
32
|
-
...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
|
|
33
|
-
]);
|
|
34
|
-
// selectedIdsRef: used in stable effect closures
|
|
35
|
-
const selectedIdsRef = useRef(selectedIds);
|
|
36
|
-
selectedIdsRef.current = selectedIds;
|
|
37
|
-
// selectedCanvasObjectsRef: avoids stale prop in handleDragStart
|
|
38
|
-
const selectedCanvasObjectsRef = useRef(selectedCanvasObjects);
|
|
39
|
-
selectedCanvasObjectsRef.current = selectedCanvasObjects;
|
|
40
|
-
// Parent callbacks in refs — handleEnd never captures stale callbacks
|
|
41
|
-
const onTasksUpdateRef = useRef(onTasksUpdate);
|
|
42
|
-
const onDocumentsUpdateRef = useRef(onDocumentsUpdate);
|
|
43
|
-
onTasksUpdateRef.current = onTasksUpdate;
|
|
44
|
-
onDocumentsUpdateRef.current = onDocumentsUpdate;
|
|
45
|
-
// ─── Sync props → local state ─────────────────────────────────────────────
|
|
22
|
+
// ── Sync props → local state ────────────────────────────────────────────────
|
|
46
23
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
47
24
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
48
|
-
//
|
|
25
|
+
// ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
|
|
26
|
+
const handleOverlayWheel = (e) => {
|
|
27
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
28
|
+
const canvas = fabricCanvas?.current;
|
|
29
|
+
if (!canvas)
|
|
30
|
+
return;
|
|
31
|
+
const nativeEvent = e.nativeEvent;
|
|
32
|
+
// getScenePoint handles the transformation from screen to canvas space
|
|
33
|
+
const scenePoint = canvas.getScenePoint(nativeEvent);
|
|
34
|
+
// Viewport point is simply the mouse position relative to the canvas element
|
|
35
|
+
const rect = canvas.getElement().getBoundingClientRect();
|
|
36
|
+
const viewportPoint = {
|
|
37
|
+
x: nativeEvent.clientX - rect.left,
|
|
38
|
+
y: nativeEvent.clientY - rect.top,
|
|
39
|
+
};
|
|
40
|
+
// We cast to 'any' here because we are manually triggering an internal
|
|
41
|
+
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
42
|
+
canvas.fire("mouse:wheel", {
|
|
43
|
+
e: nativeEvent,
|
|
44
|
+
scenePoint,
|
|
45
|
+
viewportPoint,
|
|
46
|
+
});
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
e.stopPropagation();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
49
51
|
useEffect(() => {
|
|
50
52
|
const overlayEl = overlayRef.current;
|
|
51
53
|
const canvas = fabricCanvas?.current;
|
|
52
54
|
if (!overlayEl || !canvas)
|
|
53
55
|
return;
|
|
54
56
|
const handleGlobalWheel = (e) => {
|
|
55
|
-
|
|
57
|
+
// Check if the user is hovering over an element that has pointer-events: auto
|
|
58
|
+
// (meaning they are hovering over a Task or Document)
|
|
59
|
+
const target = e.target;
|
|
60
|
+
const isOverNode = target !== overlayEl;
|
|
56
61
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
62
|
+
// 1. Prevent Browser Zoom immediately
|
|
57
63
|
e.preventDefault();
|
|
58
64
|
e.stopPropagation();
|
|
65
|
+
// 2. Calculate coordinates for Fabric
|
|
59
66
|
const scenePoint = canvas.getScenePoint(e);
|
|
60
67
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
68
|
+
const viewportPoint = {
|
|
69
|
+
x: e.clientX - rect.left,
|
|
70
|
+
y: e.clientY - rect.top,
|
|
71
|
+
};
|
|
72
|
+
// 3. Manually fire the event into Fabric
|
|
61
73
|
canvas.fire("mouse:wheel", {
|
|
62
|
-
e
|
|
63
|
-
|
|
74
|
+
e: e,
|
|
75
|
+
scenePoint,
|
|
76
|
+
viewportPoint,
|
|
64
77
|
});
|
|
65
78
|
}
|
|
66
79
|
};
|
|
80
|
+
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
67
81
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
68
|
-
return () =>
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
return () => {
|
|
83
|
+
overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
84
|
+
};
|
|
85
|
+
}, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
|
|
86
|
+
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
71
87
|
useEffect(() => {
|
|
72
88
|
const canvas = fabricCanvas?.current;
|
|
73
89
|
if (!canvas)
|
|
74
90
|
return;
|
|
75
91
|
const handleObjectMoving = (e) => {
|
|
76
|
-
// MUTEX: HTML drag and Fabric drag must never write positions simultaneously
|
|
77
|
-
if (isHtmlDraggingRef.current)
|
|
78
|
-
return;
|
|
79
92
|
const target = e.transform?.target || e.target;
|
|
80
93
|
if (!target)
|
|
81
94
|
return;
|
|
95
|
+
// 1. Calculate delta in raw Scene Coordinates
|
|
96
|
+
// We do NOT divide by zoom here because target.left/top are world units.
|
|
82
97
|
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
83
98
|
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
84
99
|
target._prevLeft = target.left;
|
|
85
100
|
target._prevTop = target.top;
|
|
86
101
|
if (deltaX === 0 && deltaY === 0)
|
|
87
102
|
return;
|
|
88
|
-
|
|
89
|
-
setLocalTasks((prev) => prev.map((t) =>
|
|
90
|
-
setLocalDocuments((prev) => prev.map((d) =>
|
|
103
|
+
// 2. Apply the raw delta to HTML items
|
|
104
|
+
setLocalTasks((prev) => prev.map((t) => (selectedIds.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t)));
|
|
105
|
+
setLocalDocuments((prev) => prev.map((d) => (selectedIds.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d)));
|
|
91
106
|
};
|
|
92
107
|
const handleMouseDown = (e) => {
|
|
93
108
|
const target = e.target;
|
|
@@ -102,68 +117,138 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
102
117
|
canvas.off("object:moving", handleObjectMoving);
|
|
103
118
|
canvas.off("mouse:down", handleMouseDown);
|
|
104
119
|
};
|
|
105
|
-
}, [fabricCanvas]);
|
|
106
|
-
//
|
|
120
|
+
}, [canvasZoom, selectedIds, fabricCanvas]);
|
|
121
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
122
|
+
const getItemPosition = (id) => {
|
|
123
|
+
const task = localTasks.find((t) => t.id === id);
|
|
124
|
+
if (task)
|
|
125
|
+
return { x: task.x, y: task.y };
|
|
126
|
+
const doc = localDocuments.find((d) => d.id === id);
|
|
127
|
+
if (doc)
|
|
128
|
+
return { x: doc.x, y: doc.y };
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
132
|
+
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
133
|
+
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
134
|
+
const itemX2 = itemX1 + width * canvasZoom;
|
|
135
|
+
const itemY2 = itemY1 + height * canvasZoom;
|
|
136
|
+
const boxX1 = Math.min(box.x1, box.x2);
|
|
137
|
+
const boxY1 = Math.min(box.y1, box.y2);
|
|
138
|
+
const boxX2 = Math.max(box.x1, box.x2);
|
|
139
|
+
const boxY2 = Math.max(box.y1, box.y2);
|
|
140
|
+
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
141
|
+
};
|
|
142
|
+
// ── Selection box detection ──────────────────────────────────────────────────
|
|
107
143
|
useEffect(() => {
|
|
108
144
|
if (!selectionBox)
|
|
109
145
|
return;
|
|
146
|
+
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
110
147
|
const newSelected = new Set();
|
|
111
148
|
for (const task of localTasks) {
|
|
112
|
-
|
|
113
|
-
const y1 = task.y * canvasZoom + canvasViewport.y;
|
|
114
|
-
const x2 = x1 + 300 * canvasZoom;
|
|
115
|
-
const y2 = y1 + 140 * canvasZoom;
|
|
116
|
-
const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
|
|
117
|
-
const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
|
|
118
|
-
const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
|
|
119
|
-
const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
|
|
120
|
-
if (!(bX2 < x1 || bX1 > x2 || bY2 < y1 || bY1 > y2))
|
|
149
|
+
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
121
150
|
newSelected.add(task.id);
|
|
122
151
|
}
|
|
123
152
|
for (const doc of localDocuments) {
|
|
124
|
-
|
|
125
|
-
const y1 = doc.y * canvasZoom + canvasViewport.y;
|
|
126
|
-
const x2 = x1 + 320 * canvasZoom;
|
|
127
|
-
const y2 = y1 + 160 * canvasZoom;
|
|
128
|
-
const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
|
|
129
|
-
const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
|
|
130
|
-
const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
|
|
131
|
-
const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
|
|
132
|
-
if (!(bX2 < x1 || bX1 > x2 || bY2 < y1 || bY1 > y2))
|
|
153
|
+
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
133
154
|
newSelected.add(doc.id);
|
|
134
155
|
}
|
|
135
|
-
// O(n) equality
|
|
156
|
+
// ── O(n) equality check: size first (fast path), then membership ──
|
|
136
157
|
setSelectedIds((prev) => {
|
|
137
158
|
if (prev.size !== newSelected.size)
|
|
138
159
|
return newSelected;
|
|
139
|
-
for (const id of newSelected)
|
|
160
|
+
for (const id of newSelected) {
|
|
140
161
|
if (!prev.has(id))
|
|
141
|
-
return newSelected;
|
|
142
|
-
|
|
162
|
+
return newSelected; // found a difference, swap
|
|
163
|
+
}
|
|
164
|
+
return prev; // identical — return same reference, no re-render
|
|
165
|
+
});
|
|
166
|
+
}, [selectionBox, localTasks, localDocuments]);
|
|
167
|
+
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
168
|
+
// Helper to extract coordinates regardless of event type
|
|
169
|
+
const getPointerEvent = (e) => {
|
|
170
|
+
if ('touches' in e && e.touches.length > 0)
|
|
171
|
+
return e.touches[0];
|
|
172
|
+
return e;
|
|
173
|
+
};
|
|
174
|
+
const handleDragStart = (itemId, e) => {
|
|
175
|
+
// 1. Safety check for the Fabric instance
|
|
176
|
+
const canvas = fabricCanvas?.current;
|
|
177
|
+
if (!canvas)
|
|
178
|
+
return;
|
|
179
|
+
// 2. Normalize the event (Touch vs Mouse)
|
|
180
|
+
if (e.cancelable)
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
const pointer = getPointerEvent(e);
|
|
183
|
+
// 3. Determine which items are being dragged
|
|
184
|
+
// selection update DOES NOT trigger before drag snapshot
|
|
185
|
+
let itemsToDrag;
|
|
186
|
+
if (selectedIds.has(itemId)) {
|
|
187
|
+
itemsToDrag = Array.from(selectedIds);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
itemsToDrag = [itemId];
|
|
191
|
+
}
|
|
192
|
+
// 4. Capture current World Transform (Zoom & Pan)
|
|
193
|
+
// We read directly from the canvas to ensure zero-frame lag
|
|
194
|
+
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
195
|
+
const liveZoom = vpt[0];
|
|
196
|
+
const liveVpX = vpt[4];
|
|
197
|
+
const liveVpY = vpt[5];
|
|
198
|
+
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
199
|
+
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
200
|
+
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
201
|
+
// 6. Get the clicked item's current World Position
|
|
202
|
+
const clickedPos = getItemPosition(itemId);
|
|
203
|
+
if (!clickedPos)
|
|
204
|
+
return;
|
|
205
|
+
// 7. Calculate the Offset in WORLD UNITS
|
|
206
|
+
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
207
|
+
// This value remains constant even if you zoom during the drag.
|
|
208
|
+
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
209
|
+
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
210
|
+
// 8. Snapshot starting positions for all selected HTML nodes
|
|
211
|
+
const startPositions = new Map();
|
|
212
|
+
itemsToDrag.forEach((id) => {
|
|
213
|
+
const pos = getItemPosition(id);
|
|
214
|
+
if (pos)
|
|
215
|
+
startPositions.set(id, pos);
|
|
216
|
+
});
|
|
217
|
+
// 9. Snapshot starting positions for all selected Fabric objects
|
|
218
|
+
const canvasObjectsStartPos = new Map();
|
|
219
|
+
selectedCanvasObjects.forEach((obj) => {
|
|
220
|
+
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
143
221
|
});
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
223
|
+
dragStateRef.current = {
|
|
224
|
+
isDragging: true,
|
|
225
|
+
itemIds: itemsToDrag,
|
|
226
|
+
startPositions,
|
|
227
|
+
canvasObjectsStartPos,
|
|
228
|
+
offsetX: worldOffsetX, // Now stored as World Units
|
|
229
|
+
offsetY: worldOffsetY, // Now stored as World Units
|
|
230
|
+
};
|
|
231
|
+
if (!selectedIds.has(itemId)) {
|
|
232
|
+
setSelectedIds(new Set([itemId]));
|
|
233
|
+
}
|
|
234
|
+
// 11. Trigger UI states
|
|
235
|
+
setDragging({ itemIds: itemsToDrag });
|
|
236
|
+
document.body.style.cursor = "grabbing";
|
|
237
|
+
document.body.style.userSelect = "none";
|
|
238
|
+
document.body.style.touchAction = "none";
|
|
239
|
+
};
|
|
240
|
+
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
159
241
|
useEffect(() => {
|
|
242
|
+
if (!dragging)
|
|
243
|
+
return;
|
|
244
|
+
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
160
245
|
const handleMove = (e) => {
|
|
161
246
|
if (!dragStateRef.current.isDragging)
|
|
162
247
|
return;
|
|
163
248
|
if (e.cancelable)
|
|
164
249
|
e.preventDefault();
|
|
165
|
-
const pointer =
|
|
166
|
-
|
|
250
|
+
const pointer = getPointerEvent(e);
|
|
251
|
+
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
167
252
|
if (rafIdRef.current !== null)
|
|
168
253
|
cancelAnimationFrame(rafIdRef.current);
|
|
169
254
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -171,50 +256,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
171
256
|
const canvas = fabricCanvas?.current;
|
|
172
257
|
if (!canvas)
|
|
173
258
|
return;
|
|
174
|
-
//
|
|
259
|
+
// 2. Read the "Source of Truth" transform from the canvas
|
|
175
260
|
const vpt = canvas.viewportTransform;
|
|
176
|
-
const liveZoom = vpt[0];
|
|
177
|
-
const liveVpX = vpt[4];
|
|
178
|
-
const liveVpY = vpt[5];
|
|
179
|
-
// Screen → World
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
261
|
+
const liveZoom = vpt[0]; // Scale
|
|
262
|
+
const liveVpX = vpt[4]; // Pan X
|
|
263
|
+
const liveVpY = vpt[5]; // Pan Y
|
|
264
|
+
// 3. Convert current Mouse Screen Position → World Position
|
|
265
|
+
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
266
|
+
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
267
|
+
// 4. Calculate where the "Anchor" node should be in World Units
|
|
268
|
+
// (Current Mouse World - Initial World Offset from Start)
|
|
269
|
+
const newWorldX = currentWorldX - offsetX;
|
|
270
|
+
const newWorldY = currentWorldY - offsetY;
|
|
271
|
+
// 5. Calculate the Movement Delta in World Units
|
|
272
|
+
// We compare where the first item started vs where it is now.
|
|
273
|
+
const firstId = itemIds[0];
|
|
274
|
+
const firstStart = startPositions.get(firstId);
|
|
183
275
|
if (!firstStart)
|
|
184
276
|
return;
|
|
185
277
|
const deltaX = newWorldX - firstStart.x;
|
|
186
278
|
const deltaY = newWorldY - firstStart.y;
|
|
187
|
-
//
|
|
188
|
-
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
: t))
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
: d))
|
|
196
|
-
|
|
279
|
+
// 6. Update HTML Nodes (Batching these into one state update)
|
|
280
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
|
|
281
|
+
...t,
|
|
282
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
283
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
284
|
+
} : t));
|
|
285
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
|
|
286
|
+
...d,
|
|
287
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
288
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
289
|
+
} : d));
|
|
290
|
+
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
197
291
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
198
|
-
obj.set({
|
|
199
|
-
|
|
292
|
+
obj.set({
|
|
293
|
+
left: startPos.left + deltaX,
|
|
294
|
+
top: startPos.top + deltaY,
|
|
295
|
+
});
|
|
296
|
+
obj.setCoords(); // Required for selection/intersection accuracy
|
|
200
297
|
});
|
|
298
|
+
// 8. Single render call for all Fabric changes
|
|
201
299
|
canvas.requestRenderAll();
|
|
202
300
|
});
|
|
203
301
|
};
|
|
204
302
|
const handleEnd = () => {
|
|
205
|
-
if (
|
|
206
|
-
return;
|
|
207
|
-
if (rafIdRef.current !== null) {
|
|
303
|
+
if (rafIdRef.current !== null)
|
|
208
304
|
cancelAnimationFrame(rafIdRef.current);
|
|
209
|
-
rafIdRef.current = null;
|
|
210
|
-
}
|
|
211
305
|
dragStateRef.current.isDragging = false;
|
|
212
|
-
|
|
306
|
+
setDragging(null);
|
|
213
307
|
document.body.style.cursor = "";
|
|
214
308
|
document.body.style.userSelect = "";
|
|
215
309
|
document.body.style.touchAction = "";
|
|
216
|
-
|
|
217
|
-
|
|
310
|
+
onTasksUpdate?.(localTasks);
|
|
311
|
+
onDocumentsUpdate?.(localDocuments);
|
|
218
312
|
};
|
|
219
313
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
220
314
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -228,62 +322,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
228
322
|
window.removeEventListener("touchend", handleEnd);
|
|
229
323
|
window.removeEventListener("touchcancel", handleEnd);
|
|
230
324
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const handleDragStart = useCallback((itemId, e) => {
|
|
235
|
-
const canvas = fabricCanvas?.current;
|
|
236
|
-
if (!canvas)
|
|
237
|
-
return;
|
|
238
|
-
if (e.cancelable)
|
|
239
|
-
e.preventDefault();
|
|
240
|
-
const pointer = "touches" in e && e.touches.length > 0
|
|
241
|
-
? e.touches[0] : e;
|
|
242
|
-
// Read from ref — not state (avoids async lag from setSelectedIds)
|
|
243
|
-
const currentSelected = selectedIdsRef.current;
|
|
244
|
-
const itemsToDrag = currentSelected.has(itemId)
|
|
245
|
-
? Array.from(currentSelected) : [itemId];
|
|
246
|
-
if (!currentSelected.has(itemId))
|
|
247
|
-
setSelectedIds(new Set([itemId]));
|
|
248
|
-
// Live VPT
|
|
249
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
250
|
-
const liveZoom = vpt[0];
|
|
251
|
-
const liveVpX = vpt[4];
|
|
252
|
-
const liveVpY = vpt[5];
|
|
253
|
-
// Pointer → world
|
|
254
|
-
const pointerWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
255
|
-
const pointerWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
256
|
-
// nodePositionsRef is rebuilt synchronously every render — never stale
|
|
257
|
-
const clickedPos = nodePositionsRef.current.get(itemId);
|
|
258
|
-
if (!clickedPos)
|
|
259
|
-
return;
|
|
260
|
-
// Snapshot ALL start positions from ref synchronously
|
|
261
|
-
const startPositions = new Map();
|
|
262
|
-
for (const id of itemsToDrag) {
|
|
263
|
-
const pos = nodePositionsRef.current.get(id);
|
|
264
|
-
if (pos)
|
|
265
|
-
startPositions.set(id, { x: pos.x, y: pos.y });
|
|
266
|
-
}
|
|
267
|
-
const canvasObjectsStartPos = new Map();
|
|
268
|
-
for (const obj of selectedCanvasObjectsRef.current) {
|
|
269
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
270
|
-
}
|
|
271
|
-
dragStateRef.current = {
|
|
272
|
-
isDragging: true,
|
|
273
|
-
itemIds: itemsToDrag,
|
|
274
|
-
startPositions,
|
|
275
|
-
canvasObjectsStartPos,
|
|
276
|
-
// World-space offset: pointer distance from node top-left in world units
|
|
277
|
-
offsetX: pointerWorldX - clickedPos.x,
|
|
278
|
-
offsetY: pointerWorldY - clickedPos.y,
|
|
279
|
-
};
|
|
280
|
-
isHtmlDraggingRef.current = true;
|
|
281
|
-
document.body.style.cursor = "grabbing";
|
|
282
|
-
document.body.style.userSelect = "none";
|
|
283
|
-
document.body.style.touchAction = "none";
|
|
284
|
-
}, [fabricCanvas]);
|
|
285
|
-
// ─── Node interaction ─────────────────────────────────────────────────────
|
|
286
|
-
const handleSelect = useCallback((id, e) => {
|
|
325
|
+
}, [dragging, localTasks, localDocuments, fabricCanvas]);
|
|
326
|
+
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
327
|
+
const handleSelect = (id, e) => {
|
|
287
328
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
288
329
|
setSelectedIds((prev) => {
|
|
289
330
|
const next = new Set(prev);
|
|
@@ -294,48 +335,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
294
335
|
else {
|
|
295
336
|
setSelectedIds(new Set([id]));
|
|
296
337
|
}
|
|
297
|
-
}
|
|
298
|
-
const handleStatusChange =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
});
|
|
304
|
-
}, []);
|
|
305
|
-
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
|
|
338
|
+
};
|
|
339
|
+
const handleStatusChange = (taskId, newStatus) => {
|
|
340
|
+
const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
|
|
341
|
+
setLocalTasks(updated);
|
|
342
|
+
onTasksUpdate?.(updated);
|
|
343
|
+
};
|
|
306
344
|
useEffect(() => {
|
|
307
345
|
const handleKeyDown = (e) => {
|
|
346
|
+
// Don't trigger if typing in input
|
|
308
347
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
309
348
|
return;
|
|
349
|
+
// Select All
|
|
310
350
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
311
351
|
e.preventDefault();
|
|
312
352
|
setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
|
|
313
353
|
}
|
|
314
|
-
|
|
354
|
+
// Clear selection
|
|
355
|
+
if (e.key === "Escape") {
|
|
315
356
|
setSelectedIds(new Set());
|
|
316
|
-
|
|
357
|
+
}
|
|
358
|
+
// ← ADD THIS: Delete selected nodes
|
|
359
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
317
360
|
e.preventDefault();
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
361
|
+
const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
|
|
362
|
+
const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
|
|
363
|
+
setLocalTasks(updatedTasks);
|
|
364
|
+
setLocalDocuments(updatedDocs);
|
|
321
365
|
setSelectedIds(new Set());
|
|
366
|
+
onTasksUpdate?.(updatedTasks);
|
|
367
|
+
onDocumentsUpdate?.(updatedDocs);
|
|
322
368
|
}
|
|
323
369
|
};
|
|
324
370
|
window.addEventListener("keydown", handleKeyDown);
|
|
325
371
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
326
|
-
}, [localTasks, localDocuments]);
|
|
327
|
-
//
|
|
328
|
-
const renderItem = (id, x, y, children) =>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
372
|
+
}, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
|
|
373
|
+
// ── Render helper ────────────────────────────────────────────────────────────
|
|
374
|
+
const renderItem = (id, x, y, children) => {
|
|
375
|
+
const screenX = x * canvasZoom;
|
|
376
|
+
const screenY = y * canvasZoom;
|
|
377
|
+
// 1. Detect if the user is interacting with the canvas at all
|
|
378
|
+
// 'dragging' is your existing state.
|
|
379
|
+
// You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
|
|
380
|
+
const isDragging = dragging?.itemIds.includes(id);
|
|
381
|
+
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
382
|
+
left: 0,
|
|
383
|
+
top: 0,
|
|
384
|
+
// 2. Use translate3d for GPU performance
|
|
385
|
+
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
386
|
+
transformOrigin: "top left",
|
|
387
|
+
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
388
|
+
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
389
|
+
transition: "none",
|
|
390
|
+
// 4. Optimization
|
|
391
|
+
willChange: "transform",
|
|
392
|
+
zIndex: isDragging ? 1000 : 1,
|
|
393
|
+
}, children: children }, id));
|
|
394
|
+
};
|
|
395
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
396
|
+
if (e.target === e.currentTarget)
|
|
397
|
+
setSelectedIds(new Set());
|
|
398
|
+
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|
|
338
399
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
339
400
|
transformOrigin: "top left",
|
|
340
|
-
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(
|
|
401
|
+
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(TaskNode, { ...task, isSelected: selectedIds.has(task.id), onSelect: handleSelect, onDragStart: handleDragStart, onStatusChange: handleStatusChange, zoom: 1 }))), localDocuments.map((doc) => renderItem(doc.id, doc.x, doc.y, _jsx(DocumentNode, { ...doc, isSelected: selectedIds.has(doc.id), onSelect: handleSelect, onDragStart: handleDragStart })))] }) }));
|
|
341
402
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usePan.d.ts","sourceRoot":"","sources":["../../src/hooks/usePan.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"usePan.d.ts","sourceRoot":"","sources":["../../src/hooks/usePan.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,SAAS,EAAE,MAAM,OAAO,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,UAAU,WAAW;IACnB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACrE,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,MAAM,GAAI,8DAKpB,WAAW,SAoIb,CAAC"}
|
package/dist/hooks/usePan.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport, }) => {
|
|
3
|
+
// ── PERF FIX 1: All mutable values in refs — effect registers ONCE ────────
|
|
4
|
+
// Old deps: [activeTool, handleZoom, setCanvasViewport, fabricCanvas]
|
|
5
|
+
// handleZoom and setCanvasViewport are new references every render →
|
|
6
|
+
// all 3 listeners re-registered on every render.
|
|
7
|
+
const activeToolRef = useRef(activeTool);
|
|
8
|
+
const handleZoomRef = useRef(handleZoom);
|
|
9
|
+
const setViewportRef = useRef(setCanvasViewport);
|
|
10
|
+
activeToolRef.current = activeTool;
|
|
11
|
+
handleZoomRef.current = handleZoom;
|
|
12
|
+
setViewportRef.current = setCanvasViewport;
|
|
3
13
|
useEffect(() => {
|
|
4
14
|
const canvas = fabricCanvas.current;
|
|
5
15
|
if (!canvas)
|
|
@@ -10,17 +20,18 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
|
|
|
10
20
|
let lastX = 0;
|
|
11
21
|
let lastY = 0;
|
|
12
22
|
let lastTouchDistance = 0;
|
|
23
|
+
let rafId = null;
|
|
13
24
|
const onDown = (opt) => {
|
|
14
|
-
if (
|
|
25
|
+
if (activeToolRef.current !== "pan")
|
|
15
26
|
return;
|
|
16
27
|
const e = opt.e;
|
|
17
|
-
// Pinch
|
|
18
|
-
if (e.touches
|
|
28
|
+
// Pinch zoom init
|
|
29
|
+
if (e.touches?.length === 2) {
|
|
19
30
|
isPanning = false;
|
|
20
31
|
lastTouchDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
|
21
32
|
return;
|
|
22
33
|
}
|
|
23
|
-
// Pan
|
|
34
|
+
// Pan init
|
|
24
35
|
const pointer = e.touches ? e.touches[0] : e;
|
|
25
36
|
isPanning = true;
|
|
26
37
|
lastX = pointer.clientX;
|
|
@@ -28,45 +39,63 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
|
|
|
28
39
|
canvas.setCursor("grabbing");
|
|
29
40
|
};
|
|
30
41
|
const onMove = (opt) => {
|
|
31
|
-
if (
|
|
42
|
+
if (activeToolRef.current !== "pan")
|
|
32
43
|
return;
|
|
33
44
|
const e = opt.e;
|
|
34
|
-
//
|
|
35
|
-
|
|
45
|
+
// ── PERF FIX 2: rAF throttle — pan fires at raw mouse rate (200+/sec) ─
|
|
46
|
+
// Without throttling, every mousemove directly mutates VPT and calls
|
|
47
|
+
// requestRenderAll, saturating the main thread.
|
|
48
|
+
if (rafId !== null)
|
|
49
|
+
return; // skip if a frame is already queued
|
|
50
|
+
// Pinch zoom
|
|
51
|
+
if (e.touches?.length === 2) {
|
|
36
52
|
const currentDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
|
37
53
|
if (lastTouchDistance > 0) {
|
|
38
|
-
const zoom = canvas.getZoom();
|
|
39
54
|
const delta = (currentDistance - lastTouchDistance) * 0.01;
|
|
40
|
-
const newZoom = zoom + delta;
|
|
41
55
|
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
42
56
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
43
57
|
const rect = canvasEl.getBoundingClientRect();
|
|
44
|
-
|
|
58
|
+
handleZoomRef.current(canvas.getZoom() + delta, { x: midX - rect.left, y: midY - rect.top });
|
|
45
59
|
}
|
|
46
60
|
lastTouchDistance = currentDistance;
|
|
47
61
|
return;
|
|
48
62
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
if (!isPanning)
|
|
64
|
+
return;
|
|
65
|
+
const pointer = e.touches ? e.touches[0] : e;
|
|
66
|
+
const dx = pointer.clientX - lastX;
|
|
67
|
+
const dy = pointer.clientY - lastY;
|
|
68
|
+
lastX = pointer.clientX;
|
|
69
|
+
lastY = pointer.clientY;
|
|
70
|
+
// Schedule the actual VPT mutation on next animation frame
|
|
71
|
+
rafId = requestAnimationFrame(() => {
|
|
72
|
+
rafId = null;
|
|
52
73
|
const vpt = canvas.viewportTransform;
|
|
53
|
-
if (vpt)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
if (!vpt)
|
|
75
|
+
return;
|
|
76
|
+
vpt[4] += dx;
|
|
77
|
+
vpt[5] += dy;
|
|
78
|
+
canvas.requestRenderAll();
|
|
79
|
+
// ── PERF FIX 3: Do NOT call setCanvasViewport here ────────────────
|
|
80
|
+
// Calling setCanvasViewport per frame triggers React re-renders at
|
|
81
|
+
// 60fps, cascading to CanvasOverlayLayer re-rendering all HTML nodes.
|
|
82
|
+
// Viewport state is only flushed ONCE on mouse:up (see onUp below).
|
|
83
|
+
});
|
|
61
84
|
};
|
|
62
85
|
const onUp = () => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
if (rafId !== null) {
|
|
87
|
+
cancelAnimationFrame(rafId);
|
|
88
|
+
rafId = null;
|
|
66
89
|
}
|
|
67
90
|
isPanning = false;
|
|
68
91
|
lastTouchDistance = 0;
|
|
69
|
-
canvas.setCursor(
|
|
92
|
+
canvas.setCursor(activeToolRef.current === "pan" ? "grab" : "default");
|
|
93
|
+
// ── Flush React state ONCE per gesture end ────────────────────────────
|
|
94
|
+
// HTML overlay nodes only need to reposition when the pan is complete.
|
|
95
|
+
// One setCanvasViewport call here vs hundreds during the gesture.
|
|
96
|
+
const vpt = canvas.viewportTransform;
|
|
97
|
+
if (vpt)
|
|
98
|
+
setViewportRef.current({ x: vpt[4], y: vpt[5] });
|
|
70
99
|
};
|
|
71
100
|
canvas.on("mouse:down", onDown);
|
|
72
101
|
canvas.on("mouse:move", onMove);
|
|
@@ -75,6 +104,9 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
|
|
|
75
104
|
canvas.off("mouse:down", onDown);
|
|
76
105
|
canvas.off("mouse:move", onMove);
|
|
77
106
|
canvas.off("mouse:up", onUp);
|
|
107
|
+
if (rafId !== null)
|
|
108
|
+
cancelAnimationFrame(rafId);
|
|
78
109
|
};
|
|
79
|
-
|
|
110
|
+
// ── PERF FIX 4: Empty deps — registered once, reads everything via refs ───
|
|
111
|
+
}, [fabricCanvas]);
|
|
80
112
|
};
|
package/dist/hooks/useZoom.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ interface UseZoomProps {
|
|
|
15
15
|
y: number;
|
|
16
16
|
}) => void;
|
|
17
17
|
}
|
|
18
|
-
export declare const useZoom: ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM,
|
|
18
|
+
export declare const useZoom: ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, setCanvasZoom, setCanvasViewport, }: UseZoomProps) => {
|
|
19
19
|
handleZoom: (newZoom: number, point?: {
|
|
20
20
|
x: number;
|
|
21
21
|
y: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useZoom.d.ts","sourceRoot":"","sources":["../../src/hooks/useZoom.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useZoom.d.ts","sourceRoot":"","sources":["../../src/hooks/useZoom.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,SAAS,EAAE,MAAM,OAAO,CAAC;AAClE,OAAO,EAAE,MAAM,EAAuB,MAAM,QAAQ,CAAC;AAErD,UAAU,YAAY;IACpB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,OAAO,GAAI,yEAMrB,YAAY;0BAiB4B,MAAM,UAAU;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE;CA2IlF,CAAC"}
|
package/dist/hooks/useZoom.js
CHANGED
|
@@ -1,27 +1,57 @@
|
|
|
1
|
-
import { useCallback, useEffect } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { Point } from "fabric";
|
|
3
|
-
export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM,
|
|
3
|
+
export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, setCanvasZoom, setCanvasViewport, }) => {
|
|
4
|
+
// ── PERF FIX 1: Stable refs for setters ──────────────────────────────────
|
|
5
|
+
// setCanvasZoom and setCanvasViewport are new references every render from
|
|
6
|
+
// FabricWhiteboard's useState. Putting them in deps caused handleZoom and
|
|
7
|
+
// all effects to re-register on every render. Refs break the cycle.
|
|
8
|
+
const setCanvasZoomRef = useRef(setCanvasZoom);
|
|
9
|
+
const setCanvasViewportRef = useRef(setCanvasViewport);
|
|
10
|
+
setCanvasZoomRef.current = setCanvasZoom;
|
|
11
|
+
setCanvasViewportRef.current = setCanvasViewport;
|
|
12
|
+
// ── PERF FIX 2: handleZoom reads limits from refs, stable reference ───────
|
|
13
|
+
const minZoomRef = useRef(MIN_ZOOM);
|
|
14
|
+
const maxZoomRef = useRef(MAX_ZOOM);
|
|
15
|
+
minZoomRef.current = MIN_ZOOM;
|
|
16
|
+
maxZoomRef.current = MAX_ZOOM;
|
|
4
17
|
const handleZoom = useCallback((newZoom, point) => {
|
|
5
18
|
const canvas = fabricCanvas.current;
|
|
6
19
|
if (!canvas)
|
|
7
20
|
return;
|
|
8
|
-
const
|
|
21
|
+
const clamped = Math.min(Math.max(newZoom, minZoomRef.current), maxZoomRef.current);
|
|
9
22
|
const pivot = point
|
|
10
23
|
? new Point(point.x, point.y)
|
|
11
24
|
: new Point(canvas.getWidth() / 2, canvas.getHeight() / 2);
|
|
12
|
-
canvas.zoomToPoint(pivot,
|
|
25
|
+
canvas.zoomToPoint(pivot, clamped);
|
|
26
|
+
canvas.requestRenderAll();
|
|
27
|
+
// Flush React state once per zoom action — not per frame
|
|
13
28
|
const vpt = canvas.viewportTransform;
|
|
14
29
|
if (vpt) {
|
|
15
|
-
|
|
16
|
-
|
|
30
|
+
setCanvasZoomRef.current(clamped);
|
|
31
|
+
setCanvasViewportRef.current({ x: vpt[4], y: vpt[5] });
|
|
17
32
|
}
|
|
18
|
-
|
|
19
|
-
}, [
|
|
20
|
-
// Wheel
|
|
33
|
+
// fabricCanvas ref is stable — this function never needs to be recreated
|
|
34
|
+
}, [fabricCanvas]);
|
|
35
|
+
// ── Wheel zoom + pan ───────────────────────────────────────────────────────
|
|
21
36
|
useEffect(() => {
|
|
22
37
|
const canvas = fabricCanvas.current;
|
|
23
38
|
if (!canvas)
|
|
24
39
|
return;
|
|
40
|
+
// ── PERF FIX 3: rAF-throttled viewport flush ─────────────────────────
|
|
41
|
+
// setCanvasViewport on every wheel tick caused a React re-render per scroll
|
|
42
|
+
// event (60-200/sec). Batch with rAF — flush at most once per paint frame.
|
|
43
|
+
let rafId = null;
|
|
44
|
+
const flushViewport = () => {
|
|
45
|
+
rafId = null;
|
|
46
|
+
const vpt = canvas.viewportTransform;
|
|
47
|
+
if (vpt)
|
|
48
|
+
setCanvasViewportRef.current({ x: vpt[4], y: vpt[5] });
|
|
49
|
+
};
|
|
50
|
+
const scheduleFlush = () => {
|
|
51
|
+
if (rafId !== null)
|
|
52
|
+
cancelAnimationFrame(rafId);
|
|
53
|
+
rafId = requestAnimationFrame(flushViewport);
|
|
54
|
+
};
|
|
25
55
|
const onWheel = (opt) => {
|
|
26
56
|
const e = opt.e;
|
|
27
57
|
e.preventDefault();
|
|
@@ -30,36 +60,41 @@ export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasVi
|
|
|
30
60
|
if (!vpt)
|
|
31
61
|
return;
|
|
32
62
|
if (e.ctrlKey || e.metaKey) {
|
|
33
|
-
// Zoom
|
|
63
|
+
// Zoom to cursor
|
|
34
64
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
35
|
-
|
|
65
|
+
const currentZoom = canvas.getZoom();
|
|
66
|
+
handleZoom(e.deltaY < 0 ? currentZoom * 1.1 : currentZoom / 1.1, { x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
36
67
|
}
|
|
37
68
|
else {
|
|
38
|
-
// Pan via
|
|
39
|
-
|
|
40
|
-
const shiftDelta = e.deltaX || e.deltaY;
|
|
41
|
-
vpt[5] -= delta;
|
|
69
|
+
// Pan via scroll — mutate VPT directly, no React state until rAF
|
|
70
|
+
vpt[5] -= e.deltaY;
|
|
42
71
|
if (e.shiftKey)
|
|
43
|
-
vpt[4] -=
|
|
44
|
-
|
|
45
|
-
// Sync selection coordinates
|
|
72
|
+
vpt[4] -= (e.deltaX || e.deltaY);
|
|
73
|
+
// Keep active object coords in sync
|
|
46
74
|
const activeObj = canvas.getActiveObject();
|
|
47
75
|
if (activeObj) {
|
|
48
76
|
activeObj.setCoords();
|
|
49
77
|
if (activeObj.type === "activeSelection") {
|
|
50
|
-
|
|
51
|
-
objects.forEach((obj) => obj.setCoords());
|
|
52
|
-
activeObj.setCoords();
|
|
78
|
+
activeObj.getObjects().forEach((o) => o.setCoords());
|
|
53
79
|
}
|
|
54
80
|
}
|
|
55
81
|
canvas.calcOffset();
|
|
56
82
|
canvas.requestRenderAll();
|
|
83
|
+
// Batch React state flush — at most once per animation frame
|
|
84
|
+
scheduleFlush();
|
|
57
85
|
}
|
|
58
86
|
};
|
|
59
87
|
canvas.on("mouse:wheel", onWheel);
|
|
60
|
-
return () =>
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
return () => {
|
|
89
|
+
canvas.off("mouse:wheel", onWheel);
|
|
90
|
+
if (rafId !== null)
|
|
91
|
+
cancelAnimationFrame(rafId);
|
|
92
|
+
};
|
|
93
|
+
// ── PERF FIX 4: Empty deps — reads everything via refs or canvas directly ──
|
|
94
|
+
// Old code had [canvasZoom, canvasViewport, handleZoom] here which caused
|
|
95
|
+
// the listener to re-register on every single zoom/pan action.
|
|
96
|
+
}, [fabricCanvas, handleZoom]);
|
|
97
|
+
// ── Prevent browser zoom ──────────────────────────────────────────────────
|
|
63
98
|
useEffect(() => {
|
|
64
99
|
const prevent = (e) => {
|
|
65
100
|
if (e.ctrlKey || e.metaKey)
|
|
@@ -68,66 +103,50 @@ export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasVi
|
|
|
68
103
|
window.addEventListener("wheel", prevent, { passive: false });
|
|
69
104
|
return () => window.removeEventListener("wheel", prevent);
|
|
70
105
|
}, []);
|
|
71
|
-
// Keyboard shortcuts
|
|
106
|
+
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
|
72
107
|
useEffect(() => {
|
|
73
108
|
const onKey = (e) => {
|
|
74
|
-
if (e.target instanceof HTMLInputElement ||
|
|
75
|
-
e.target instanceof HTMLTextAreaElement)
|
|
109
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
76
110
|
return;
|
|
77
111
|
if (!(e.ctrlKey || e.metaKey))
|
|
78
112
|
return;
|
|
113
|
+
const canvas = fabricCanvas.current;
|
|
114
|
+
if (!canvas)
|
|
115
|
+
return;
|
|
79
116
|
if (e.key === "=" || e.key === "+") {
|
|
80
117
|
e.preventDefault();
|
|
81
|
-
|
|
118
|
+
// ── PERF FIX 5: Read zoom from canvas directly, not from stale state ─
|
|
119
|
+
handleZoom(canvas.getZoom() + 0.1);
|
|
82
120
|
}
|
|
83
121
|
else if (e.key === "-" || e.key === "_") {
|
|
84
122
|
e.preventDefault();
|
|
85
|
-
handleZoom(
|
|
123
|
+
handleZoom(canvas.getZoom() - 0.1);
|
|
86
124
|
}
|
|
87
125
|
else if (e.key === "0") {
|
|
88
126
|
e.preventDefault();
|
|
89
|
-
const canvas = fabricCanvas.current;
|
|
90
|
-
if (!canvas)
|
|
91
|
-
return;
|
|
92
127
|
canvas.setZoom(1);
|
|
93
128
|
const vpt = canvas.viewportTransform;
|
|
94
129
|
if (vpt) {
|
|
95
130
|
vpt[4] = 0;
|
|
96
131
|
vpt[5] = 0;
|
|
97
|
-
setCanvasViewport({ x: 0, y: 0 });
|
|
98
132
|
}
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
canvas.requestRenderAll();
|
|
134
|
+
setCanvasZoomRef.current(1);
|
|
135
|
+
setCanvasViewportRef.current({ x: 0, y: 0 });
|
|
101
136
|
}
|
|
102
137
|
};
|
|
103
138
|
window.addEventListener("keydown", onKey);
|
|
104
139
|
return () => window.removeEventListener("keydown", onKey);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (canvasViewport.x !== vpt[4] || canvasViewport.y !== vpt[5]) {
|
|
117
|
-
setCanvasViewport({ x: vpt[4], y: vpt[5] });
|
|
118
|
-
}
|
|
119
|
-
if (canvasZoom !== z) {
|
|
120
|
-
setCanvasZoom(z);
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
canvas.on("after:render", sync);
|
|
124
|
-
canvas.on("object:moving", sync);
|
|
125
|
-
canvas.on("mouse:up", sync);
|
|
126
|
-
return () => {
|
|
127
|
-
canvas.off("after:render", sync);
|
|
128
|
-
canvas.off("object:moving", sync);
|
|
129
|
-
canvas.off("mouse:up", sync);
|
|
130
|
-
};
|
|
131
|
-
}, [fabricCanvas, setCanvasViewport, setCanvasZoom]);
|
|
140
|
+
// ── Stable — reads zoom from canvas directly, not from canvasZoom state ───
|
|
141
|
+
}, [fabricCanvas, handleZoom]);
|
|
142
|
+
// ── PERF FIX 6: after:render sync REMOVED ────────────────────────────────
|
|
143
|
+
// The old code registered a "after:render" listener that called
|
|
144
|
+
// setCanvasViewport + setCanvasZoom. Since after:render fires after EVERY
|
|
145
|
+
// Fabric paint frame, this created an infinite loop:
|
|
146
|
+
// Fabric renders → setCanvasZoom → React re-renders → Fabric re-renders → ...
|
|
147
|
+
// React state is now only updated at deliberate points:
|
|
148
|
+
// - handleZoom (once per zoom gesture)
|
|
149
|
+
// - scheduleFlush via rAF (once per animation frame during scroll pan)
|
|
150
|
+
// - usePan's onUp (once per pan gesture end)
|
|
132
151
|
return { handleZoom };
|
|
133
152
|
};
|