@mhamz.01/easyflow-whiteboard 2.69.0 → 2.70.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;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;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,2CAulBzB"}
|
|
@@ -1,17 +1,8 @@
|
|
|
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
|
-
// ─── Pure helper: world → screen using the live VPT ──────────────────────────
|
|
7
|
-
// This is the SINGLE source of truth for positioning.
|
|
8
|
-
// screenX = worldX * zoom + vpX (same math Fabric uses internally)
|
|
9
|
-
function worldToScreen(worldX, worldY, vpt) {
|
|
10
|
-
return {
|
|
11
|
-
x: worldX * vpt[0] + vpt[4],
|
|
12
|
-
y: worldY * vpt[3] + vpt[5],
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
16
7
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
17
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
@@ -19,7 +10,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
19
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
20
11
|
const [dragging, setDragging] = useState(null);
|
|
21
12
|
const [canvasReady, setCanvasReady] = useState(false);
|
|
22
|
-
|
|
13
|
+
const nodeClipboardRef = useRef({
|
|
14
|
+
tasks: [],
|
|
15
|
+
documents: [],
|
|
16
|
+
});
|
|
23
17
|
const dragStateRef = useRef({
|
|
24
18
|
isDragging: false,
|
|
25
19
|
itemIds: [],
|
|
@@ -28,26 +22,19 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
28
22
|
offsetX: 0,
|
|
29
23
|
offsetY: 0,
|
|
30
24
|
});
|
|
25
|
+
const finalPositionsRef = useRef(null);
|
|
31
26
|
const rafIdRef = useRef(null);
|
|
32
27
|
const overlayRef = useRef(null);
|
|
33
28
|
const localTasksRef = useRef(localTasks);
|
|
34
29
|
const localDocumentsRef = useRef(localDocuments);
|
|
35
30
|
const selectedIdsRef = useRef(selectedIds);
|
|
36
|
-
// ── THE KEY REF: always holds the live VPT from Fabric ───────────────────────
|
|
37
|
-
// This is what eliminates the double-transform mismatch.
|
|
38
|
-
// Instead of using canvasZoom/canvasViewport props (which are 1 render behind),
|
|
39
|
-
// we read directly from the canvas during render via this ref.
|
|
40
|
-
const liveVptRef = useRef([1, 0, 0, 1, 0, 0]);
|
|
41
|
-
// Sync all refs on every render — O(1), no cost
|
|
42
31
|
selectedIdsRef.current = selectedIds;
|
|
43
32
|
localTasksRef.current = localTasks;
|
|
44
33
|
localDocumentsRef.current = localDocuments;
|
|
45
|
-
//
|
|
46
|
-
liveVptRef.current = [canvasZoom, 0, 0, canvasZoom, canvasViewport.x, canvasViewport.y];
|
|
47
|
-
// ── Sync props → local state ──────────────────────────────────────────────────
|
|
34
|
+
// ── Sync props → local state ────────────────────────────────────────────────
|
|
48
35
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
49
36
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
50
|
-
//
|
|
37
|
+
// effect — polls until fabricCanvas.current is available:
|
|
51
38
|
useEffect(() => {
|
|
52
39
|
if (canvasReady)
|
|
53
40
|
return;
|
|
@@ -55,6 +42,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
55
42
|
setCanvasReady(true);
|
|
56
43
|
return;
|
|
57
44
|
}
|
|
45
|
+
// Poll every 50ms until canvas is ready (only needed on first load)
|
|
58
46
|
const interval = setInterval(() => {
|
|
59
47
|
if (fabricCanvas?.current) {
|
|
60
48
|
setCanvasReady(true);
|
|
@@ -63,46 +51,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
63
51
|
}, 50);
|
|
64
52
|
return () => clearInterval(interval);
|
|
65
53
|
}, [fabricCanvas, canvasReady]);
|
|
66
|
-
// ──
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
54
|
+
// ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
|
|
55
|
+
const handleOverlayWheel = (e) => {
|
|
56
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
57
|
+
const canvas = fabricCanvas?.current;
|
|
58
|
+
if (!canvas)
|
|
59
|
+
return;
|
|
60
|
+
const nativeEvent = e.nativeEvent;
|
|
61
|
+
// getScenePoint handles the transformation from screen to canvas space
|
|
62
|
+
const scenePoint = canvas.getScenePoint(nativeEvent);
|
|
63
|
+
// Viewport point is simply the mouse position relative to the canvas element
|
|
64
|
+
const rect = canvas.getElement().getBoundingClientRect();
|
|
65
|
+
const viewportPoint = {
|
|
66
|
+
x: nativeEvent.clientX - rect.left,
|
|
67
|
+
y: nativeEvent.clientY - rect.top,
|
|
68
|
+
};
|
|
69
|
+
// We cast to 'any' here because we are manually triggering an internal
|
|
70
|
+
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
71
|
+
canvas.fire("mouse:wheel", {
|
|
72
|
+
e: nativeEvent,
|
|
73
|
+
scenePoint,
|
|
74
|
+
viewportPoint,
|
|
75
|
+
});
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
85
80
|
useEffect(() => {
|
|
86
81
|
const overlayEl = overlayRef.current;
|
|
87
82
|
const canvas = fabricCanvas?.current;
|
|
88
83
|
if (!overlayEl || !canvas)
|
|
89
84
|
return;
|
|
90
85
|
const handleGlobalWheel = (e) => {
|
|
86
|
+
// Check if the user is hovering over an element that has pointer-events: auto
|
|
87
|
+
// (meaning they are hovering over a Task or Document)
|
|
91
88
|
const target = e.target;
|
|
92
89
|
const isOverNode = target !== overlayEl;
|
|
93
90
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
91
|
+
// 1. Prevent Browser Zoom immediately
|
|
94
92
|
e.preventDefault();
|
|
95
93
|
e.stopPropagation();
|
|
94
|
+
// 2. Calculate coordinates for Fabric
|
|
96
95
|
const scenePoint = canvas.getScenePoint(e);
|
|
97
96
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
98
|
-
const viewportPoint = {
|
|
99
|
-
|
|
97
|
+
const viewportPoint = {
|
|
98
|
+
x: e.clientX - rect.left,
|
|
99
|
+
y: e.clientY - rect.top,
|
|
100
|
+
};
|
|
101
|
+
// 3. Manually fire the event into Fabric
|
|
102
|
+
canvas.fire("mouse:wheel", {
|
|
103
|
+
e: e,
|
|
104
|
+
scenePoint,
|
|
105
|
+
viewportPoint,
|
|
106
|
+
});
|
|
100
107
|
}
|
|
101
108
|
};
|
|
109
|
+
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
102
110
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
103
|
-
return () =>
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
return () => {
|
|
112
|
+
overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
113
|
+
};
|
|
114
|
+
}, [fabricCanvas, canvasZoom, canvasReady]); // Re-bind when zoom changes to keep closure fresh
|
|
115
|
+
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
106
116
|
useEffect(() => {
|
|
107
117
|
const canvas = fabricCanvas?.current;
|
|
108
118
|
if (!canvas)
|
|
@@ -117,17 +127,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
117
127
|
target._prevTop = target.top;
|
|
118
128
|
if (deltaX === 0 && deltaY === 0)
|
|
119
129
|
return;
|
|
130
|
+
// ── Read from ref — always fresh, never stale ──
|
|
120
131
|
const sel = selectedIdsRef.current;
|
|
121
|
-
setLocalTasks((prev) => {
|
|
122
|
-
|
|
123
|
-
localTasksRef.current = next;
|
|
124
|
-
return next;
|
|
125
|
-
});
|
|
126
|
-
setLocalDocuments((prev) => {
|
|
127
|
-
const next = prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d);
|
|
128
|
-
localDocumentsRef.current = next;
|
|
129
|
-
return next;
|
|
130
|
-
});
|
|
132
|
+
setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
133
|
+
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
131
134
|
};
|
|
132
135
|
const handleMouseDown = (e) => {
|
|
133
136
|
const target = e.target;
|
|
@@ -139,16 +142,22 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
139
142
|
setSelectedIds(new Set());
|
|
140
143
|
return;
|
|
141
144
|
}
|
|
145
|
+
// At zoom=1 with identity VPT, getActiveObject() can return null before
|
|
146
|
+
// Fabric updates _activeObject. Use e.transform as the primary check —
|
|
147
|
+
// it is populated by Fabric's hit-test regardless of zoom level.
|
|
148
|
+
const transformTarget = e.transform?.target;
|
|
142
149
|
const activeObject = canvas.getActiveObject();
|
|
143
150
|
const activeObjects = canvas.getActiveObjects();
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!isPartOfActiveSelection)
|
|
151
|
+
const isPartOfActiveSelection = transformTarget === target || // most reliable — direct from event
|
|
152
|
+
activeObject === target || // selection box group
|
|
153
|
+
activeObjects.includes(target); // individual object in multi-select
|
|
154
|
+
if (!isPartOfActiveSelection) {
|
|
149
155
|
setSelectedIds(new Set());
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const handleSelectionCleared = () => {
|
|
159
|
+
setSelectedIds(new Set());
|
|
150
160
|
};
|
|
151
|
-
const handleSelectionCleared = () => setSelectedIds(new Set());
|
|
152
161
|
canvas.on("object:moving", handleObjectMoving);
|
|
153
162
|
canvas.on("mouse:down", handleMouseDown);
|
|
154
163
|
canvas.on("selection:cleared", handleSelectionCleared);
|
|
@@ -157,128 +166,142 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
157
166
|
canvas.off("mouse:down", handleMouseDown);
|
|
158
167
|
canvas.off("selection:cleared", handleSelectionCleared);
|
|
159
168
|
};
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
// ── selectedIds REMOVED from deps — read via selectedIdsRef instead ──────
|
|
170
|
+
// Having selectedIds here caused the effect to re-register on every selection
|
|
171
|
+
// change, creating a new closure each time. The second drag captured a stale
|
|
172
|
+
// or empty selectedIds from the closure at re-registration time.
|
|
173
|
+
}, [canvasZoom, fabricCanvas, canvasReady]);
|
|
174
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
175
|
+
const getItemPosition = (id) => {
|
|
176
|
+
const task = localTasks.find((t) => t.id === id);
|
|
165
177
|
if (task)
|
|
166
178
|
return { x: task.x, y: task.y };
|
|
167
|
-
const doc =
|
|
179
|
+
const doc = localDocuments.find((d) => d.id === id);
|
|
168
180
|
if (doc)
|
|
169
181
|
return { x: doc.x, y: doc.y };
|
|
170
182
|
return undefined;
|
|
171
|
-
}
|
|
172
|
-
)
|
|
173
|
-
|
|
183
|
+
};
|
|
184
|
+
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
185
|
+
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
186
|
+
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
187
|
+
const itemX2 = itemX1 + width * canvasZoom;
|
|
188
|
+
const itemY2 = itemY1 + height * canvasZoom;
|
|
189
|
+
const boxX1 = Math.min(box.x1, box.x2);
|
|
190
|
+
const boxY1 = Math.min(box.y1, box.y2);
|
|
191
|
+
const boxX2 = Math.max(box.x1, box.x2);
|
|
192
|
+
const boxY2 = Math.max(box.y1, box.y2);
|
|
193
|
+
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
194
|
+
};
|
|
195
|
+
// ── Selection box detection ──────────────────────────────────────────────────
|
|
174
196
|
useEffect(() => {
|
|
175
197
|
if (!selectionBox)
|
|
176
198
|
return;
|
|
177
|
-
|
|
178
|
-
const zoom = vpt[0];
|
|
179
|
-
const vpX = vpt[4];
|
|
180
|
-
const vpY = vpt[5];
|
|
199
|
+
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
181
200
|
const newSelected = new Set();
|
|
182
|
-
const check = (x, y, w, h) => {
|
|
183
|
-
// Use the same worldToScreen math for accurate hit testing
|
|
184
|
-
const sx = x * zoom + vpX;
|
|
185
|
-
const sy = y * zoom + vpY;
|
|
186
|
-
const sx2 = sx + w * zoom;
|
|
187
|
-
const sy2 = sy + h * zoom;
|
|
188
|
-
const bx1 = Math.min(selectionBox.x1, selectionBox.x2);
|
|
189
|
-
const by1 = Math.min(selectionBox.y1, selectionBox.y2);
|
|
190
|
-
const bx2 = Math.max(selectionBox.x1, selectionBox.x2);
|
|
191
|
-
const by2 = Math.max(selectionBox.y1, selectionBox.y2);
|
|
192
|
-
return !(bx2 < sx || bx1 > sx2 || by2 < sy || by1 > sy2);
|
|
193
|
-
};
|
|
194
201
|
for (const task of localTasks) {
|
|
195
|
-
if (
|
|
202
|
+
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
196
203
|
newSelected.add(task.id);
|
|
197
204
|
}
|
|
198
205
|
for (const doc of localDocuments) {
|
|
199
|
-
if (
|
|
206
|
+
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
200
207
|
newSelected.add(doc.id);
|
|
201
208
|
}
|
|
209
|
+
// ── O(n) equality check: size first (fast path), then membership ──
|
|
202
210
|
setSelectedIds((prev) => {
|
|
203
211
|
if (prev.size !== newSelected.size)
|
|
204
212
|
return newSelected;
|
|
205
|
-
for (const id of newSelected)
|
|
213
|
+
for (const id of newSelected) {
|
|
206
214
|
if (!prev.has(id))
|
|
207
|
-
return newSelected;
|
|
208
|
-
|
|
215
|
+
return newSelected; // found a difference, swap
|
|
216
|
+
}
|
|
217
|
+
return prev; // identical — return same reference, no re-render
|
|
209
218
|
});
|
|
210
219
|
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
211
|
-
// ── Drag start
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
221
|
+
// Helper to extract coordinates regardless of event type
|
|
222
|
+
const getPointerEvent = (e) => {
|
|
223
|
+
if ('touches' in e && e.touches.length > 0)
|
|
224
|
+
return e.touches[0];
|
|
215
225
|
return e;
|
|
216
226
|
};
|
|
217
|
-
const handleDragStart =
|
|
227
|
+
const handleDragStart = (itemId, e) => {
|
|
228
|
+
// 1. Safety check for the Fabric instance
|
|
218
229
|
const canvas = fabricCanvas?.current;
|
|
219
230
|
if (!canvas)
|
|
220
231
|
return;
|
|
232
|
+
// 2. Normalize the event (Touch vs Mouse)
|
|
221
233
|
if (e.cancelable)
|
|
222
234
|
e.preventDefault();
|
|
223
|
-
const pointer =
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
const pointer = getPointerEvent(e);
|
|
236
|
+
// 3. Determine which items are being dragged
|
|
237
|
+
// selection update DOES NOT trigger before drag snapshot
|
|
238
|
+
let itemsToDrag;
|
|
239
|
+
if (selectedIdsRef.current.has(itemId)) {
|
|
240
|
+
itemsToDrag = Array.from(selectedIdsRef.current);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
itemsToDrag = [itemId];
|
|
244
|
+
}
|
|
245
|
+
// 4. Capture current World Transform (Zoom & Pan)
|
|
246
|
+
// We read directly from the canvas to ensure zero-frame lag
|
|
230
247
|
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
// Click
|
|
235
|
-
const clickWorldX = (pointer.clientX -
|
|
236
|
-
const clickWorldY = (pointer.clientY -
|
|
237
|
-
//
|
|
248
|
+
const liveZoom = vpt[0];
|
|
249
|
+
const liveVpX = vpt[4];
|
|
250
|
+
const liveVpY = vpt[5];
|
|
251
|
+
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
252
|
+
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
253
|
+
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
254
|
+
// 6. Get the clicked item's current World Position
|
|
238
255
|
const clickedPos = getItemPosition(itemId);
|
|
239
256
|
if (!clickedPos)
|
|
240
257
|
return;
|
|
258
|
+
// 7. Calculate the Offset in WORLD UNITS
|
|
259
|
+
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
260
|
+
// This value remains constant even if you zoom during the drag.
|
|
241
261
|
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
242
262
|
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
243
|
-
// Snapshot
|
|
263
|
+
// 8. Snapshot starting positions for all selected HTML nodes
|
|
244
264
|
const startPositions = new Map();
|
|
245
|
-
|
|
265
|
+
itemsToDrag.forEach((id) => {
|
|
246
266
|
const pos = getItemPosition(id);
|
|
247
267
|
if (pos)
|
|
248
|
-
startPositions.set(id,
|
|
249
|
-
}
|
|
250
|
-
// Snapshot Fabric objects
|
|
268
|
+
startPositions.set(id, pos);
|
|
269
|
+
});
|
|
270
|
+
// 9. Snapshot starting positions for all selected Fabric objects
|
|
251
271
|
const canvasObjectsStartPos = new Map();
|
|
252
|
-
|
|
253
|
-
canvasObjectsStartPos.set(obj, { left: obj.left
|
|
254
|
-
}
|
|
272
|
+
selectedCanvasObjects.forEach((obj) => {
|
|
273
|
+
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
274
|
+
});
|
|
275
|
+
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
255
276
|
dragStateRef.current = {
|
|
256
277
|
isDragging: true,
|
|
257
278
|
itemIds: itemsToDrag,
|
|
258
279
|
startPositions,
|
|
259
280
|
canvasObjectsStartPos,
|
|
260
|
-
offsetX: worldOffsetX,
|
|
261
|
-
offsetY: worldOffsetY,
|
|
281
|
+
offsetX: worldOffsetX, // Now stored as World Units
|
|
282
|
+
offsetY: worldOffsetY, // Now stored as World Units
|
|
262
283
|
};
|
|
263
|
-
|
|
264
|
-
if (!currentSelected.has(itemId)) {
|
|
284
|
+
if (!selectedIdsRef.current.has(itemId) && dragStateRef.current.itemIds.length === 0) {
|
|
265
285
|
setSelectedIds(new Set([itemId]));
|
|
266
286
|
}
|
|
287
|
+
// 11. Trigger UI states
|
|
267
288
|
setDragging({ itemIds: itemsToDrag });
|
|
268
289
|
document.body.style.cursor = "grabbing";
|
|
269
290
|
document.body.style.userSelect = "none";
|
|
270
291
|
document.body.style.touchAction = "none";
|
|
271
|
-
}
|
|
272
|
-
// ── Drag move
|
|
292
|
+
};
|
|
293
|
+
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
273
294
|
useEffect(() => {
|
|
274
295
|
if (!dragging)
|
|
275
296
|
return;
|
|
297
|
+
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
276
298
|
const handleMove = (e) => {
|
|
277
299
|
if (!dragStateRef.current.isDragging)
|
|
278
300
|
return;
|
|
279
301
|
if (e.cancelable)
|
|
280
302
|
e.preventDefault();
|
|
281
|
-
const pointer =
|
|
303
|
+
const pointer = getPointerEvent(e);
|
|
304
|
+
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
282
305
|
if (rafIdRef.current !== null)
|
|
283
306
|
cancelAnimationFrame(rafIdRef.current);
|
|
284
307
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -286,53 +309,56 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
286
309
|
const canvas = fabricCanvas?.current;
|
|
287
310
|
if (!canvas)
|
|
288
311
|
return;
|
|
289
|
-
//
|
|
290
|
-
// Props (canvasZoom, canvasViewport) are one React render behind.
|
|
291
|
-
// canvas.viewportTransform is synchronous and always current.
|
|
312
|
+
// 2. Read the "Source of Truth" transform from the canvas
|
|
292
313
|
const vpt = canvas.viewportTransform;
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
// Mouse
|
|
297
|
-
const currentWorldX = (pointer.clientX -
|
|
298
|
-
const currentWorldY = (pointer.clientY -
|
|
299
|
-
//
|
|
314
|
+
const liveZoom = vpt[0]; // Scale
|
|
315
|
+
const liveVpX = vpt[4]; // Pan X
|
|
316
|
+
const liveVpY = vpt[5]; // Pan Y
|
|
317
|
+
// 3. Convert current Mouse Screen Position → World Position
|
|
318
|
+
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
319
|
+
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
320
|
+
// 4. Calculate where the "Anchor" node should be in World Units
|
|
321
|
+
// (Current Mouse World - Initial World Offset from Start)
|
|
300
322
|
const newWorldX = currentWorldX - offsetX;
|
|
301
323
|
const newWorldY = currentWorldY - offsetY;
|
|
302
|
-
//
|
|
303
|
-
|
|
324
|
+
// 5. Calculate the Movement Delta in World Units
|
|
325
|
+
// We compare where the first item started vs where it is now.
|
|
326
|
+
const firstId = itemIds[0];
|
|
327
|
+
const firstStart = startPositions.get(firstId);
|
|
304
328
|
if (!firstStart)
|
|
305
329
|
return;
|
|
306
330
|
const deltaX = newWorldX - firstStart.x;
|
|
307
331
|
const deltaY = newWorldY - firstStart.y;
|
|
308
|
-
// Update HTML
|
|
332
|
+
// 6. Update HTML Nodes (Batching these into one state update)
|
|
309
333
|
setLocalTasks((prev) => {
|
|
310
334
|
const next = prev.map((t) => itemIds.includes(t.id)
|
|
311
|
-
? {
|
|
312
|
-
|
|
313
|
-
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
314
|
-
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
315
|
-
}
|
|
335
|
+
? { ...t, x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
336
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
|
|
316
337
|
: t);
|
|
317
|
-
localTasksRef.current = next;
|
|
338
|
+
localTasksRef.current = next; // ← write-through: ref always has latest
|
|
318
339
|
return next;
|
|
319
340
|
});
|
|
320
341
|
setLocalDocuments((prev) => {
|
|
321
342
|
const next = prev.map((d) => itemIds.includes(d.id)
|
|
322
|
-
? {
|
|
323
|
-
|
|
324
|
-
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
325
|
-
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
326
|
-
}
|
|
343
|
+
? { ...d, x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
344
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
|
|
327
345
|
: d);
|
|
328
|
-
localDocumentsRef.current = next;
|
|
346
|
+
localDocumentsRef.current = next; // ← write-through
|
|
329
347
|
return next;
|
|
330
348
|
});
|
|
331
|
-
// Sync Fabric
|
|
349
|
+
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
332
350
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
333
|
-
obj.set({
|
|
334
|
-
|
|
351
|
+
obj.set({
|
|
352
|
+
left: startPos.left + deltaX,
|
|
353
|
+
top: startPos.top + deltaY,
|
|
354
|
+
});
|
|
355
|
+
obj.setCoords(); // Required for selection/intersection accuracy
|
|
335
356
|
});
|
|
357
|
+
finalPositionsRef.current = {
|
|
358
|
+
tasks: localTasksRef.current, // will be updated by React after setState flushes
|
|
359
|
+
documents: localDocumentsRef.current,
|
|
360
|
+
};
|
|
361
|
+
// 8. Single render call for all Fabric changes
|
|
336
362
|
canvas.requestRenderAll();
|
|
337
363
|
});
|
|
338
364
|
};
|
|
@@ -346,7 +372,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
346
372
|
document.body.style.cursor = "";
|
|
347
373
|
document.body.style.userSelect = "";
|
|
348
374
|
document.body.style.touchAction = "";
|
|
349
|
-
// ✅
|
|
375
|
+
// ✅ FIX 1+3: Read from live refs — never from the stale closure.
|
|
376
|
+
// localTasksRef is kept in sync on every render, so this is always
|
|
377
|
+
// the position after the last committed setState, not the t=0 snapshot.
|
|
350
378
|
onTasksUpdate?.(localTasksRef.current);
|
|
351
379
|
onDocumentsUpdate?.(localDocumentsRef.current);
|
|
352
380
|
};
|
|
@@ -362,33 +390,46 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
362
390
|
window.removeEventListener("touchend", handleEnd);
|
|
363
391
|
window.removeEventListener("touchcancel", handleEnd);
|
|
364
392
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
393
|
+
}, [dragging, localTasks, localDocuments, fabricCanvas]);
|
|
394
|
+
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
395
|
+
const handleSelect = (id, e) => {
|
|
396
|
+
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
397
|
+
setSelectedIds((prev) => {
|
|
398
|
+
const next = new Set(prev);
|
|
399
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
400
|
+
return next;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
setSelectedIds(new Set([id]));
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const handleStatusChange = (taskId, newStatus) => {
|
|
408
|
+
const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
|
|
409
|
+
setLocalTasks(updated);
|
|
410
|
+
onTasksUpdate?.(updated);
|
|
411
|
+
};
|
|
369
412
|
useEffect(() => {
|
|
370
413
|
const handleKeyDown = (e) => {
|
|
414
|
+
// Don't trigger if typing in input
|
|
371
415
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
372
416
|
return;
|
|
417
|
+
// Select All
|
|
373
418
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
374
419
|
e.preventDefault();
|
|
375
|
-
|
|
376
|
-
setSelectedIds(new Set([
|
|
377
|
-
...localTasksRef.current.map((t) => t.id),
|
|
378
|
-
...localDocumentsRef.current.map((d) => d.id),
|
|
379
|
-
]));
|
|
420
|
+
setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
|
|
380
421
|
}
|
|
381
|
-
|
|
422
|
+
// Clear selection
|
|
423
|
+
if (e.key === "Escape") {
|
|
382
424
|
setSelectedIds(new Set());
|
|
383
|
-
|
|
425
|
+
}
|
|
426
|
+
// ← ADD THIS: Delete selected nodes
|
|
427
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
384
428
|
e.preventDefault();
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
const updatedDocs = localDocumentsRef.current.filter((d) => !sel.has(d.id));
|
|
429
|
+
const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
|
|
430
|
+
const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
|
|
388
431
|
setLocalTasks(updatedTasks);
|
|
389
432
|
setLocalDocuments(updatedDocs);
|
|
390
|
-
localTasksRef.current = updatedTasks;
|
|
391
|
-
localDocumentsRef.current = updatedDocs;
|
|
392
433
|
setSelectedIds(new Set());
|
|
393
434
|
onTasksUpdate?.(updatedTasks);
|
|
394
435
|
onDocumentsUpdate?.(updatedDocs);
|
|
@@ -396,63 +437,28 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
396
437
|
};
|
|
397
438
|
window.addEventListener("keydown", handleKeyDown);
|
|
398
439
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
else {
|
|
411
|
-
setSelectedIds(new Set([id]));
|
|
412
|
-
}
|
|
413
|
-
}, []);
|
|
414
|
-
const handleStatusChange = useCallback((taskId, newStatus) => {
|
|
415
|
-
setLocalTasks((prev) => {
|
|
416
|
-
const next = prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
|
|
417
|
-
localTasksRef.current = next;
|
|
418
|
-
onTasksUpdate?.(next);
|
|
419
|
-
return next;
|
|
420
|
-
});
|
|
421
|
-
}, [onTasksUpdate]);
|
|
422
|
-
// ── Render each node ──────────────────────────────────────────────────────────
|
|
423
|
-
// ✅ THE CORE FIX: Single collapsed transform.
|
|
424
|
-
// Old approach had TWO transforms:
|
|
425
|
-
// parent: translate(vpX, vpY)
|
|
426
|
-
// child: translate(x*zoom, y*zoom) scale(zoom)
|
|
427
|
-
// These two transforms use props that update at different React render cycles,
|
|
428
|
-
// causing a 1-frame mismatch = visible jump on multi-select drag.
|
|
429
|
-
//
|
|
430
|
-
// New approach: ONE transform on each node using the live VPT matrix directly.
|
|
431
|
-
// Formula: screenPos = worldPos * zoom + vpOffset (identical to Fabric's math)
|
|
432
|
-
const renderItem = (id, worldX, worldY, children) => {
|
|
433
|
-
const vpt = liveVptRef.current;
|
|
434
|
-
const zoom = vpt[0];
|
|
435
|
-
const vpX = vpt[4];
|
|
436
|
-
const vpY = vpt[5];
|
|
437
|
-
// Exact same calculation Fabric uses for its own objects
|
|
438
|
-
const screenX = worldX * zoom + vpX;
|
|
439
|
-
const screenY = worldY * zoom + vpY;
|
|
440
|
-
const isDraggingThis = dragging?.itemIds.includes(id) ?? false;
|
|
440
|
+
}, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
|
|
441
|
+
const getAbsoluteTransform = (x, y) => {
|
|
442
|
+
// We calculate the screen position in one go, matching Fabric's internal VPT logic
|
|
443
|
+
const screenX = x * canvasZoom + canvasViewport.x;
|
|
444
|
+
const screenY = y * canvasZoom + canvasViewport.y;
|
|
445
|
+
return `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`;
|
|
446
|
+
};
|
|
447
|
+
// ── Render helper ────────────────────────────────────────────────────────────
|
|
448
|
+
const renderItem = (id, x, y, children) => {
|
|
449
|
+
const isDragging = dragging?.itemIds.includes(id);
|
|
441
450
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
442
451
|
left: 0,
|
|
443
452
|
top: 0,
|
|
444
|
-
//
|
|
445
|
-
transform:
|
|
453
|
+
// FIX: Combined Transform prevents the "Pan vs Zoom" lag
|
|
454
|
+
transform: getAbsoluteTransform(x, y),
|
|
446
455
|
transformOrigin: "top left",
|
|
447
|
-
transition: "none",
|
|
456
|
+
transition: "none", // Critical: prevents animation-induced jitter
|
|
448
457
|
willChange: "transform",
|
|
449
|
-
zIndex:
|
|
458
|
+
zIndex: isDragging ? 1000 : 1,
|
|
450
459
|
}, children: children }, id));
|
|
451
460
|
};
|
|
452
|
-
return (
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
if (e.target === e.currentTarget)
|
|
456
|
-
setSelectedIds(new Set());
|
|
457
|
-
}, 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 })))] }));
|
|
461
|
+
return (_jsxs("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none overflow-hidden", style: { zIndex: 50 }, 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 })))] })
|
|
462
|
+
// </div>
|
|
463
|
+
);
|
|
458
464
|
}
|