@mhamz.01/easyflow-whiteboard 2.68.0 → 2.69.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;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AA2BD,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,2CAsiBzB"}
|
|
@@ -1,8 +1,17 @@
|
|
|
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 } 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
|
+
}
|
|
6
15
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
7
16
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
8
17
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
@@ -10,10 +19,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
10
19
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
20
|
const [dragging, setDragging] = useState(null);
|
|
12
21
|
const [canvasReady, setCanvasReady] = useState(false);
|
|
13
|
-
|
|
14
|
-
tasks: [],
|
|
15
|
-
documents: [],
|
|
16
|
-
});
|
|
22
|
+
// ── Refs (always-fresh values, never stale closures) ─────────────────────────
|
|
17
23
|
const dragStateRef = useRef({
|
|
18
24
|
isDragging: false,
|
|
19
25
|
itemIds: [],
|
|
@@ -22,19 +28,26 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
22
28
|
offsetX: 0,
|
|
23
29
|
offsetY: 0,
|
|
24
30
|
});
|
|
25
|
-
const finalPositionsRef = useRef(null);
|
|
26
31
|
const rafIdRef = useRef(null);
|
|
27
32
|
const overlayRef = useRef(null);
|
|
28
33
|
const localTasksRef = useRef(localTasks);
|
|
29
34
|
const localDocumentsRef = useRef(localDocuments);
|
|
30
35
|
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
|
|
31
42
|
selectedIdsRef.current = selectedIds;
|
|
32
43
|
localTasksRef.current = localTasks;
|
|
33
44
|
localDocumentsRef.current = localDocuments;
|
|
34
|
-
//
|
|
45
|
+
// Keep liveVptRef up to date from props as a fallback (canvas updates it directly too)
|
|
46
|
+
liveVptRef.current = [canvasZoom, 0, 0, canvasZoom, canvasViewport.x, canvasViewport.y];
|
|
47
|
+
// ── Sync props → local state ──────────────────────────────────────────────────
|
|
35
48
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
36
49
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
37
|
-
//
|
|
50
|
+
// ── Poll until Fabric canvas is ready ────────────────────────────────────────
|
|
38
51
|
useEffect(() => {
|
|
39
52
|
if (canvasReady)
|
|
40
53
|
return;
|
|
@@ -42,7 +55,6 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
42
55
|
setCanvasReady(true);
|
|
43
56
|
return;
|
|
44
57
|
}
|
|
45
|
-
// Poll every 50ms until canvas is ready (only needed on first load)
|
|
46
58
|
const interval = setInterval(() => {
|
|
47
59
|
if (fabricCanvas?.current) {
|
|
48
60
|
setCanvasReady(true);
|
|
@@ -51,68 +63,46 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
51
63
|
}, 50);
|
|
52
64
|
return () => clearInterval(interval);
|
|
53
65
|
}, [fabricCanvas, canvasReady]);
|
|
54
|
-
// ──
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
scenePoint,
|
|
74
|
-
viewportPoint,
|
|
75
|
-
});
|
|
76
|
-
e.preventDefault();
|
|
77
|
-
e.stopPropagation();
|
|
78
|
-
}
|
|
79
|
-
};
|
|
66
|
+
// ── Sync liveVptRef directly from Fabric on every viewport change ─────────────
|
|
67
|
+
// This is the critical piece: we hook into Fabric's own viewport events so
|
|
68
|
+
// liveVptRef is updated BEFORE React re-renders, not after.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const canvas = fabricCanvas?.current;
|
|
71
|
+
if (!canvas)
|
|
72
|
+
return;
|
|
73
|
+
const syncVpt = () => {
|
|
74
|
+
const vpt = canvas.viewportTransform;
|
|
75
|
+
if (vpt)
|
|
76
|
+
liveVptRef.current = [...vpt];
|
|
77
|
+
};
|
|
78
|
+
// These fire on every pan/zoom frame
|
|
79
|
+
canvas.on("after:render", syncVpt);
|
|
80
|
+
return () => {
|
|
81
|
+
canvas.off("after:render", syncVpt);
|
|
82
|
+
};
|
|
83
|
+
}, [fabricCanvas, canvasReady]);
|
|
84
|
+
// ── Event forwarding: wheel over nodes → Fabric ───────────────────────────────
|
|
80
85
|
useEffect(() => {
|
|
81
86
|
const overlayEl = overlayRef.current;
|
|
82
87
|
const canvas = fabricCanvas?.current;
|
|
83
88
|
if (!overlayEl || !canvas)
|
|
84
89
|
return;
|
|
85
90
|
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)
|
|
88
91
|
const target = e.target;
|
|
89
92
|
const isOverNode = target !== overlayEl;
|
|
90
93
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
91
|
-
// 1. Prevent Browser Zoom immediately
|
|
92
94
|
e.preventDefault();
|
|
93
95
|
e.stopPropagation();
|
|
94
|
-
// 2. Calculate coordinates for Fabric
|
|
95
96
|
const scenePoint = canvas.getScenePoint(e);
|
|
96
97
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
97
|
-
const viewportPoint = {
|
|
98
|
-
|
|
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
|
-
});
|
|
98
|
+
const viewportPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
99
|
+
canvas.fire("mouse:wheel", { e, scenePoint, viewportPoint });
|
|
107
100
|
}
|
|
108
101
|
};
|
|
109
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
110
102
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
111
|
-
return () =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}, [fabricCanvas, canvasZoom, canvasReady]); // Re-bind when zoom changes to keep closure fresh
|
|
115
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
103
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
104
|
+
}, [fabricCanvas, canvasReady]);
|
|
105
|
+
// ── Fabric object:moving → sync HTML nodes ────────────────────────────────────
|
|
116
106
|
useEffect(() => {
|
|
117
107
|
const canvas = fabricCanvas?.current;
|
|
118
108
|
if (!canvas)
|
|
@@ -127,10 +117,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
127
117
|
target._prevTop = target.top;
|
|
128
118
|
if (deltaX === 0 && deltaY === 0)
|
|
129
119
|
return;
|
|
130
|
-
// ── Read from ref — always fresh, never stale ──
|
|
131
120
|
const sel = selectedIdsRef.current;
|
|
132
|
-
setLocalTasks((prev) =>
|
|
133
|
-
|
|
121
|
+
setLocalTasks((prev) => {
|
|
122
|
+
const next = prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t);
|
|
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
|
+
});
|
|
134
131
|
};
|
|
135
132
|
const handleMouseDown = (e) => {
|
|
136
133
|
const target = e.target;
|
|
@@ -142,22 +139,16 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
142
139
|
setSelectedIds(new Set());
|
|
143
140
|
return;
|
|
144
141
|
}
|
|
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;
|
|
149
142
|
const activeObject = canvas.getActiveObject();
|
|
150
143
|
const activeObjects = canvas.getActiveObjects();
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
144
|
+
const transformTarget = e.transform?.target;
|
|
145
|
+
const isPartOfActiveSelection = transformTarget === target ||
|
|
146
|
+
activeObject === target ||
|
|
147
|
+
activeObjects.includes(target);
|
|
148
|
+
if (!isPartOfActiveSelection)
|
|
155
149
|
setSelectedIds(new Set());
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
const handleSelectionCleared = () => {
|
|
159
|
-
setSelectedIds(new Set());
|
|
160
150
|
};
|
|
151
|
+
const handleSelectionCleared = () => setSelectedIds(new Set());
|
|
161
152
|
canvas.on("object:moving", handleObjectMoving);
|
|
162
153
|
canvas.on("mouse:down", handleMouseDown);
|
|
163
154
|
canvas.on("selection:cleared", handleSelectionCleared);
|
|
@@ -166,142 +157,128 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
166
157
|
canvas.off("mouse:down", handleMouseDown);
|
|
167
158
|
canvas.off("selection:cleared", handleSelectionCleared);
|
|
168
159
|
};
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
175
|
-
const getItemPosition = (id) => {
|
|
176
|
-
const task = localTasks.find((t) => t.id === id);
|
|
160
|
+
}, [fabricCanvas, canvasReady]);
|
|
161
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
162
|
+
// Always reads from refs — never from stale closure state
|
|
163
|
+
const getItemPosition = useCallback((id) => {
|
|
164
|
+
const task = localTasksRef.current.find((t) => t.id === id);
|
|
177
165
|
if (task)
|
|
178
166
|
return { x: task.x, y: task.y };
|
|
179
|
-
const doc =
|
|
167
|
+
const doc = localDocumentsRef.current.find((d) => d.id === id);
|
|
180
168
|
if (doc)
|
|
181
169
|
return { x: doc.x, y: doc.y };
|
|
182
170
|
return undefined;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
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 ──────────────────────────────────────────────────
|
|
171
|
+
}, [] // no deps — reads from refs, always fresh
|
|
172
|
+
);
|
|
173
|
+
// ── Selection box ─────────────────────────────────────────────────────────────
|
|
196
174
|
useEffect(() => {
|
|
197
175
|
if (!selectionBox)
|
|
198
176
|
return;
|
|
199
|
-
|
|
177
|
+
const vpt = liveVptRef.current;
|
|
178
|
+
const zoom = vpt[0];
|
|
179
|
+
const vpX = vpt[4];
|
|
180
|
+
const vpY = vpt[5];
|
|
200
181
|
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
|
+
};
|
|
201
194
|
for (const task of localTasks) {
|
|
202
|
-
if (
|
|
195
|
+
if (check(task.x, task.y, 300, 140))
|
|
203
196
|
newSelected.add(task.id);
|
|
204
197
|
}
|
|
205
198
|
for (const doc of localDocuments) {
|
|
206
|
-
if (
|
|
199
|
+
if (check(doc.x, doc.y, 320, 160))
|
|
207
200
|
newSelected.add(doc.id);
|
|
208
201
|
}
|
|
209
|
-
// ── O(n) equality check: size first (fast path), then membership ──
|
|
210
202
|
setSelectedIds((prev) => {
|
|
211
203
|
if (prev.size !== newSelected.size)
|
|
212
204
|
return newSelected;
|
|
213
|
-
for (const id of newSelected)
|
|
205
|
+
for (const id of newSelected)
|
|
214
206
|
if (!prev.has(id))
|
|
215
|
-
return newSelected;
|
|
216
|
-
|
|
217
|
-
return prev; // identical — return same reference, no re-render
|
|
207
|
+
return newSelected;
|
|
208
|
+
return prev;
|
|
218
209
|
});
|
|
219
210
|
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
220
|
-
// ── Drag start
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return e.touches[0];
|
|
211
|
+
// ── Drag start ────────────────────────────────────────────────────────────────
|
|
212
|
+
const getPointerCoords = (e) => {
|
|
213
|
+
if ("touches" in e && e.touches.length > 0)
|
|
214
|
+
return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
|
|
225
215
|
return e;
|
|
226
216
|
};
|
|
227
|
-
const handleDragStart = (itemId, e) => {
|
|
228
|
-
// 1. Safety check for the Fabric instance
|
|
217
|
+
const handleDragStart = useCallback((itemId, e) => {
|
|
229
218
|
const canvas = fabricCanvas?.current;
|
|
230
219
|
if (!canvas)
|
|
231
220
|
return;
|
|
232
|
-
// 2. Normalize the event (Touch vs Mouse)
|
|
233
221
|
if (e.cancelable)
|
|
234
222
|
e.preventDefault();
|
|
235
|
-
const pointer =
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
223
|
+
const pointer = getPointerCoords(e);
|
|
224
|
+
// ✅ Use selectedIdsRef — never the closure's selectedIds
|
|
225
|
+
const currentSelected = selectedIdsRef.current;
|
|
226
|
+
const itemsToDrag = currentSelected.has(itemId)
|
|
227
|
+
? Array.from(currentSelected)
|
|
228
|
+
: [itemId];
|
|
229
|
+
// ✅ Read VPT directly from canvas — zero-frame-lag, not from props
|
|
247
230
|
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
//
|
|
252
|
-
const clickWorldX = (pointer.clientX -
|
|
253
|
-
const clickWorldY = (pointer.clientY -
|
|
254
|
-
//
|
|
231
|
+
const zoom = vpt[0];
|
|
232
|
+
const vpX = vpt[4];
|
|
233
|
+
const vpY = vpt[5];
|
|
234
|
+
// Click position in world space
|
|
235
|
+
const clickWorldX = (pointer.clientX - vpX) / zoom;
|
|
236
|
+
const clickWorldY = (pointer.clientY - vpY) / zoom;
|
|
237
|
+
// ✅ Use getItemPosition which reads from refs
|
|
255
238
|
const clickedPos = getItemPosition(itemId);
|
|
256
239
|
if (!clickedPos)
|
|
257
240
|
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.
|
|
261
241
|
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
262
242
|
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
263
|
-
//
|
|
243
|
+
// Snapshot all dragged items' start positions from refs
|
|
264
244
|
const startPositions = new Map();
|
|
265
|
-
|
|
245
|
+
for (const id of itemsToDrag) {
|
|
266
246
|
const pos = getItemPosition(id);
|
|
267
247
|
if (pos)
|
|
268
|
-
startPositions.set(id, pos);
|
|
269
|
-
}
|
|
270
|
-
//
|
|
248
|
+
startPositions.set(id, { ...pos });
|
|
249
|
+
}
|
|
250
|
+
// Snapshot Fabric objects
|
|
271
251
|
const canvasObjectsStartPos = new Map();
|
|
272
|
-
|
|
273
|
-
canvasObjectsStartPos.set(obj, { left: obj.left
|
|
274
|
-
}
|
|
275
|
-
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
252
|
+
for (const obj of selectedCanvasObjects) {
|
|
253
|
+
canvasObjectsStartPos.set(obj, { left: obj.left ?? 0, top: obj.top ?? 0 });
|
|
254
|
+
}
|
|
276
255
|
dragStateRef.current = {
|
|
277
256
|
isDragging: true,
|
|
278
257
|
itemIds: itemsToDrag,
|
|
279
258
|
startPositions,
|
|
280
259
|
canvasObjectsStartPos,
|
|
281
|
-
offsetX: worldOffsetX,
|
|
282
|
-
offsetY: worldOffsetY,
|
|
260
|
+
offsetX: worldOffsetX,
|
|
261
|
+
offsetY: worldOffsetY,
|
|
283
262
|
};
|
|
284
|
-
|
|
263
|
+
// Select solo item if it wasn't already selected
|
|
264
|
+
if (!currentSelected.has(itemId)) {
|
|
285
265
|
setSelectedIds(new Set([itemId]));
|
|
286
266
|
}
|
|
287
|
-
// 11. Trigger UI states
|
|
288
267
|
setDragging({ itemIds: itemsToDrag });
|
|
289
268
|
document.body.style.cursor = "grabbing";
|
|
290
269
|
document.body.style.userSelect = "none";
|
|
291
270
|
document.body.style.touchAction = "none";
|
|
292
|
-
};
|
|
293
|
-
// ── Drag move
|
|
271
|
+
}, [fabricCanvas, selectedCanvasObjects, getItemPosition]);
|
|
272
|
+
// ── Drag move ─────────────────────────────────────────────────────────────────
|
|
294
273
|
useEffect(() => {
|
|
295
274
|
if (!dragging)
|
|
296
275
|
return;
|
|
297
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
298
276
|
const handleMove = (e) => {
|
|
299
277
|
if (!dragStateRef.current.isDragging)
|
|
300
278
|
return;
|
|
301
279
|
if (e.cancelable)
|
|
302
280
|
e.preventDefault();
|
|
303
|
-
const pointer =
|
|
304
|
-
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
281
|
+
const pointer = getPointerCoords(e);
|
|
305
282
|
if (rafIdRef.current !== null)
|
|
306
283
|
cancelAnimationFrame(rafIdRef.current);
|
|
307
284
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -309,56 +286,53 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
309
286
|
const canvas = fabricCanvas?.current;
|
|
310
287
|
if (!canvas)
|
|
311
288
|
return;
|
|
312
|
-
//
|
|
289
|
+
// ✅ ALWAYS read VPT from canvas — this is the fix for the jump.
|
|
290
|
+
// Props (canvasZoom, canvasViewport) are one React render behind.
|
|
291
|
+
// canvas.viewportTransform is synchronous and always current.
|
|
313
292
|
const vpt = canvas.viewportTransform;
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
//
|
|
318
|
-
const currentWorldX = (pointer.clientX -
|
|
319
|
-
const currentWorldY = (pointer.clientY -
|
|
320
|
-
//
|
|
321
|
-
// (Current Mouse World - Initial World Offset from Start)
|
|
293
|
+
const zoom = vpt[0];
|
|
294
|
+
const vpX = vpt[4];
|
|
295
|
+
const vpY = vpt[5];
|
|
296
|
+
// Mouse in world space
|
|
297
|
+
const currentWorldX = (pointer.clientX - vpX) / zoom;
|
|
298
|
+
const currentWorldY = (pointer.clientY - vpY) / zoom;
|
|
299
|
+
// Where the anchor node's top-left should be
|
|
322
300
|
const newWorldX = currentWorldX - offsetX;
|
|
323
301
|
const newWorldY = currentWorldY - offsetY;
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
const firstId = itemIds[0];
|
|
327
|
-
const firstStart = startPositions.get(firstId);
|
|
302
|
+
// Delta from each item's snapshot position
|
|
303
|
+
const firstStart = startPositions.get(itemIds[0]);
|
|
328
304
|
if (!firstStart)
|
|
329
305
|
return;
|
|
330
306
|
const deltaX = newWorldX - firstStart.x;
|
|
331
307
|
const deltaY = newWorldY - firstStart.y;
|
|
332
|
-
//
|
|
308
|
+
// Update HTML nodes — write-through to ref for handleEnd
|
|
333
309
|
setLocalTasks((prev) => {
|
|
334
310
|
const next = prev.map((t) => itemIds.includes(t.id)
|
|
335
|
-
? {
|
|
336
|
-
|
|
311
|
+
? {
|
|
312
|
+
...t,
|
|
313
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
314
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
315
|
+
}
|
|
337
316
|
: t);
|
|
338
|
-
localTasksRef.current = next;
|
|
317
|
+
localTasksRef.current = next;
|
|
339
318
|
return next;
|
|
340
319
|
});
|
|
341
320
|
setLocalDocuments((prev) => {
|
|
342
321
|
const next = prev.map((d) => itemIds.includes(d.id)
|
|
343
|
-
? {
|
|
344
|
-
|
|
322
|
+
? {
|
|
323
|
+
...d,
|
|
324
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
325
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
326
|
+
}
|
|
345
327
|
: d);
|
|
346
|
-
localDocumentsRef.current = next;
|
|
328
|
+
localDocumentsRef.current = next;
|
|
347
329
|
return next;
|
|
348
330
|
});
|
|
349
|
-
//
|
|
331
|
+
// Sync Fabric objects
|
|
350
332
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
351
|
-
obj.set({
|
|
352
|
-
|
|
353
|
-
top: startPos.top + deltaY,
|
|
354
|
-
});
|
|
355
|
-
obj.setCoords(); // Required for selection/intersection accuracy
|
|
333
|
+
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
334
|
+
obj.setCoords();
|
|
356
335
|
});
|
|
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
|
|
362
336
|
canvas.requestRenderAll();
|
|
363
337
|
});
|
|
364
338
|
};
|
|
@@ -372,9 +346,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
372
346
|
document.body.style.cursor = "";
|
|
373
347
|
document.body.style.userSelect = "";
|
|
374
348
|
document.body.style.touchAction = "";
|
|
375
|
-
// ✅
|
|
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.
|
|
349
|
+
// ✅ Always read from refs — never stale closure values
|
|
378
350
|
onTasksUpdate?.(localTasksRef.current);
|
|
379
351
|
onDocumentsUpdate?.(localDocumentsRef.current);
|
|
380
352
|
};
|
|
@@ -390,46 +362,33 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
390
362
|
window.removeEventListener("touchend", handleEnd);
|
|
391
363
|
window.removeEventListener("touchcancel", handleEnd);
|
|
392
364
|
};
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
};
|
|
365
|
+
// ✅ localTasks/localDocuments removed from deps — read via refs in handleEnd.
|
|
366
|
+
// Only 'dragging' and 'fabricCanvas' needed here.
|
|
367
|
+
}, [dragging, fabricCanvas]);
|
|
368
|
+
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
412
369
|
useEffect(() => {
|
|
413
370
|
const handleKeyDown = (e) => {
|
|
414
|
-
// Don't trigger if typing in input
|
|
415
371
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
416
372
|
return;
|
|
417
|
-
// Select All
|
|
418
373
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
419
374
|
e.preventDefault();
|
|
420
|
-
|
|
375
|
+
// Read from refs — avoids stale closure on localTasks/localDocuments
|
|
376
|
+
setSelectedIds(new Set([
|
|
377
|
+
...localTasksRef.current.map((t) => t.id),
|
|
378
|
+
...localDocumentsRef.current.map((d) => d.id),
|
|
379
|
+
]));
|
|
421
380
|
}
|
|
422
|
-
|
|
423
|
-
if (e.key === "Escape") {
|
|
381
|
+
if (e.key === "Escape")
|
|
424
382
|
setSelectedIds(new Set());
|
|
425
|
-
|
|
426
|
-
// ← ADD THIS: Delete selected nodes
|
|
427
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
383
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
428
384
|
e.preventDefault();
|
|
429
|
-
const
|
|
430
|
-
const
|
|
385
|
+
const sel = selectedIdsRef.current;
|
|
386
|
+
const updatedTasks = localTasksRef.current.filter((t) => !sel.has(t.id));
|
|
387
|
+
const updatedDocs = localDocumentsRef.current.filter((d) => !sel.has(d.id));
|
|
431
388
|
setLocalTasks(updatedTasks);
|
|
432
389
|
setLocalDocuments(updatedDocs);
|
|
390
|
+
localTasksRef.current = updatedTasks;
|
|
391
|
+
localDocumentsRef.current = updatedDocs;
|
|
433
392
|
setSelectedIds(new Set());
|
|
434
393
|
onTasksUpdate?.(updatedTasks);
|
|
435
394
|
onDocumentsUpdate?.(updatedDocs);
|
|
@@ -437,34 +396,63 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
437
396
|
};
|
|
438
397
|
window.addEventListener("keydown", handleKeyDown);
|
|
439
398
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
399
|
+
// ✅ No localTasks/localDocuments/selectedIds in deps — all read via refs
|
|
400
|
+
}, [onTasksUpdate, onDocumentsUpdate]);
|
|
401
|
+
// ── handleSelect / handleStatusChange ─────────────────────────────────────────
|
|
402
|
+
const handleSelect = useCallback((id, e) => {
|
|
403
|
+
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
404
|
+
setSelectedIds((prev) => {
|
|
405
|
+
const next = new Set(prev);
|
|
406
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
407
|
+
return next;
|
|
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;
|
|
449
441
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
450
442
|
left: 0,
|
|
451
443
|
top: 0,
|
|
452
|
-
//
|
|
453
|
-
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${
|
|
444
|
+
// Single transform — no double-transform mismatch possible
|
|
445
|
+
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${zoom})`,
|
|
454
446
|
transformOrigin: "top left",
|
|
455
|
-
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
456
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
457
447
|
transition: "none",
|
|
458
|
-
// 4. Optimization
|
|
459
448
|
willChange: "transform",
|
|
460
|
-
zIndex:
|
|
449
|
+
zIndex: isDraggingThis ? 1000 : 1,
|
|
461
450
|
}, children: children }, id));
|
|
462
451
|
};
|
|
463
|
-
return (
|
|
452
|
+
return (
|
|
453
|
+
// ✅ No wrapper div with viewport transform anymore — it's collapsed into each node
|
|
454
|
+
_jsxs("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
|
|
464
455
|
if (e.target === e.currentTarget)
|
|
465
456
|
setSelectedIds(new Set());
|
|
466
|
-
}, children:
|
|
467
|
-
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
468
|
-
transformOrigin: "top left",
|
|
469
|
-
}, 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 })))] }) }));
|
|
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 })))] }));
|
|
470
458
|
}
|