@mhamz.01/easyflow-whiteboard 2.29.0 → 2.30.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.
|
@@ -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;AAM9C,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;AAoBD,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,2CAqZzB"}
|
|
@@ -1,96 +1,81 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, memo } 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);
|
|
6
11
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
7
12
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
8
13
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
9
14
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
10
15
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
|
-
|
|
16
|
+
// ─── Core refs ────────────────────────────────────────────────────────────
|
|
12
17
|
const dragStateRef = useRef({
|
|
13
|
-
isDragging: false,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
canvasObjectsStartPos: new Map(),
|
|
17
|
-
offsetX: 0,
|
|
18
|
-
offsetY: 0,
|
|
18
|
+
isDragging: false, itemIds: [],
|
|
19
|
+
startPositions: new Map(), canvasObjectsStartPos: new Map(),
|
|
20
|
+
offsetX: 0, offsetY: 0,
|
|
19
21
|
});
|
|
20
22
|
const rafIdRef = useRef(null);
|
|
21
23
|
const overlayRef = useRef(null);
|
|
24
|
+
// ── PERF: Mutex prevents object:moving and handleMove writing simultaneously ─
|
|
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
|
|
22
35
|
const selectedIdsRef = useRef(selectedIds);
|
|
23
36
|
selectedIdsRef.current = selectedIds;
|
|
24
|
-
//
|
|
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 ─────────────────────────────────────────────
|
|
25
46
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
26
47
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
27
|
-
//
|
|
28
|
-
const handleOverlayWheel = (e) => {
|
|
29
|
-
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
30
|
-
const canvas = fabricCanvas?.current;
|
|
31
|
-
if (!canvas)
|
|
32
|
-
return;
|
|
33
|
-
const nativeEvent = e.nativeEvent;
|
|
34
|
-
// getScenePoint handles the transformation from screen to canvas space
|
|
35
|
-
const scenePoint = canvas.getScenePoint(nativeEvent);
|
|
36
|
-
// Viewport point is simply the mouse position relative to the canvas element
|
|
37
|
-
const rect = canvas.getElement().getBoundingClientRect();
|
|
38
|
-
const viewportPoint = {
|
|
39
|
-
x: nativeEvent.clientX - rect.left,
|
|
40
|
-
y: nativeEvent.clientY - rect.top,
|
|
41
|
-
};
|
|
42
|
-
// We cast to 'any' here because we are manually triggering an internal
|
|
43
|
-
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
44
|
-
canvas.fire("mouse:wheel", {
|
|
45
|
-
e: nativeEvent,
|
|
46
|
-
scenePoint,
|
|
47
|
-
viewportPoint,
|
|
48
|
-
});
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
e.stopPropagation();
|
|
51
|
-
}
|
|
52
|
-
};
|
|
48
|
+
// ─── Wheel forwarding ─────────────────────────────────────────────────────
|
|
53
49
|
useEffect(() => {
|
|
54
50
|
const overlayEl = overlayRef.current;
|
|
55
51
|
const canvas = fabricCanvas?.current;
|
|
56
52
|
if (!overlayEl || !canvas)
|
|
57
53
|
return;
|
|
58
54
|
const handleGlobalWheel = (e) => {
|
|
59
|
-
|
|
60
|
-
// (meaning they are hovering over a Task or Document)
|
|
61
|
-
const target = e.target;
|
|
62
|
-
const isOverNode = target !== overlayEl;
|
|
55
|
+
const isOverNode = e.target !== overlayEl;
|
|
63
56
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
64
|
-
// 1. Prevent Browser Zoom immediately
|
|
65
57
|
e.preventDefault();
|
|
66
58
|
e.stopPropagation();
|
|
67
|
-
// 2. Calculate coordinates for Fabric
|
|
68
59
|
const scenePoint = canvas.getScenePoint(e);
|
|
69
60
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
70
|
-
const viewportPoint = {
|
|
71
|
-
x: e.clientX - rect.left,
|
|
72
|
-
y: e.clientY - rect.top,
|
|
73
|
-
};
|
|
74
|
-
// 3. Manually fire the event into Fabric
|
|
75
61
|
canvas.fire("mouse:wheel", {
|
|
76
|
-
e
|
|
77
|
-
|
|
78
|
-
viewportPoint,
|
|
62
|
+
e, scenePoint,
|
|
63
|
+
viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
79
64
|
});
|
|
80
65
|
}
|
|
81
66
|
};
|
|
82
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
83
67
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
84
|
-
return () =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
|
|
88
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
68
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
69
|
+
}, [fabricCanvas]);
|
|
70
|
+
// ─── Fabric → Overlay sync ────────────────────────────────────────────────
|
|
89
71
|
useEffect(() => {
|
|
90
72
|
const canvas = fabricCanvas?.current;
|
|
91
73
|
if (!canvas)
|
|
92
74
|
return;
|
|
93
75
|
const handleObjectMoving = (e) => {
|
|
76
|
+
// MUTEX: HTML drag and Fabric drag must never write positions simultaneously
|
|
77
|
+
if (isHtmlDraggingRef.current)
|
|
78
|
+
return;
|
|
94
79
|
const target = e.transform?.target || e.target;
|
|
95
80
|
if (!target)
|
|
96
81
|
return;
|
|
@@ -100,7 +85,6 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
100
85
|
target._prevTop = target.top;
|
|
101
86
|
if (deltaX === 0 && deltaY === 0)
|
|
102
87
|
return;
|
|
103
|
-
// ── Read from ref — always fresh, never stale ──
|
|
104
88
|
const sel = selectedIdsRef.current;
|
|
105
89
|
setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
106
90
|
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
@@ -111,168 +95,75 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
111
95
|
target._prevLeft = target.left;
|
|
112
96
|
target._prevTop = target.top;
|
|
113
97
|
}
|
|
114
|
-
if (!target) {
|
|
115
|
-
setSelectedIds(new Set());
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
// ── Read from ref — not stale closure ──
|
|
119
|
-
const activeObjects = canvas.getActiveObjects();
|
|
120
|
-
const isTargetAlreadySelected = activeObjects.includes(target);
|
|
121
|
-
if (!isTargetAlreadySelected) {
|
|
122
|
-
setSelectedIds(new Set());
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
const handleFabricSelection = () => {
|
|
126
|
-
if (!dragStateRef.current.isDragging) {
|
|
127
|
-
setSelectedIds(new Set());
|
|
128
|
-
}
|
|
129
98
|
};
|
|
130
99
|
canvas.on("object:moving", handleObjectMoving);
|
|
131
100
|
canvas.on("mouse:down", handleMouseDown);
|
|
132
|
-
canvas.on("selection:created", handleFabricSelection);
|
|
133
|
-
canvas.on("selection:updated", handleFabricSelection);
|
|
134
101
|
return () => {
|
|
135
102
|
canvas.off("object:moving", handleObjectMoving);
|
|
136
103
|
canvas.off("mouse:down", handleMouseDown);
|
|
137
|
-
canvas.off("selection:created", handleFabricSelection);
|
|
138
|
-
canvas.off("selection:updated", handleFabricSelection);
|
|
139
104
|
};
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// change, creating a new closure each time. The second drag captured a stale
|
|
143
|
-
// or empty selectedIds from the closure at re-registration time.
|
|
144
|
-
}, [canvasZoom, fabricCanvas]);
|
|
145
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
146
|
-
const getItemPosition = (id) => {
|
|
147
|
-
const task = localTasks.find((t) => t.id === id);
|
|
148
|
-
if (task)
|
|
149
|
-
return { x: task.x, y: task.y };
|
|
150
|
-
const doc = localDocuments.find((d) => d.id === id);
|
|
151
|
-
if (doc)
|
|
152
|
-
return { x: doc.x, y: doc.y };
|
|
153
|
-
return undefined;
|
|
154
|
-
};
|
|
155
|
-
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
156
|
-
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
157
|
-
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
158
|
-
const itemX2 = itemX1 + width * canvasZoom;
|
|
159
|
-
const itemY2 = itemY1 + height * canvasZoom;
|
|
160
|
-
const boxX1 = Math.min(box.x1, box.x2);
|
|
161
|
-
const boxY1 = Math.min(box.y1, box.y2);
|
|
162
|
-
const boxX2 = Math.max(box.x1, box.x2);
|
|
163
|
-
const boxY2 = Math.max(box.y1, box.y2);
|
|
164
|
-
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
165
|
-
};
|
|
166
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
105
|
+
}, [fabricCanvas]);
|
|
106
|
+
// ─── Selection box ────────────────────────────────────────────────────────
|
|
167
107
|
useEffect(() => {
|
|
168
108
|
if (!selectionBox)
|
|
169
109
|
return;
|
|
170
|
-
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
171
110
|
const newSelected = new Set();
|
|
172
111
|
for (const task of localTasks) {
|
|
173
|
-
|
|
112
|
+
const x1 = task.x * canvasZoom + canvasViewport.x;
|
|
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))
|
|
174
121
|
newSelected.add(task.id);
|
|
175
122
|
}
|
|
176
123
|
for (const doc of localDocuments) {
|
|
177
|
-
|
|
124
|
+
const x1 = doc.x * canvasZoom + canvasViewport.x;
|
|
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))
|
|
178
133
|
newSelected.add(doc.id);
|
|
179
134
|
}
|
|
180
|
-
//
|
|
135
|
+
// O(n) equality — same Set ref if unchanged, blocks unnecessary re-render
|
|
181
136
|
setSelectedIds((prev) => {
|
|
182
137
|
if (prev.size !== newSelected.size)
|
|
183
138
|
return newSelected;
|
|
184
|
-
for (const id of newSelected)
|
|
139
|
+
for (const id of newSelected)
|
|
185
140
|
if (!prev.has(id))
|
|
186
|
-
return newSelected;
|
|
187
|
-
|
|
188
|
-
return prev; // identical — return same reference, no re-render
|
|
189
|
-
});
|
|
190
|
-
}, [selectionBox, localTasks, localDocuments]);
|
|
191
|
-
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
192
|
-
// Helper to extract coordinates regardless of event type
|
|
193
|
-
const getPointerEvent = (e) => {
|
|
194
|
-
if ('touches' in e && e.touches.length > 0)
|
|
195
|
-
return e.touches[0];
|
|
196
|
-
return e;
|
|
197
|
-
};
|
|
198
|
-
const handleDragStart = (itemId, e) => {
|
|
199
|
-
// 1. Safety check for the Fabric instance
|
|
200
|
-
const canvas = fabricCanvas?.current;
|
|
201
|
-
if (!canvas)
|
|
202
|
-
return;
|
|
203
|
-
// 2. Normalize the event (Touch vs Mouse)
|
|
204
|
-
if (e.cancelable)
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
const pointer = getPointerEvent(e);
|
|
207
|
-
// 3. Determine which items are being dragged
|
|
208
|
-
// selection update DOES NOT trigger before drag snapshot
|
|
209
|
-
let itemsToDrag;
|
|
210
|
-
if (selectedIds.has(itemId)) {
|
|
211
|
-
itemsToDrag = Array.from(selectedIds);
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
itemsToDrag = [itemId];
|
|
215
|
-
}
|
|
216
|
-
// 4. Capture current World Transform (Zoom & Pan)
|
|
217
|
-
// We read directly from the canvas to ensure zero-frame lag
|
|
218
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
219
|
-
const liveZoom = vpt[0];
|
|
220
|
-
const liveVpX = vpt[4];
|
|
221
|
-
const liveVpY = vpt[5];
|
|
222
|
-
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
223
|
-
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
224
|
-
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
225
|
-
// 6. Get the clicked item's current World Position
|
|
226
|
-
const clickedPos = getItemPosition(itemId);
|
|
227
|
-
if (!clickedPos)
|
|
228
|
-
return;
|
|
229
|
-
// 7. Calculate the Offset in WORLD UNITS
|
|
230
|
-
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
231
|
-
// This value remains constant even if you zoom during the drag.
|
|
232
|
-
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
233
|
-
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
234
|
-
// 8. Snapshot starting positions for all selected HTML nodes
|
|
235
|
-
const startPositions = new Map();
|
|
236
|
-
itemsToDrag.forEach((id) => {
|
|
237
|
-
const pos = getItemPosition(id);
|
|
238
|
-
if (pos)
|
|
239
|
-
startPositions.set(id, pos);
|
|
240
|
-
});
|
|
241
|
-
// 9. Snapshot starting positions for all selected Fabric objects
|
|
242
|
-
const canvasObjectsStartPos = new Map();
|
|
243
|
-
selectedCanvasObjects.forEach((obj) => {
|
|
244
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
141
|
+
return newSelected;
|
|
142
|
+
return prev;
|
|
245
143
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
document.body.style.userSelect = "none";
|
|
262
|
-
document.body.style.touchAction = "none";
|
|
263
|
-
};
|
|
264
|
-
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
144
|
+
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
145
|
+
// ─── Global drag listeners — attached ONCE on mount ───────────────────────
|
|
146
|
+
//
|
|
147
|
+
// ROOT CAUSE OF THE POSITION JUMP — fixed here:
|
|
148
|
+
//
|
|
149
|
+
// Old pattern: useEffect(() => { mousemove }, [dragging, localTasks, localDocuments])
|
|
150
|
+
// Problem:
|
|
151
|
+
// 1. handleDragStart → setDragging() → React re-render
|
|
152
|
+
// 2. Re-render tears down mousemove listener, attaches new one
|
|
153
|
+
// 3. First real mousemove fires into old (dead) listener → ignored or wrong
|
|
154
|
+
// 4. New listener fires with potentially different startPositions → JUMP
|
|
155
|
+
//
|
|
156
|
+
// Fix: attach listeners ONCE. They read all mutable state via refs.
|
|
157
|
+
// Zero re-registration, zero double-fires, zero stale closure issues.
|
|
158
|
+
//
|
|
265
159
|
useEffect(() => {
|
|
266
|
-
if (!dragging)
|
|
267
|
-
return;
|
|
268
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
269
160
|
const handleMove = (e) => {
|
|
270
161
|
if (!dragStateRef.current.isDragging)
|
|
271
162
|
return;
|
|
272
163
|
if (e.cancelable)
|
|
273
164
|
e.preventDefault();
|
|
274
|
-
const pointer =
|
|
275
|
-
|
|
165
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
166
|
+
? e.touches[0] : e;
|
|
276
167
|
if (rafIdRef.current !== null)
|
|
277
168
|
cancelAnimationFrame(rafIdRef.current);
|
|
278
169
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -280,59 +171,50 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
280
171
|
const canvas = fabricCanvas?.current;
|
|
281
172
|
if (!canvas)
|
|
282
173
|
return;
|
|
283
|
-
//
|
|
174
|
+
// Live VPT — never from React state
|
|
284
175
|
const vpt = canvas.viewportTransform;
|
|
285
|
-
const liveZoom = vpt[0];
|
|
286
|
-
const liveVpX = vpt[4];
|
|
287
|
-
const liveVpY = vpt[5];
|
|
288
|
-
//
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
// (Current Mouse World - Initial World Offset from Start)
|
|
293
|
-
const newWorldX = currentWorldX - offsetX;
|
|
294
|
-
const newWorldY = currentWorldY - offsetY;
|
|
295
|
-
// 5. Calculate the Movement Delta in World Units
|
|
296
|
-
// We compare where the first item started vs where it is now.
|
|
297
|
-
const firstId = itemIds[0];
|
|
298
|
-
const firstStart = startPositions.get(firstId);
|
|
176
|
+
const liveZoom = vpt[0];
|
|
177
|
+
const liveVpX = vpt[4];
|
|
178
|
+
const liveVpY = vpt[5];
|
|
179
|
+
// Screen → World, subtract world-space offset
|
|
180
|
+
const newWorldX = (pointer.clientX - liveVpX) / liveZoom - offsetX;
|
|
181
|
+
const newWorldY = (pointer.clientY - liveVpY) / liveZoom - offsetY;
|
|
182
|
+
const firstStart = startPositions.get(itemIds[0]);
|
|
299
183
|
if (!firstStart)
|
|
300
184
|
return;
|
|
301
185
|
const deltaX = newWorldX - firstStart.x;
|
|
302
186
|
const deltaY = newWorldY - firstStart.y;
|
|
303
|
-
//
|
|
304
|
-
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
305
|
-
...t,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
} : d));
|
|
314
|
-
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
187
|
+
// ── PERF: Only map over items being dragged ────────────────────────
|
|
188
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
189
|
+
? { ...t, x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
190
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
|
|
191
|
+
: t));
|
|
192
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
|
|
193
|
+
? { ...d, x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
194
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
|
|
195
|
+
: d));
|
|
196
|
+
// Sync Fabric objects imperatively
|
|
315
197
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
316
|
-
obj.set({
|
|
317
|
-
|
|
318
|
-
top: startPos.top + deltaY,
|
|
319
|
-
});
|
|
320
|
-
obj.setCoords(); // Required for selection/intersection accuracy
|
|
198
|
+
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
199
|
+
obj.setCoords();
|
|
321
200
|
});
|
|
322
|
-
// 8. Single render call for all Fabric changes
|
|
323
201
|
canvas.requestRenderAll();
|
|
324
202
|
});
|
|
325
203
|
};
|
|
326
204
|
const handleEnd = () => {
|
|
327
|
-
if (
|
|
205
|
+
if (!dragStateRef.current.isDragging)
|
|
206
|
+
return;
|
|
207
|
+
if (rafIdRef.current !== null) {
|
|
328
208
|
cancelAnimationFrame(rafIdRef.current);
|
|
209
|
+
rafIdRef.current = null;
|
|
210
|
+
}
|
|
329
211
|
dragStateRef.current.isDragging = false;
|
|
330
|
-
|
|
212
|
+
isHtmlDraggingRef.current = false;
|
|
331
213
|
document.body.style.cursor = "";
|
|
332
214
|
document.body.style.userSelect = "";
|
|
333
215
|
document.body.style.touchAction = "";
|
|
334
|
-
|
|
335
|
-
|
|
216
|
+
setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
|
|
217
|
+
setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
|
|
336
218
|
};
|
|
337
219
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
338
220
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -346,9 +228,62 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
346
228
|
window.removeEventListener("touchend", handleEnd);
|
|
347
229
|
window.removeEventListener("touchcancel", handleEnd);
|
|
348
230
|
};
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
231
|
+
// Empty deps — registered once, reads everything via refs
|
|
232
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
233
|
+
// ─── Drag start ───────────────────────────────────────────────────────────
|
|
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) => {
|
|
352
287
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
353
288
|
setSelectedIds((prev) => {
|
|
354
289
|
const next = new Set(prev);
|
|
@@ -359,68 +294,48 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
359
294
|
else {
|
|
360
295
|
setSelectedIds(new Set([id]));
|
|
361
296
|
}
|
|
362
|
-
};
|
|
363
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
297
|
+
}, []);
|
|
298
|
+
const handleStatusChange = useCallback((taskId, newStatus) => {
|
|
299
|
+
setLocalTasks((prev) => {
|
|
300
|
+
const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
|
|
301
|
+
onTasksUpdateRef.current?.(updated);
|
|
302
|
+
return updated;
|
|
303
|
+
});
|
|
304
|
+
}, []);
|
|
305
|
+
// ─── Keyboard shortcuts ───────────────────────────────────────────────────
|
|
368
306
|
useEffect(() => {
|
|
369
307
|
const handleKeyDown = (e) => {
|
|
370
|
-
// Don't trigger if typing in input
|
|
371
308
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
372
309
|
return;
|
|
373
|
-
// Select All
|
|
374
310
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
375
311
|
e.preventDefault();
|
|
376
312
|
setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
|
|
377
313
|
}
|
|
378
|
-
|
|
379
|
-
if (e.key === "Escape") {
|
|
314
|
+
if (e.key === "Escape")
|
|
380
315
|
setSelectedIds(new Set());
|
|
381
|
-
|
|
382
|
-
// ← ADD THIS: Delete selected nodes
|
|
383
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
316
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
384
317
|
e.preventDefault();
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
setLocalDocuments(updatedDocs);
|
|
318
|
+
const ids = selectedIdsRef.current;
|
|
319
|
+
setLocalTasks((prev) => { const u = prev.filter((t) => !ids.has(t.id)); onTasksUpdateRef.current?.(u); return u; });
|
|
320
|
+
setLocalDocuments((prev) => { const u = prev.filter((d) => !ids.has(d.id)); onDocumentsUpdateRef.current?.(u); return u; });
|
|
389
321
|
setSelectedIds(new Set());
|
|
390
|
-
onTasksUpdate?.(updatedTasks);
|
|
391
|
-
onDocumentsUpdate?.(updatedDocs);
|
|
392
322
|
}
|
|
393
323
|
};
|
|
394
324
|
window.addEventListener("keydown", handleKeyDown);
|
|
395
325
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
396
|
-
}, [localTasks, localDocuments
|
|
397
|
-
//
|
|
398
|
-
const renderItem = (id, x, y, children) => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
// 2. Use translate3d for GPU performance
|
|
409
|
-
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
410
|
-
transformOrigin: "top left",
|
|
411
|
-
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
412
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
413
|
-
transition: "none",
|
|
414
|
-
// 4. Optimization
|
|
415
|
-
willChange: "transform",
|
|
416
|
-
zIndex: isDragging ? 1000 : 1,
|
|
417
|
-
}, children: children }, id));
|
|
418
|
-
};
|
|
419
|
-
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
420
|
-
if (e.target === e.currentTarget)
|
|
421
|
-
setSelectedIds(new Set());
|
|
422
|
-
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|
|
326
|
+
}, [localTasks, localDocuments]);
|
|
327
|
+
// ─── Render ───────────────────────────────────────────────────────────────
|
|
328
|
+
const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
329
|
+
left: 0, top: 0,
|
|
330
|
+
transform: `translate3d(${x * canvasZoom}px, ${y * canvasZoom}px, 0) scale(${canvasZoom})`,
|
|
331
|
+
transformOrigin: "top left",
|
|
332
|
+
transition: "none", // No CSS transitions — causes shaking during zoom
|
|
333
|
+
willChange: "transform", // GPU layer hint
|
|
334
|
+
zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
|
|
335
|
+
}, children: children }, id));
|
|
336
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => { if (e.target === e.currentTarget)
|
|
337
|
+
setSelectedIds(new Set()); }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|
|
423
338
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
424
339
|
transformOrigin: "top left",
|
|
425
|
-
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(
|
|
340
|
+
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(MemoTaskNode, { ...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(MemoDocumentNode, { ...doc, isSelected: selectedIds.has(doc.id), onSelect: handleSelect, onDragStart: handleDragStart })))] }) }));
|
|
426
341
|
}
|