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