@mhamz.01/easyflow-whiteboard 2.67.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: [],
|
|
@@ -24,12 +30,24 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
24
30
|
});
|
|
25
31
|
const rafIdRef = useRef(null);
|
|
26
32
|
const overlayRef = useRef(null);
|
|
33
|
+
const localTasksRef = useRef(localTasks);
|
|
34
|
+
const localDocumentsRef = useRef(localDocuments);
|
|
27
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
|
|
28
42
|
selectedIdsRef.current = selectedIds;
|
|
29
|
-
|
|
43
|
+
localTasksRef.current = localTasks;
|
|
44
|
+
localDocumentsRef.current = localDocuments;
|
|
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 ──────────────────────────────────────────────────
|
|
30
48
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
31
49
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
32
|
-
//
|
|
50
|
+
// ── Poll until Fabric canvas is ready ────────────────────────────────────────
|
|
33
51
|
useEffect(() => {
|
|
34
52
|
if (canvasReady)
|
|
35
53
|
return;
|
|
@@ -37,7 +55,6 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
37
55
|
setCanvasReady(true);
|
|
38
56
|
return;
|
|
39
57
|
}
|
|
40
|
-
// Poll every 50ms until canvas is ready (only needed on first load)
|
|
41
58
|
const interval = setInterval(() => {
|
|
42
59
|
if (fabricCanvas?.current) {
|
|
43
60
|
setCanvasReady(true);
|
|
@@ -46,68 +63,46 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
46
63
|
}, 50);
|
|
47
64
|
return () => clearInterval(interval);
|
|
48
65
|
}, [fabricCanvas, canvasReady]);
|
|
49
|
-
// ──
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
scenePoint,
|
|
69
|
-
viewportPoint,
|
|
70
|
-
});
|
|
71
|
-
e.preventDefault();
|
|
72
|
-
e.stopPropagation();
|
|
73
|
-
}
|
|
74
|
-
};
|
|
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 ───────────────────────────────
|
|
75
85
|
useEffect(() => {
|
|
76
86
|
const overlayEl = overlayRef.current;
|
|
77
87
|
const canvas = fabricCanvas?.current;
|
|
78
88
|
if (!overlayEl || !canvas)
|
|
79
89
|
return;
|
|
80
90
|
const handleGlobalWheel = (e) => {
|
|
81
|
-
// Check if the user is hovering over an element that has pointer-events: auto
|
|
82
|
-
// (meaning they are hovering over a Task or Document)
|
|
83
91
|
const target = e.target;
|
|
84
92
|
const isOverNode = target !== overlayEl;
|
|
85
93
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
86
|
-
// 1. Prevent Browser Zoom immediately
|
|
87
94
|
e.preventDefault();
|
|
88
95
|
e.stopPropagation();
|
|
89
|
-
// 2. Calculate coordinates for Fabric
|
|
90
96
|
const scenePoint = canvas.getScenePoint(e);
|
|
91
97
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
92
|
-
const viewportPoint = {
|
|
93
|
-
|
|
94
|
-
y: e.clientY - rect.top,
|
|
95
|
-
};
|
|
96
|
-
// 3. Manually fire the event into Fabric
|
|
97
|
-
canvas.fire("mouse:wheel", {
|
|
98
|
-
e: e,
|
|
99
|
-
scenePoint,
|
|
100
|
-
viewportPoint,
|
|
101
|
-
});
|
|
98
|
+
const viewportPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
99
|
+
canvas.fire("mouse:wheel", { e, scenePoint, viewportPoint });
|
|
102
100
|
}
|
|
103
101
|
};
|
|
104
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
105
102
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
106
|
-
return () =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}, [fabricCanvas, canvasZoom, canvasReady]); // Re-bind when zoom changes to keep closure fresh
|
|
110
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
103
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
104
|
+
}, [fabricCanvas, canvasReady]);
|
|
105
|
+
// ── Fabric object:moving → sync HTML nodes ────────────────────────────────────
|
|
111
106
|
useEffect(() => {
|
|
112
107
|
const canvas = fabricCanvas?.current;
|
|
113
108
|
if (!canvas)
|
|
@@ -122,10 +117,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
122
117
|
target._prevTop = target.top;
|
|
123
118
|
if (deltaX === 0 && deltaY === 0)
|
|
124
119
|
return;
|
|
125
|
-
// ── Read from ref — always fresh, never stale ──
|
|
126
120
|
const sel = selectedIdsRef.current;
|
|
127
|
-
setLocalTasks((prev) =>
|
|
128
|
-
|
|
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
|
+
});
|
|
129
131
|
};
|
|
130
132
|
const handleMouseDown = (e) => {
|
|
131
133
|
const target = e.target;
|
|
@@ -137,22 +139,16 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
137
139
|
setSelectedIds(new Set());
|
|
138
140
|
return;
|
|
139
141
|
}
|
|
140
|
-
// At zoom=1 with identity VPT, getActiveObject() can return null before
|
|
141
|
-
// Fabric updates _activeObject. Use e.transform as the primary check —
|
|
142
|
-
// it is populated by Fabric's hit-test regardless of zoom level.
|
|
143
|
-
const transformTarget = e.transform?.target;
|
|
144
142
|
const activeObject = canvas.getActiveObject();
|
|
145
143
|
const activeObjects = canvas.getActiveObjects();
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
const transformTarget = e.transform?.target;
|
|
145
|
+
const isPartOfActiveSelection = transformTarget === target ||
|
|
146
|
+
activeObject === target ||
|
|
147
|
+
activeObjects.includes(target);
|
|
148
|
+
if (!isPartOfActiveSelection)
|
|
150
149
|
setSelectedIds(new Set());
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
const handleSelectionCleared = () => {
|
|
154
|
-
setSelectedIds(new Set());
|
|
155
150
|
};
|
|
151
|
+
const handleSelectionCleared = () => setSelectedIds(new Set());
|
|
156
152
|
canvas.on("object:moving", handleObjectMoving);
|
|
157
153
|
canvas.on("mouse:down", handleMouseDown);
|
|
158
154
|
canvas.on("selection:cleared", handleSelectionCleared);
|
|
@@ -161,100 +157,101 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
161
157
|
canvas.off("mouse:down", handleMouseDown);
|
|
162
158
|
canvas.off("selection:cleared", handleSelectionCleared);
|
|
163
159
|
};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
170
|
-
const getItemPosition = (id) => {
|
|
171
|
-
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);
|
|
172
165
|
if (task)
|
|
173
166
|
return { x: task.x, y: task.y };
|
|
174
|
-
const doc =
|
|
167
|
+
const doc = localDocumentsRef.current.find((d) => d.id === id);
|
|
175
168
|
if (doc)
|
|
176
169
|
return { x: doc.x, y: doc.y };
|
|
177
170
|
return undefined;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
182
|
-
const itemX2 = itemX1 + width * canvasZoom;
|
|
183
|
-
const itemY2 = itemY1 + height * canvasZoom;
|
|
184
|
-
const boxX1 = Math.min(box.x1, box.x2);
|
|
185
|
-
const boxY1 = Math.min(box.y1, box.y2);
|
|
186
|
-
const boxX2 = Math.max(box.x1, box.x2);
|
|
187
|
-
const boxY2 = Math.max(box.y1, box.y2);
|
|
188
|
-
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
189
|
-
};
|
|
190
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
171
|
+
}, [] // no deps — reads from refs, always fresh
|
|
172
|
+
);
|
|
173
|
+
// ── Selection box ─────────────────────────────────────────────────────────────
|
|
191
174
|
useEffect(() => {
|
|
192
175
|
if (!selectionBox)
|
|
193
176
|
return;
|
|
194
|
-
|
|
177
|
+
const vpt = liveVptRef.current;
|
|
178
|
+
const zoom = vpt[0];
|
|
179
|
+
const vpX = vpt[4];
|
|
180
|
+
const vpY = vpt[5];
|
|
195
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
|
+
};
|
|
196
194
|
for (const task of localTasks) {
|
|
197
|
-
if (
|
|
195
|
+
if (check(task.x, task.y, 300, 140))
|
|
198
196
|
newSelected.add(task.id);
|
|
199
197
|
}
|
|
200
198
|
for (const doc of localDocuments) {
|
|
201
|
-
if (
|
|
199
|
+
if (check(doc.x, doc.y, 320, 160))
|
|
202
200
|
newSelected.add(doc.id);
|
|
203
201
|
}
|
|
204
|
-
// ── O(n) equality check: size first (fast path), then membership ──
|
|
205
202
|
setSelectedIds((prev) => {
|
|
206
203
|
if (prev.size !== newSelected.size)
|
|
207
204
|
return newSelected;
|
|
208
|
-
for (const id of newSelected)
|
|
205
|
+
for (const id of newSelected)
|
|
209
206
|
if (!prev.has(id))
|
|
210
|
-
return newSelected;
|
|
211
|
-
|
|
212
|
-
return prev; // identical — return same reference, no re-render
|
|
207
|
+
return newSelected;
|
|
208
|
+
return prev;
|
|
213
209
|
});
|
|
214
210
|
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
215
|
-
// ── Drag start
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
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 };
|
|
220
215
|
return e;
|
|
221
216
|
};
|
|
222
|
-
const handleDragStart = (itemId, e) => {
|
|
217
|
+
const handleDragStart = useCallback((itemId, e) => {
|
|
223
218
|
const canvas = fabricCanvas?.current;
|
|
224
219
|
if (!canvas)
|
|
225
220
|
return;
|
|
226
221
|
if (e.cancelable)
|
|
227
222
|
e.preventDefault();
|
|
228
|
-
const pointer =
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
setSelectedIds(new Set([itemId]));
|
|
236
|
-
}
|
|
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
|
|
237
230
|
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
const
|
|
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
|
|
243
238
|
const clickedPos = getItemPosition(itemId);
|
|
244
239
|
if (!clickedPos)
|
|
245
240
|
return;
|
|
246
241
|
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
247
242
|
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
243
|
+
// Snapshot all dragged items' start positions from refs
|
|
248
244
|
const startPositions = new Map();
|
|
249
|
-
|
|
245
|
+
for (const id of itemsToDrag) {
|
|
250
246
|
const pos = getItemPosition(id);
|
|
251
247
|
if (pos)
|
|
252
|
-
startPositions.set(id, pos);
|
|
253
|
-
}
|
|
248
|
+
startPositions.set(id, { ...pos });
|
|
249
|
+
}
|
|
250
|
+
// Snapshot Fabric objects
|
|
254
251
|
const canvasObjectsStartPos = new Map();
|
|
255
|
-
|
|
256
|
-
canvasObjectsStartPos.set(obj, { left: obj.left
|
|
257
|
-
}
|
|
252
|
+
for (const obj of selectedCanvasObjects) {
|
|
253
|
+
canvasObjectsStartPos.set(obj, { left: obj.left ?? 0, top: obj.top ?? 0 });
|
|
254
|
+
}
|
|
258
255
|
dragStateRef.current = {
|
|
259
256
|
isDragging: true,
|
|
260
257
|
itemIds: itemsToDrag,
|
|
@@ -263,23 +260,25 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
263
260
|
offsetX: worldOffsetX,
|
|
264
261
|
offsetY: worldOffsetY,
|
|
265
262
|
};
|
|
263
|
+
// Select solo item if it wasn't already selected
|
|
264
|
+
if (!currentSelected.has(itemId)) {
|
|
265
|
+
setSelectedIds(new Set([itemId]));
|
|
266
|
+
}
|
|
266
267
|
setDragging({ itemIds: itemsToDrag });
|
|
267
|
-
// ── ATTACH LISTENERS DIRECTLY ──
|
|
268
268
|
document.body.style.cursor = "grabbing";
|
|
269
269
|
document.body.style.userSelect = "none";
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
document.body.style.touchAction = "none";
|
|
271
|
+
}, [fabricCanvas, selectedCanvasObjects, getItemPosition]);
|
|
272
|
+
// ── Drag move ─────────────────────────────────────────────────────────────────
|
|
272
273
|
useEffect(() => {
|
|
273
274
|
if (!dragging)
|
|
274
275
|
return;
|
|
275
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
276
276
|
const handleMove = (e) => {
|
|
277
277
|
if (!dragStateRef.current.isDragging)
|
|
278
278
|
return;
|
|
279
279
|
if (e.cancelable)
|
|
280
280
|
e.preventDefault();
|
|
281
|
-
const pointer =
|
|
282
|
-
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
281
|
+
const pointer = getPointerCoords(e);
|
|
283
282
|
if (rafIdRef.current !== null)
|
|
284
283
|
cancelAnimationFrame(rafIdRef.current);
|
|
285
284
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -287,64 +286,69 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
287
286
|
const canvas = fabricCanvas?.current;
|
|
288
287
|
if (!canvas)
|
|
289
288
|
return;
|
|
290
|
-
//
|
|
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.
|
|
291
292
|
const vpt = canvas.viewportTransform;
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
//
|
|
296
|
-
const currentWorldX = (pointer.clientX -
|
|
297
|
-
const currentWorldY = (pointer.clientY -
|
|
298
|
-
//
|
|
299
|
-
// (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
|
|
300
300
|
const newWorldX = currentWorldX - offsetX;
|
|
301
301
|
const newWorldY = currentWorldY - offsetY;
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
const firstId = itemIds[0];
|
|
305
|
-
const firstStart = startPositions.get(firstId);
|
|
302
|
+
// Delta from each item's snapshot position
|
|
303
|
+
const firstStart = startPositions.get(itemIds[0]);
|
|
306
304
|
if (!firstStart)
|
|
307
305
|
return;
|
|
308
306
|
const deltaX = newWorldX - firstStart.x;
|
|
309
307
|
const deltaY = newWorldY - firstStart.y;
|
|
310
|
-
//
|
|
311
|
-
setLocalTasks((prev) =>
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
308
|
+
// Update HTML nodes — write-through to ref for handleEnd
|
|
309
|
+
setLocalTasks((prev) => {
|
|
310
|
+
const next = prev.map((t) => itemIds.includes(t.id)
|
|
311
|
+
? {
|
|
312
|
+
...t,
|
|
313
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
314
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
315
|
+
}
|
|
316
|
+
: t);
|
|
317
|
+
localTasksRef.current = next;
|
|
318
|
+
return next;
|
|
319
|
+
});
|
|
320
|
+
setLocalDocuments((prev) => {
|
|
321
|
+
const next = prev.map((d) => itemIds.includes(d.id)
|
|
322
|
+
? {
|
|
323
|
+
...d,
|
|
324
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
325
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
326
|
+
}
|
|
327
|
+
: d);
|
|
328
|
+
localDocumentsRef.current = next;
|
|
329
|
+
return next;
|
|
330
|
+
});
|
|
331
|
+
// Sync Fabric objects
|
|
322
332
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
323
|
-
obj.set({
|
|
324
|
-
|
|
325
|
-
top: startPos.top + deltaY,
|
|
326
|
-
});
|
|
327
|
-
obj.setCoords(); // Required for selection/intersection accuracy
|
|
333
|
+
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
334
|
+
obj.setCoords();
|
|
328
335
|
});
|
|
329
|
-
// 8. Single render call for all Fabric changes
|
|
330
336
|
canvas.requestRenderAll();
|
|
331
337
|
});
|
|
332
338
|
};
|
|
333
339
|
const handleEnd = () => {
|
|
334
|
-
if (rafIdRef.current !== null)
|
|
340
|
+
if (rafIdRef.current !== null) {
|
|
335
341
|
cancelAnimationFrame(rafIdRef.current);
|
|
336
|
-
|
|
342
|
+
rafIdRef.current = null;
|
|
343
|
+
}
|
|
337
344
|
dragStateRef.current.isDragging = false;
|
|
338
345
|
setDragging(null);
|
|
339
|
-
// 2. Remove the listeners immediately
|
|
340
|
-
window.removeEventListener("mousemove", handleMove);
|
|
341
|
-
window.removeEventListener("mouseup", handleEnd);
|
|
342
|
-
window.removeEventListener("touchmove", handleMove);
|
|
343
|
-
window.removeEventListener("touchend", handleEnd);
|
|
344
346
|
document.body.style.cursor = "";
|
|
345
347
|
document.body.style.userSelect = "";
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
document.body.style.touchAction = "";
|
|
349
|
+
// ✅ Always read from refs — never stale closure values
|
|
350
|
+
onTasksUpdate?.(localTasksRef.current);
|
|
351
|
+
onDocumentsUpdate?.(localDocumentsRef.current);
|
|
348
352
|
};
|
|
349
353
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
350
354
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -358,46 +362,33 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
358
362
|
window.removeEventListener("touchend", handleEnd);
|
|
359
363
|
window.removeEventListener("touchcancel", handleEnd);
|
|
360
364
|
};
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
setSelectedIds((prev) => {
|
|
366
|
-
const next = new Set(prev);
|
|
367
|
-
next.has(id) ? next.delete(id) : next.add(id);
|
|
368
|
-
return next;
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
setSelectedIds(new Set([id]));
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
376
|
-
const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
|
|
377
|
-
setLocalTasks(updated);
|
|
378
|
-
onTasksUpdate?.(updated);
|
|
379
|
-
};
|
|
365
|
+
// ✅ localTasks/localDocuments removed from deps — read via refs in handleEnd.
|
|
366
|
+
// Only 'dragging' and 'fabricCanvas' needed here.
|
|
367
|
+
}, [dragging, fabricCanvas]);
|
|
368
|
+
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
380
369
|
useEffect(() => {
|
|
381
370
|
const handleKeyDown = (e) => {
|
|
382
|
-
// Don't trigger if typing in input
|
|
383
371
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
384
372
|
return;
|
|
385
|
-
// Select All
|
|
386
373
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
387
374
|
e.preventDefault();
|
|
388
|
-
|
|
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
|
+
]));
|
|
389
380
|
}
|
|
390
|
-
|
|
391
|
-
if (e.key === "Escape") {
|
|
381
|
+
if (e.key === "Escape")
|
|
392
382
|
setSelectedIds(new Set());
|
|
393
|
-
|
|
394
|
-
// ← ADD THIS: Delete selected nodes
|
|
395
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
383
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
396
384
|
e.preventDefault();
|
|
397
|
-
const
|
|
398
|
-
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));
|
|
399
388
|
setLocalTasks(updatedTasks);
|
|
400
389
|
setLocalDocuments(updatedDocs);
|
|
390
|
+
localTasksRef.current = updatedTasks;
|
|
391
|
+
localDocumentsRef.current = updatedDocs;
|
|
401
392
|
setSelectedIds(new Set());
|
|
402
393
|
onTasksUpdate?.(updatedTasks);
|
|
403
394
|
onDocumentsUpdate?.(updatedDocs);
|
|
@@ -405,34 +396,63 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
405
396
|
};
|
|
406
397
|
window.addEventListener("keydown", handleKeyDown);
|
|
407
398
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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;
|
|
417
441
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
418
442
|
left: 0,
|
|
419
443
|
top: 0,
|
|
420
|
-
//
|
|
421
|
-
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})`,
|
|
422
446
|
transformOrigin: "top left",
|
|
423
|
-
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
424
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
425
447
|
transition: "none",
|
|
426
|
-
// 4. Optimization
|
|
427
448
|
willChange: "transform",
|
|
428
|
-
zIndex:
|
|
449
|
+
zIndex: isDraggingThis ? 1000 : 1,
|
|
429
450
|
}, children: children }, id));
|
|
430
451
|
};
|
|
431
|
-
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) => {
|
|
432
455
|
if (e.target === e.currentTarget)
|
|
433
456
|
setSelectedIds(new Set());
|
|
434
|
-
}, children:
|
|
435
|
-
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
436
|
-
transformOrigin: "top left",
|
|
437
|
-
}, 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 })))] }));
|
|
438
458
|
}
|