@mhamz.01/easyflow-whiteboard 2.10.0 → 2.12.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;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;
|
|
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;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,6 +1,6 @@
|
|
|
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
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
@@ -8,9 +8,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
8
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
9
9
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
10
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
|
-
|
|
12
|
-
// This is the core fix: dragging state lives ONLY in a ref, never triggers
|
|
13
|
-
// re-renders, so the mousemove/mouseup listeners are NEVER torn down mid-drag.
|
|
11
|
+
const [dragging, setDragging] = useState(null);
|
|
14
12
|
const dragStateRef = useRef({
|
|
15
13
|
isDragging: false,
|
|
16
14
|
itemIds: [],
|
|
@@ -19,69 +17,92 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
19
17
|
offsetX: 0,
|
|
20
18
|
offsetY: 0,
|
|
21
19
|
});
|
|
22
|
-
// Tracks whether an HTML node drag is active — blocks object:moving sync
|
|
23
|
-
const isHtmlDraggingRef = useRef(false);
|
|
24
20
|
const rafIdRef = useRef(null);
|
|
25
21
|
const overlayRef = useRef(null);
|
|
26
|
-
// ──
|
|
27
|
-
// Avoids stale closure problem in handleDragStart reading localTasks state
|
|
28
|
-
const nodePositionsRef = useRef(new Map());
|
|
29
|
-
nodePositionsRef.current = new Map([
|
|
30
|
-
...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
|
|
31
|
-
...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
|
|
32
|
-
]);
|
|
33
|
-
// ── selectedIds ref — so object:moving closure always has fresh selectedIds ─
|
|
34
|
-
const selectedIdsRef = useRef(selectedIds);
|
|
35
|
-
selectedIdsRef.current = selectedIds;
|
|
36
|
-
// ── Sync props → local state ───────────────────────────────────────────────
|
|
22
|
+
// ── Sync props → local state ────────────────────────────────────────────────
|
|
37
23
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
38
24
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
39
|
-
// ──
|
|
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
|
+
};
|
|
40
51
|
useEffect(() => {
|
|
41
52
|
const overlayEl = overlayRef.current;
|
|
42
53
|
const canvas = fabricCanvas?.current;
|
|
43
54
|
if (!overlayEl || !canvas)
|
|
44
55
|
return;
|
|
45
56
|
const handleGlobalWheel = (e) => {
|
|
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)
|
|
46
59
|
const target = e.target;
|
|
47
60
|
const isOverNode = target !== overlayEl;
|
|
48
61
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
62
|
+
// 1. Prevent Browser Zoom immediately
|
|
49
63
|
e.preventDefault();
|
|
50
64
|
e.stopPropagation();
|
|
65
|
+
// 2. Calculate coordinates for Fabric
|
|
51
66
|
const scenePoint = canvas.getScenePoint(e);
|
|
52
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
|
|
53
73
|
canvas.fire("mouse:wheel", {
|
|
54
|
-
e,
|
|
74
|
+
e: e,
|
|
55
75
|
scenePoint,
|
|
56
|
-
viewportPoint
|
|
76
|
+
viewportPoint,
|
|
57
77
|
});
|
|
58
78
|
}
|
|
59
79
|
};
|
|
80
|
+
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
60
81
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
61
|
-
return () =>
|
|
62
|
-
|
|
63
|
-
|
|
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) ──────────────────
|
|
64
87
|
useEffect(() => {
|
|
65
88
|
const canvas = fabricCanvas?.current;
|
|
66
89
|
if (!canvas)
|
|
67
90
|
return;
|
|
68
91
|
const handleObjectMoving = (e) => {
|
|
69
|
-
// CRITICAL: Skip entirely during HTML node drag — prevents position fighting
|
|
70
|
-
if (isHtmlDraggingRef.current)
|
|
71
|
-
return;
|
|
72
92
|
const target = e.transform?.target || e.target;
|
|
73
93
|
if (!target)
|
|
74
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.
|
|
75
97
|
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
76
98
|
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
77
99
|
target._prevLeft = target.left;
|
|
78
100
|
target._prevTop = target.top;
|
|
79
101
|
if (deltaX === 0 && deltaY === 0)
|
|
80
102
|
return;
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
setLocalDocuments((prev) => prev.map((d) => currentSelected.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : 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)));
|
|
85
106
|
};
|
|
86
107
|
const handleMouseDown = (e) => {
|
|
87
108
|
const target = e.target;
|
|
@@ -96,9 +117,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
96
117
|
canvas.off("object:moving", handleObjectMoving);
|
|
97
118
|
canvas.off("mouse:down", handleMouseDown);
|
|
98
119
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
};
|
|
102
131
|
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
103
132
|
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
104
133
|
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
@@ -110,9 +139,11 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
110
139
|
const boxY2 = Math.max(box.y1, box.y2);
|
|
111
140
|
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
112
141
|
};
|
|
142
|
+
// ── Selection box detection ──────────────────────────────────────────────────
|
|
113
143
|
useEffect(() => {
|
|
114
144
|
if (!selectionBox)
|
|
115
145
|
return;
|
|
146
|
+
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
116
147
|
const newSelected = new Set();
|
|
117
148
|
for (const task of localTasks) {
|
|
118
149
|
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
@@ -122,30 +153,102 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
122
153
|
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
123
154
|
newSelected.add(doc.id);
|
|
124
155
|
}
|
|
125
|
-
// O(n) equality
|
|
156
|
+
// ── O(n) equality check: size first (fast path), then membership ──
|
|
126
157
|
setSelectedIds((prev) => {
|
|
127
158
|
if (prev.size !== newSelected.size)
|
|
128
159
|
return newSelected;
|
|
129
|
-
for (const id of newSelected)
|
|
160
|
+
for (const id of newSelected) {
|
|
130
161
|
if (!prev.has(id))
|
|
131
|
-
return newSelected;
|
|
132
|
-
|
|
162
|
+
return newSelected; // found a difference, swap
|
|
163
|
+
}
|
|
164
|
+
return prev; // identical — return same reference, no re-render
|
|
133
165
|
});
|
|
134
166
|
}, [selectionBox, localTasks, localDocuments]);
|
|
135
|
-
// ──
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 });
|
|
221
|
+
});
|
|
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) ───────────────────────────────────────────────
|
|
140
241
|
useEffect(() => {
|
|
242
|
+
if (!dragging)
|
|
243
|
+
return;
|
|
244
|
+
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
141
245
|
const handleMove = (e) => {
|
|
142
246
|
if (!dragStateRef.current.isDragging)
|
|
143
247
|
return;
|
|
144
248
|
if (e.cancelable)
|
|
145
249
|
e.preventDefault();
|
|
146
|
-
const pointer =
|
|
147
|
-
|
|
148
|
-
: e;
|
|
250
|
+
const pointer = getPointerEvent(e);
|
|
251
|
+
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
149
252
|
if (rafIdRef.current !== null)
|
|
150
253
|
cancelAnimationFrame(rafIdRef.current);
|
|
151
254
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -153,23 +256,27 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
153
256
|
const canvas = fabricCanvas?.current;
|
|
154
257
|
if (!canvas)
|
|
155
258
|
return;
|
|
156
|
-
// Read
|
|
259
|
+
// 2. Read the "Source of Truth" transform from the canvas
|
|
157
260
|
const vpt = canvas.viewportTransform;
|
|
158
|
-
const liveZoom = vpt[0];
|
|
159
|
-
const liveVpX = vpt[4];
|
|
160
|
-
const liveVpY = vpt[5];
|
|
161
|
-
// Screen → World
|
|
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
|
|
162
265
|
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
163
266
|
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
164
|
-
//
|
|
267
|
+
// 4. Calculate where the "Anchor" node should be in World Units
|
|
268
|
+
// (Current Mouse World - Initial World Offset from Start)
|
|
165
269
|
const newWorldX = currentWorldX - offsetX;
|
|
166
270
|
const newWorldY = currentWorldY - offsetY;
|
|
167
|
-
//
|
|
168
|
-
|
|
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);
|
|
169
275
|
if (!firstStart)
|
|
170
276
|
return;
|
|
171
277
|
const deltaX = newWorldX - firstStart.x;
|
|
172
278
|
const deltaY = newWorldY - firstStart.y;
|
|
279
|
+
// 6. Update HTML Nodes (Batching these into one state update)
|
|
173
280
|
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
|
|
174
281
|
...t,
|
|
175
282
|
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
@@ -180,28 +287,28 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
180
287
|
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
181
288
|
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
182
289
|
} : d));
|
|
290
|
+
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
183
291
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
184
|
-
obj.set({
|
|
185
|
-
|
|
292
|
+
obj.set({
|
|
293
|
+
left: startPos.left + deltaX,
|
|
294
|
+
top: startPos.top + deltaY,
|
|
295
|
+
});
|
|
296
|
+
obj.setCoords(); // Required for selection/intersection accuracy
|
|
186
297
|
});
|
|
298
|
+
// 8. Single render call for all Fabric changes
|
|
187
299
|
canvas.requestRenderAll();
|
|
188
300
|
});
|
|
189
301
|
};
|
|
190
|
-
const handleEnd = (
|
|
191
|
-
if (
|
|
192
|
-
return;
|
|
193
|
-
if (rafIdRef.current !== null) {
|
|
302
|
+
const handleEnd = () => {
|
|
303
|
+
if (rafIdRef.current !== null)
|
|
194
304
|
cancelAnimationFrame(rafIdRef.current);
|
|
195
|
-
rafIdRef.current = null;
|
|
196
|
-
}
|
|
197
305
|
dragStateRef.current.isDragging = false;
|
|
198
|
-
|
|
306
|
+
setDragging(null);
|
|
199
307
|
document.body.style.cursor = "";
|
|
200
308
|
document.body.style.userSelect = "";
|
|
201
309
|
document.body.style.touchAction = "";
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
setLocalDocuments((prev) => { onDocumentsUpdate?.(prev); return prev; });
|
|
310
|
+
onTasksUpdate?.(localTasks);
|
|
311
|
+
onDocumentsUpdate?.(localDocuments);
|
|
205
312
|
};
|
|
206
313
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
207
314
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -215,72 +322,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
215
322
|
window.removeEventListener("touchend", handleEnd);
|
|
216
323
|
window.removeEventListener("touchcancel", handleEnd);
|
|
217
324
|
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const handleDragStart = useCallback((itemId, e) => {
|
|
222
|
-
if (e.cancelable)
|
|
223
|
-
e.preventDefault();
|
|
224
|
-
const canvas = fabricCanvas?.current;
|
|
225
|
-
if (!canvas)
|
|
226
|
-
return;
|
|
227
|
-
const pointer = "touches" in e && e.touches.length > 0
|
|
228
|
-
? e.touches[0]
|
|
229
|
-
: e;
|
|
230
|
-
// Determine drag group
|
|
231
|
-
const currentSelected = selectedIdsRef.current;
|
|
232
|
-
let itemsToDrag;
|
|
233
|
-
if (currentSelected.has(itemId)) {
|
|
234
|
-
itemsToDrag = Array.from(currentSelected);
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
itemsToDrag = [itemId];
|
|
238
|
-
setSelectedIds(new Set([itemId]));
|
|
239
|
-
}
|
|
240
|
-
// Read VPT live — zero lag
|
|
241
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
242
|
-
const liveZoom = vpt[0];
|
|
243
|
-
const liveVpX = vpt[4];
|
|
244
|
-
const liveVpY = vpt[5];
|
|
245
|
-
// Convert pointer → world
|
|
246
|
-
const pointerWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
247
|
-
const pointerWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
248
|
-
// Read positions from ref — synchronously updated every render, NEVER stale
|
|
249
|
-
const clickedPos = nodePositionsRef.current.get(itemId);
|
|
250
|
-
if (!clickedPos)
|
|
251
|
-
return;
|
|
252
|
-
// World-space offset: how far pointer is from node's top-left, in world units
|
|
253
|
-
const worldOffsetX = pointerWorldX - clickedPos.x;
|
|
254
|
-
const worldOffsetY = pointerWorldY - clickedPos.y;
|
|
255
|
-
// Snapshot all start positions from ref (not state — avoids async lag)
|
|
256
|
-
const startPositions = new Map();
|
|
257
|
-
for (const id of itemsToDrag) {
|
|
258
|
-
const pos = nodePositionsRef.current.get(id);
|
|
259
|
-
if (pos)
|
|
260
|
-
startPositions.set(id, { x: pos.x, y: pos.y });
|
|
261
|
-
}
|
|
262
|
-
// Snapshot Fabric object positions
|
|
263
|
-
const canvasObjectsStartPos = new Map();
|
|
264
|
-
selectedCanvasObjects.forEach((obj) => {
|
|
265
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
266
|
-
});
|
|
267
|
-
// Commit to ref — handleMove reads this, no state update needed
|
|
268
|
-
dragStateRef.current = {
|
|
269
|
-
isDragging: true,
|
|
270
|
-
itemIds: itemsToDrag,
|
|
271
|
-
startPositions,
|
|
272
|
-
canvasObjectsStartPos,
|
|
273
|
-
offsetX: worldOffsetX,
|
|
274
|
-
offsetY: worldOffsetY,
|
|
275
|
-
};
|
|
276
|
-
// Block object:moving from fighting our position updates
|
|
277
|
-
isHtmlDraggingRef.current = true;
|
|
278
|
-
document.body.style.cursor = "grabbing";
|
|
279
|
-
document.body.style.userSelect = "none";
|
|
280
|
-
document.body.style.touchAction = "none";
|
|
281
|
-
}, [fabricCanvas, selectedCanvasObjects]);
|
|
282
|
-
// ── Node select ────────────────────────────────────────────────────────────
|
|
283
|
-
const handleSelect = useCallback((id, e) => {
|
|
325
|
+
}, [dragging, localTasks, localDocuments, fabricCanvas]);
|
|
326
|
+
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
327
|
+
const handleSelect = (id, e) => {
|
|
284
328
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
285
329
|
setSelectedIds((prev) => {
|
|
286
330
|
const next = new Set(prev);
|
|
@@ -291,54 +335,64 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
291
335
|
else {
|
|
292
336
|
setSelectedIds(new Set([id]));
|
|
293
337
|
}
|
|
294
|
-
}
|
|
295
|
-
const handleStatusChange =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
});
|
|
301
|
-
}, [onTasksUpdate]);
|
|
302
|
-
// ── 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
|
+
};
|
|
303
344
|
useEffect(() => {
|
|
304
345
|
const handleKeyDown = (e) => {
|
|
346
|
+
// Don't trigger if typing in input
|
|
305
347
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
306
348
|
return;
|
|
349
|
+
// Select All
|
|
307
350
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
308
351
|
e.preventDefault();
|
|
309
|
-
setSelectedIds(new Set([
|
|
310
|
-
...localTasks.map((t) => t.id),
|
|
311
|
-
...localDocuments.map((d) => d.id),
|
|
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, onTasksUpdate, onDocumentsUpdate]);
|
|
327
|
-
// ── Render
|
|
372
|
+
}, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
|
|
373
|
+
// ── Render helper ────────────────────────────────────────────────────────────
|
|
328
374
|
const renderItem = (id, x, y, children) => {
|
|
329
375
|
const screenX = x * canvasZoom;
|
|
330
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);
|
|
331
381
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
332
382
|
left: 0,
|
|
333
383
|
top: 0,
|
|
384
|
+
// 2. Use translate3d for GPU performance
|
|
334
385
|
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
335
386
|
transformOrigin: "top left",
|
|
387
|
+
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
388
|
+
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
336
389
|
transition: "none",
|
|
390
|
+
// 4. Optimization
|
|
337
391
|
willChange: "transform",
|
|
338
|
-
zIndex:
|
|
392
|
+
zIndex: isDragging ? 1000 : 1,
|
|
339
393
|
}, children: children }, id));
|
|
340
394
|
};
|
|
341
|
-
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
|
|
395
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
342
396
|
if (e.target === e.currentTarget)
|
|
343
397
|
setSelectedIds(new Set());
|
|
344
398
|
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|