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