@mhamz.01/easyflow-whiteboard 2.28.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,94 +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);
|
|
22
|
-
// ──
|
|
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
|
|
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 ─────────────────────────────────────────────
|
|
23
46
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
24
47
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
25
|
-
//
|
|
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
|
-
};
|
|
48
|
+
// ─── Wheel forwarding ─────────────────────────────────────────────────────
|
|
51
49
|
useEffect(() => {
|
|
52
50
|
const overlayEl = overlayRef.current;
|
|
53
51
|
const canvas = fabricCanvas?.current;
|
|
54
52
|
if (!overlayEl || !canvas)
|
|
55
53
|
return;
|
|
56
54
|
const handleGlobalWheel = (e) => {
|
|
57
|
-
|
|
58
|
-
// (meaning they are hovering over a Task or Document)
|
|
59
|
-
const target = e.target;
|
|
60
|
-
const isOverNode = target !== overlayEl;
|
|
55
|
+
const isOverNode = e.target !== overlayEl;
|
|
61
56
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
62
|
-
// 1. Prevent Browser Zoom immediately
|
|
63
57
|
e.preventDefault();
|
|
64
58
|
e.stopPropagation();
|
|
65
|
-
// 2. Calculate coordinates for Fabric
|
|
66
59
|
const scenePoint = canvas.getScenePoint(e);
|
|
67
60
|
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
|
|
73
61
|
canvas.fire("mouse:wheel", {
|
|
74
|
-
e
|
|
75
|
-
|
|
76
|
-
viewportPoint,
|
|
62
|
+
e, scenePoint,
|
|
63
|
+
viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
77
64
|
});
|
|
78
65
|
}
|
|
79
66
|
};
|
|
80
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
81
67
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
82
|
-
return () =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
|
|
86
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
68
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
69
|
+
}, [fabricCanvas]);
|
|
70
|
+
// ─── Fabric → Overlay sync ────────────────────────────────────────────────
|
|
87
71
|
useEffect(() => {
|
|
88
72
|
const canvas = fabricCanvas?.current;
|
|
89
73
|
if (!canvas)
|
|
90
74
|
return;
|
|
91
75
|
const handleObjectMoving = (e) => {
|
|
76
|
+
// MUTEX: HTML drag and Fabric drag must never write positions simultaneously
|
|
77
|
+
if (isHtmlDraggingRef.current)
|
|
78
|
+
return;
|
|
92
79
|
const target = e.transform?.target || e.target;
|
|
93
80
|
if (!target)
|
|
94
81
|
return;
|
|
@@ -98,179 +85,85 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
98
85
|
target._prevTop = target.top;
|
|
99
86
|
if (deltaX === 0 && deltaY === 0)
|
|
100
87
|
return;
|
|
101
|
-
|
|
102
|
-
|
|
88
|
+
const sel = selectedIdsRef.current;
|
|
89
|
+
setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
90
|
+
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
103
91
|
};
|
|
104
92
|
const handleMouseDown = (e) => {
|
|
105
93
|
const target = e.target;
|
|
106
|
-
// Always snapshot for delta tracking
|
|
107
94
|
if (target) {
|
|
108
95
|
target._prevLeft = target.left;
|
|
109
96
|
target._prevTop = target.top;
|
|
110
97
|
}
|
|
111
|
-
if (!target) {
|
|
112
|
-
// Clicked empty canvas — deselect HTML nodes
|
|
113
|
-
setSelectedIds(new Set());
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
// Clicked a Fabric object — only deselect HTML nodes if that object
|
|
117
|
-
// is NOT already part of the active selection (i.e. not a group drag)
|
|
118
|
-
const activeObjects = canvas.getActiveObjects();
|
|
119
|
-
const isTargetAlreadySelected = activeObjects.includes(target);
|
|
120
|
-
if (!isTargetAlreadySelected) {
|
|
121
|
-
setSelectedIds(new Set());
|
|
122
|
-
}
|
|
123
|
-
// If already selected: preserve HTML selectedIds for mixed-group drag
|
|
124
|
-
};
|
|
125
|
-
const handleFabricSelection = () => {
|
|
126
|
-
// Only clear HTML selection when no HTML drag is active
|
|
127
|
-
// Prevents clearing during programmatic selection updates mid-drag
|
|
128
|
-
if (!dragStateRef.current.isDragging) {
|
|
129
|
-
setSelectedIds(new Set());
|
|
130
|
-
}
|
|
131
98
|
};
|
|
132
99
|
canvas.on("object:moving", handleObjectMoving);
|
|
133
100
|
canvas.on("mouse:down", handleMouseDown);
|
|
134
|
-
canvas.on("selection:created", handleFabricSelection);
|
|
135
|
-
canvas.on("selection:updated", handleFabricSelection);
|
|
136
101
|
return () => {
|
|
137
102
|
canvas.off("object:moving", handleObjectMoving);
|
|
138
103
|
canvas.off("mouse:down", handleMouseDown);
|
|
139
|
-
canvas.off("selection:created", handleFabricSelection);
|
|
140
|
-
canvas.off("selection:updated", handleFabricSelection);
|
|
141
104
|
};
|
|
142
|
-
}, [
|
|
143
|
-
//
|
|
144
|
-
const getItemPosition = (id) => {
|
|
145
|
-
const task = localTasks.find((t) => t.id === id);
|
|
146
|
-
if (task)
|
|
147
|
-
return { x: task.x, y: task.y };
|
|
148
|
-
const doc = localDocuments.find((d) => d.id === id);
|
|
149
|
-
if (doc)
|
|
150
|
-
return { x: doc.x, y: doc.y };
|
|
151
|
-
return undefined;
|
|
152
|
-
};
|
|
153
|
-
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
154
|
-
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
155
|
-
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
156
|
-
const itemX2 = itemX1 + width * canvasZoom;
|
|
157
|
-
const itemY2 = itemY1 + height * canvasZoom;
|
|
158
|
-
const boxX1 = Math.min(box.x1, box.x2);
|
|
159
|
-
const boxY1 = Math.min(box.y1, box.y2);
|
|
160
|
-
const boxX2 = Math.max(box.x1, box.x2);
|
|
161
|
-
const boxY2 = Math.max(box.y1, box.y2);
|
|
162
|
-
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
163
|
-
};
|
|
164
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
105
|
+
}, [fabricCanvas]);
|
|
106
|
+
// ─── Selection box ────────────────────────────────────────────────────────
|
|
165
107
|
useEffect(() => {
|
|
166
108
|
if (!selectionBox)
|
|
167
109
|
return;
|
|
168
|
-
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
169
110
|
const newSelected = new Set();
|
|
170
111
|
for (const task of localTasks) {
|
|
171
|
-
|
|
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))
|
|
172
121
|
newSelected.add(task.id);
|
|
173
122
|
}
|
|
174
123
|
for (const doc of localDocuments) {
|
|
175
|
-
|
|
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))
|
|
176
133
|
newSelected.add(doc.id);
|
|
177
134
|
}
|
|
178
|
-
//
|
|
135
|
+
// O(n) equality — same Set ref if unchanged, blocks unnecessary re-render
|
|
179
136
|
setSelectedIds((prev) => {
|
|
180
137
|
if (prev.size !== newSelected.size)
|
|
181
138
|
return newSelected;
|
|
182
|
-
for (const id of newSelected)
|
|
139
|
+
for (const id of newSelected)
|
|
183
140
|
if (!prev.has(id))
|
|
184
|
-
return newSelected;
|
|
185
|
-
|
|
186
|
-
return prev; // identical — return same reference, no re-render
|
|
187
|
-
});
|
|
188
|
-
}, [selectionBox, localTasks, localDocuments]);
|
|
189
|
-
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
190
|
-
// Helper to extract coordinates regardless of event type
|
|
191
|
-
const getPointerEvent = (e) => {
|
|
192
|
-
if ('touches' in e && e.touches.length > 0)
|
|
193
|
-
return e.touches[0];
|
|
194
|
-
return e;
|
|
195
|
-
};
|
|
196
|
-
const handleDragStart = (itemId, e) => {
|
|
197
|
-
// 1. Safety check for the Fabric instance
|
|
198
|
-
const canvas = fabricCanvas?.current;
|
|
199
|
-
if (!canvas)
|
|
200
|
-
return;
|
|
201
|
-
// 2. Normalize the event (Touch vs Mouse)
|
|
202
|
-
if (e.cancelable)
|
|
203
|
-
e.preventDefault();
|
|
204
|
-
const pointer = getPointerEvent(e);
|
|
205
|
-
// 3. Determine which items are being dragged
|
|
206
|
-
// selection update DOES NOT trigger before drag snapshot
|
|
207
|
-
let itemsToDrag;
|
|
208
|
-
if (selectedIds.has(itemId)) {
|
|
209
|
-
itemsToDrag = Array.from(selectedIds);
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
itemsToDrag = [itemId];
|
|
213
|
-
}
|
|
214
|
-
// 4. Capture current World Transform (Zoom & Pan)
|
|
215
|
-
// We read directly from the canvas to ensure zero-frame lag
|
|
216
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
217
|
-
const liveZoom = vpt[0];
|
|
218
|
-
const liveVpX = vpt[4];
|
|
219
|
-
const liveVpY = vpt[5];
|
|
220
|
-
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
221
|
-
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
222
|
-
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
223
|
-
// 6. Get the clicked item's current World Position
|
|
224
|
-
const clickedPos = getItemPosition(itemId);
|
|
225
|
-
if (!clickedPos)
|
|
226
|
-
return;
|
|
227
|
-
// 7. Calculate the Offset in WORLD UNITS
|
|
228
|
-
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
229
|
-
// This value remains constant even if you zoom during the drag.
|
|
230
|
-
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
231
|
-
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
232
|
-
// 8. Snapshot starting positions for all selected HTML nodes
|
|
233
|
-
const startPositions = new Map();
|
|
234
|
-
itemsToDrag.forEach((id) => {
|
|
235
|
-
const pos = getItemPosition(id);
|
|
236
|
-
if (pos)
|
|
237
|
-
startPositions.set(id, pos);
|
|
238
|
-
});
|
|
239
|
-
// 9. Snapshot starting positions for all selected Fabric objects
|
|
240
|
-
const canvasObjectsStartPos = new Map();
|
|
241
|
-
selectedCanvasObjects.forEach((obj) => {
|
|
242
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
141
|
+
return newSelected;
|
|
142
|
+
return prev;
|
|
243
143
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
document.body.style.userSelect = "none";
|
|
260
|
-
document.body.style.touchAction = "none";
|
|
261
|
-
};
|
|
262
|
-
// ── 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
|
+
//
|
|
263
159
|
useEffect(() => {
|
|
264
|
-
if (!dragging)
|
|
265
|
-
return;
|
|
266
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
267
160
|
const handleMove = (e) => {
|
|
268
161
|
if (!dragStateRef.current.isDragging)
|
|
269
162
|
return;
|
|
270
163
|
if (e.cancelable)
|
|
271
164
|
e.preventDefault();
|
|
272
|
-
const pointer =
|
|
273
|
-
|
|
165
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
166
|
+
? e.touches[0] : e;
|
|
274
167
|
if (rafIdRef.current !== null)
|
|
275
168
|
cancelAnimationFrame(rafIdRef.current);
|
|
276
169
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -278,59 +171,50 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
278
171
|
const canvas = fabricCanvas?.current;
|
|
279
172
|
if (!canvas)
|
|
280
173
|
return;
|
|
281
|
-
//
|
|
174
|
+
// Live VPT — never from React state
|
|
282
175
|
const vpt = canvas.viewportTransform;
|
|
283
|
-
const liveZoom = vpt[0];
|
|
284
|
-
const liveVpX = vpt[4];
|
|
285
|
-
const liveVpY = vpt[5];
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
// (Current Mouse World - Initial World Offset from Start)
|
|
291
|
-
const newWorldX = currentWorldX - offsetX;
|
|
292
|
-
const newWorldY = currentWorldY - offsetY;
|
|
293
|
-
// 5. Calculate the Movement Delta in World Units
|
|
294
|
-
// We compare where the first item started vs where it is now.
|
|
295
|
-
const firstId = itemIds[0];
|
|
296
|
-
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]);
|
|
297
183
|
if (!firstStart)
|
|
298
184
|
return;
|
|
299
185
|
const deltaX = newWorldX - firstStart.x;
|
|
300
186
|
const deltaY = newWorldY - firstStart.y;
|
|
301
|
-
//
|
|
302
|
-
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
303
|
-
...t,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
} : d));
|
|
312
|
-
// 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
|
|
313
197
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
314
|
-
obj.set({
|
|
315
|
-
|
|
316
|
-
top: startPos.top + deltaY,
|
|
317
|
-
});
|
|
318
|
-
obj.setCoords(); // Required for selection/intersection accuracy
|
|
198
|
+
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
199
|
+
obj.setCoords();
|
|
319
200
|
});
|
|
320
|
-
// 8. Single render call for all Fabric changes
|
|
321
201
|
canvas.requestRenderAll();
|
|
322
202
|
});
|
|
323
203
|
};
|
|
324
204
|
const handleEnd = () => {
|
|
325
|
-
if (
|
|
205
|
+
if (!dragStateRef.current.isDragging)
|
|
206
|
+
return;
|
|
207
|
+
if (rafIdRef.current !== null) {
|
|
326
208
|
cancelAnimationFrame(rafIdRef.current);
|
|
209
|
+
rafIdRef.current = null;
|
|
210
|
+
}
|
|
327
211
|
dragStateRef.current.isDragging = false;
|
|
328
|
-
|
|
212
|
+
isHtmlDraggingRef.current = false;
|
|
329
213
|
document.body.style.cursor = "";
|
|
330
214
|
document.body.style.userSelect = "";
|
|
331
215
|
document.body.style.touchAction = "";
|
|
332
|
-
|
|
333
|
-
|
|
216
|
+
setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
|
|
217
|
+
setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
|
|
334
218
|
};
|
|
335
219
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
336
220
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -344,9 +228,62 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
344
228
|
window.removeEventListener("touchend", handleEnd);
|
|
345
229
|
window.removeEventListener("touchcancel", handleEnd);
|
|
346
230
|
};
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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) => {
|
|
350
287
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
351
288
|
setSelectedIds((prev) => {
|
|
352
289
|
const next = new Set(prev);
|
|
@@ -357,68 +294,48 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
357
294
|
else {
|
|
358
295
|
setSelectedIds(new Set([id]));
|
|
359
296
|
}
|
|
360
|
-
};
|
|
361
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 ───────────────────────────────────────────────────
|
|
366
306
|
useEffect(() => {
|
|
367
307
|
const handleKeyDown = (e) => {
|
|
368
|
-
// Don't trigger if typing in input
|
|
369
308
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
370
309
|
return;
|
|
371
|
-
// Select All
|
|
372
310
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
373
311
|
e.preventDefault();
|
|
374
312
|
setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
|
|
375
313
|
}
|
|
376
|
-
|
|
377
|
-
if (e.key === "Escape") {
|
|
314
|
+
if (e.key === "Escape")
|
|
378
315
|
setSelectedIds(new Set());
|
|
379
|
-
|
|
380
|
-
// ← ADD THIS: Delete selected nodes
|
|
381
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
316
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
382
317
|
e.preventDefault();
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
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; });
|
|
387
321
|
setSelectedIds(new Set());
|
|
388
|
-
onTasksUpdate?.(updatedTasks);
|
|
389
|
-
onDocumentsUpdate?.(updatedDocs);
|
|
390
322
|
}
|
|
391
323
|
};
|
|
392
324
|
window.addEventListener("keydown", handleKeyDown);
|
|
393
325
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
394
|
-
}, [localTasks, localDocuments
|
|
395
|
-
//
|
|
396
|
-
const renderItem = (id, x, y, children) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// 2. Use translate3d for GPU performance
|
|
407
|
-
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
408
|
-
transformOrigin: "top left",
|
|
409
|
-
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
410
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
411
|
-
transition: "none",
|
|
412
|
-
// 4. Optimization
|
|
413
|
-
willChange: "transform",
|
|
414
|
-
zIndex: isDragging ? 1000 : 1,
|
|
415
|
-
}, children: children }, id));
|
|
416
|
-
};
|
|
417
|
-
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
418
|
-
if (e.target === e.currentTarget)
|
|
419
|
-
setSelectedIds(new Set());
|
|
420
|
-
}, 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: {
|
|
421
338
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
422
339
|
transformOrigin: "top left",
|
|
423
|
-
}, 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 })))] }) }));
|
|
424
341
|
}
|