@mhamz.01/easyflow-whiteboard 2.9.0 → 2.10.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;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;CAC/C;
|
|
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;CAC/C;AAeD,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,2CA2bzB"}
|
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
@@ -8,7 +8,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
8
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
9
9
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
10
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
|
-
|
|
11
|
+
// ── Single drag state ref — no useState for dragging ──────────────────────
|
|
12
|
+
// This is the core fix: dragging state lives ONLY in a ref, never triggers
|
|
13
|
+
// re-renders, so the mousemove/mouseup listeners are NEVER torn down mid-drag.
|
|
12
14
|
const dragStateRef = useRef({
|
|
13
15
|
isDragging: false,
|
|
14
16
|
itemIds: [],
|
|
@@ -17,92 +19,69 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
17
19
|
offsetX: 0,
|
|
18
20
|
offsetY: 0,
|
|
19
21
|
});
|
|
22
|
+
// Tracks whether an HTML node drag is active — blocks object:moving sync
|
|
23
|
+
const isHtmlDraggingRef = useRef(false);
|
|
20
24
|
const rafIdRef = useRef(null);
|
|
21
25
|
const overlayRef = useRef(null);
|
|
22
|
-
// ──
|
|
26
|
+
// ── Positions ref — synchronously updated every render, zero staleness ────
|
|
27
|
+
// Avoids stale closure problem in handleDragStart reading localTasks state
|
|
28
|
+
const nodePositionsRef = useRef(new Map());
|
|
29
|
+
nodePositionsRef.current = new Map([
|
|
30
|
+
...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
|
|
31
|
+
...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
|
|
32
|
+
]);
|
|
33
|
+
// ── selectedIds ref — so object:moving closure always has fresh selectedIds ─
|
|
34
|
+
const selectedIdsRef = useRef(selectedIds);
|
|
35
|
+
selectedIdsRef.current = selectedIds;
|
|
36
|
+
// ── Sync props → local state ───────────────────────────────────────────────
|
|
23
37
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
24
38
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
25
|
-
// ──
|
|
26
|
-
const handleOverlayWheel = (e) => {
|
|
27
|
-
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
28
|
-
const canvas = fabricCanvas?.current;
|
|
29
|
-
if (!canvas)
|
|
30
|
-
return;
|
|
31
|
-
const nativeEvent = e.nativeEvent;
|
|
32
|
-
// getScenePoint handles the transformation from screen to canvas space
|
|
33
|
-
const scenePoint = canvas.getScenePoint(nativeEvent);
|
|
34
|
-
// Viewport point is simply the mouse position relative to the canvas element
|
|
35
|
-
const rect = canvas.getElement().getBoundingClientRect();
|
|
36
|
-
const viewportPoint = {
|
|
37
|
-
x: nativeEvent.clientX - rect.left,
|
|
38
|
-
y: nativeEvent.clientY - rect.top,
|
|
39
|
-
};
|
|
40
|
-
// We cast to 'any' here because we are manually triggering an internal
|
|
41
|
-
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
42
|
-
canvas.fire("mouse:wheel", {
|
|
43
|
-
e: nativeEvent,
|
|
44
|
-
scenePoint,
|
|
45
|
-
viewportPoint,
|
|
46
|
-
});
|
|
47
|
-
e.preventDefault();
|
|
48
|
-
e.stopPropagation();
|
|
49
|
-
}
|
|
50
|
-
};
|
|
39
|
+
// ── Wheel forwarding to Fabric ─────────────────────────────────────────────
|
|
51
40
|
useEffect(() => {
|
|
52
41
|
const overlayEl = overlayRef.current;
|
|
53
42
|
const canvas = fabricCanvas?.current;
|
|
54
43
|
if (!overlayEl || !canvas)
|
|
55
44
|
return;
|
|
56
45
|
const handleGlobalWheel = (e) => {
|
|
57
|
-
// Check if the user is hovering over an element that has pointer-events: auto
|
|
58
|
-
// (meaning they are hovering over a Task or Document)
|
|
59
46
|
const target = e.target;
|
|
60
47
|
const isOverNode = target !== overlayEl;
|
|
61
48
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
62
|
-
// 1. Prevent Browser Zoom immediately
|
|
63
49
|
e.preventDefault();
|
|
64
50
|
e.stopPropagation();
|
|
65
|
-
// 2. Calculate coordinates for Fabric
|
|
66
51
|
const scenePoint = canvas.getScenePoint(e);
|
|
67
52
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
68
|
-
const viewportPoint = {
|
|
69
|
-
x: e.clientX - rect.left,
|
|
70
|
-
y: e.clientY - rect.top,
|
|
71
|
-
};
|
|
72
|
-
// 3. Manually fire the event into Fabric
|
|
73
53
|
canvas.fire("mouse:wheel", {
|
|
74
|
-
e
|
|
54
|
+
e,
|
|
75
55
|
scenePoint,
|
|
76
|
-
viewportPoint,
|
|
56
|
+
viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
77
57
|
});
|
|
78
58
|
}
|
|
79
59
|
};
|
|
80
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
81
60
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
82
|
-
return () =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
|
|
86
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
61
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
62
|
+
}, [fabricCanvas]);
|
|
63
|
+
// ── Fabric → Overlay sync (drag Fabric obj, HTML nodes follow) ────────────
|
|
87
64
|
useEffect(() => {
|
|
88
65
|
const canvas = fabricCanvas?.current;
|
|
89
66
|
if (!canvas)
|
|
90
67
|
return;
|
|
91
68
|
const handleObjectMoving = (e) => {
|
|
69
|
+
// CRITICAL: Skip entirely during HTML node drag — prevents position fighting
|
|
70
|
+
if (isHtmlDraggingRef.current)
|
|
71
|
+
return;
|
|
92
72
|
const target = e.transform?.target || e.target;
|
|
93
73
|
if (!target)
|
|
94
74
|
return;
|
|
95
|
-
// 1. Calculate delta in raw Scene Coordinates
|
|
96
|
-
// We do NOT divide by zoom here because target.left/top are world units.
|
|
97
75
|
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
98
76
|
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
99
77
|
target._prevLeft = target.left;
|
|
100
78
|
target._prevTop = target.top;
|
|
101
79
|
if (deltaX === 0 && deltaY === 0)
|
|
102
80
|
return;
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
// Use selectedIdsRef — always fresh, no stale closure
|
|
82
|
+
const currentSelected = selectedIdsRef.current;
|
|
83
|
+
setLocalTasks((prev) => prev.map((t) => currentSelected.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
84
|
+
setLocalDocuments((prev) => prev.map((d) => currentSelected.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
106
85
|
};
|
|
107
86
|
const handleMouseDown = (e) => {
|
|
108
87
|
const target = e.target;
|
|
@@ -117,17 +96,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
117
96
|
canvas.off("object:moving", handleObjectMoving);
|
|
118
97
|
canvas.off("mouse:down", handleMouseDown);
|
|
119
98
|
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const task = localTasks.find((t) => t.id === id);
|
|
124
|
-
if (task)
|
|
125
|
-
return { x: task.x, y: task.y };
|
|
126
|
-
const doc = localDocuments.find((d) => d.id === id);
|
|
127
|
-
if (doc)
|
|
128
|
-
return { x: doc.x, y: doc.y };
|
|
129
|
-
return undefined;
|
|
130
|
-
};
|
|
99
|
+
// No selectedIds in deps — we use selectedIdsRef instead. Stable registration.
|
|
100
|
+
}, [fabricCanvas]);
|
|
101
|
+
// ── Selection box hit detection ────────────────────────────────────────────
|
|
131
102
|
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
132
103
|
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
133
104
|
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
@@ -139,11 +110,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
139
110
|
const boxY2 = Math.max(box.y1, box.y2);
|
|
140
111
|
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
141
112
|
};
|
|
142
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
143
113
|
useEffect(() => {
|
|
144
114
|
if (!selectionBox)
|
|
145
115
|
return;
|
|
146
|
-
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
147
116
|
const newSelected = new Set();
|
|
148
117
|
for (const task of localTasks) {
|
|
149
118
|
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
@@ -153,116 +122,64 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
153
122
|
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
154
123
|
newSelected.add(doc.id);
|
|
155
124
|
}
|
|
156
|
-
//
|
|
125
|
+
// O(n) equality — return same ref if unchanged, prevents re-render
|
|
157
126
|
setSelectedIds((prev) => {
|
|
158
127
|
if (prev.size !== newSelected.size)
|
|
159
128
|
return newSelected;
|
|
160
|
-
for (const id of newSelected)
|
|
129
|
+
for (const id of newSelected)
|
|
161
130
|
if (!prev.has(id))
|
|
162
|
-
return newSelected;
|
|
163
|
-
|
|
164
|
-
return prev; // identical — return same reference, no re-render
|
|
131
|
+
return newSelected;
|
|
132
|
+
return prev;
|
|
165
133
|
});
|
|
166
134
|
}, [selectionBox, localTasks, localDocuments]);
|
|
167
|
-
// ──
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return e;
|
|
173
|
-
};
|
|
174
|
-
const handleDragStart = (itemId, e) => {
|
|
175
|
-
const canvas = fabricCanvas?.current;
|
|
176
|
-
if (!canvas || !e)
|
|
177
|
-
return;
|
|
178
|
-
// Use the raw native event for the most accurate pointer position
|
|
179
|
-
const nativeEvent = 'nativeEvent' in e ? e.nativeEvent : e;
|
|
180
|
-
const pointer = getPointerEvent(nativeEvent);
|
|
181
|
-
// 1. Get the ABSOLUTE current transform from the canvas engine
|
|
182
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
183
|
-
const zoom = vpt[0];
|
|
184
|
-
const vpX = vpt[4];
|
|
185
|
-
const vpY = vpt[5];
|
|
186
|
-
// 2. Calculate World Position of the mouse click immediately
|
|
187
|
-
const clickWorldX = (pointer.clientX - vpX) / zoom;
|
|
188
|
-
const clickWorldY = (pointer.clientY - vpY) / zoom;
|
|
189
|
-
// 3. Get the item's current world position
|
|
190
|
-
const clickedPos = getItemPosition(itemId);
|
|
191
|
-
if (!clickedPos)
|
|
192
|
-
return;
|
|
193
|
-
// 4. Calculate the WORLD OFFSET
|
|
194
|
-
// This is the distance from the mouse to the node's top-left in World Units.
|
|
195
|
-
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
196
|
-
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
197
|
-
// 5. Setup Drag State
|
|
198
|
-
let itemsToDrag = selectedIds.has(itemId) ? Array.from(selectedIds) : [itemId];
|
|
199
|
-
if (!selectedIds.has(itemId))
|
|
200
|
-
setSelectedIds(new Set([itemId]));
|
|
201
|
-
const startPositions = new Map();
|
|
202
|
-
itemsToDrag.forEach(id => {
|
|
203
|
-
const pos = getItemPosition(id);
|
|
204
|
-
if (pos)
|
|
205
|
-
startPositions.set(id, pos);
|
|
206
|
-
});
|
|
207
|
-
const canvasObjectsStartPos = new Map();
|
|
208
|
-
selectedCanvasObjects.forEach(obj => {
|
|
209
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
210
|
-
});
|
|
211
|
-
dragStateRef.current = {
|
|
212
|
-
isDragging: true,
|
|
213
|
-
itemIds: itemsToDrag,
|
|
214
|
-
startPositions,
|
|
215
|
-
canvasObjectsStartPos,
|
|
216
|
-
offsetX: worldOffsetX,
|
|
217
|
-
offsetY: worldOffsetY,
|
|
218
|
-
};
|
|
219
|
-
setDragging({ itemIds: itemsToDrag });
|
|
220
|
-
};
|
|
221
|
-
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
135
|
+
// ── Global drag listeners — attached ONCE, never torn down mid-drag ────────
|
|
136
|
+
// This is the core architectural fix. Instead of putting listeners inside a
|
|
137
|
+
// useEffect that watches [dragging], we attach them ONCE on mount and read
|
|
138
|
+
// dragStateRef inside them. This means there is zero listener churn,
|
|
139
|
+
// zero double-fires, and zero stale closure issues.
|
|
222
140
|
useEffect(() => {
|
|
223
|
-
if (!dragging)
|
|
224
|
-
return;
|
|
225
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
226
141
|
const handleMove = (e) => {
|
|
227
142
|
if (!dragStateRef.current.isDragging)
|
|
228
143
|
return;
|
|
229
144
|
if (e.cancelable)
|
|
230
145
|
e.preventDefault();
|
|
231
|
-
const pointer =
|
|
232
|
-
|
|
146
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
147
|
+
? e.touches[0]
|
|
148
|
+
: e;
|
|
233
149
|
if (rafIdRef.current !== null)
|
|
234
150
|
cancelAnimationFrame(rafIdRef.current);
|
|
235
|
-
// Inside handleMove rAF
|
|
236
151
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
237
152
|
const { itemIds, startPositions, canvasObjectsStartPos, offsetX, offsetY } = dragStateRef.current;
|
|
238
153
|
const canvas = fabricCanvas?.current;
|
|
239
154
|
if (!canvas)
|
|
240
155
|
return;
|
|
156
|
+
// Read live VPT — frame-perfect, no React state lag
|
|
241
157
|
const vpt = canvas.viewportTransform;
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
//
|
|
246
|
-
const currentWorldX = (pointer.clientX -
|
|
247
|
-
const currentWorldY = (pointer.clientY -
|
|
248
|
-
//
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
const firstStart = startPositions.get(
|
|
158
|
+
const liveZoom = vpt[0];
|
|
159
|
+
const liveVpX = vpt[4];
|
|
160
|
+
const liveVpY = vpt[5];
|
|
161
|
+
// Screen → World
|
|
162
|
+
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
163
|
+
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
164
|
+
// Subtract world-space offset → exact world position of node top-left
|
|
165
|
+
const newWorldX = currentWorldX - offsetX;
|
|
166
|
+
const newWorldY = currentWorldY - offsetY;
|
|
167
|
+
// Delta from snapshotted start position of the anchor (first) item
|
|
168
|
+
const firstStart = startPositions.get(itemIds[0]);
|
|
253
169
|
if (!firstStart)
|
|
254
170
|
return;
|
|
255
|
-
const deltaX =
|
|
256
|
-
const deltaY =
|
|
257
|
-
|
|
258
|
-
if (Math.abs(deltaX) < 0.01 && Math.abs(deltaY) < 0.01)
|
|
259
|
-
return;
|
|
260
|
-
setLocalTasks(prev => prev.map(t => itemIds.includes(t.id) ? {
|
|
171
|
+
const deltaX = newWorldX - firstStart.x;
|
|
172
|
+
const deltaY = newWorldY - firstStart.y;
|
|
173
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
|
|
261
174
|
...t,
|
|
262
175
|
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
263
176
|
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
264
177
|
} : t));
|
|
265
|
-
|
|
178
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
|
|
179
|
+
...d,
|
|
180
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
181
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
182
|
+
} : d));
|
|
266
183
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
267
184
|
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
268
185
|
obj.setCoords();
|
|
@@ -270,16 +187,21 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
270
187
|
canvas.requestRenderAll();
|
|
271
188
|
});
|
|
272
189
|
};
|
|
273
|
-
const handleEnd = () => {
|
|
274
|
-
if (
|
|
190
|
+
const handleEnd = (e) => {
|
|
191
|
+
if (!dragStateRef.current.isDragging)
|
|
192
|
+
return;
|
|
193
|
+
if (rafIdRef.current !== null) {
|
|
275
194
|
cancelAnimationFrame(rafIdRef.current);
|
|
195
|
+
rafIdRef.current = null;
|
|
196
|
+
}
|
|
276
197
|
dragStateRef.current.isDragging = false;
|
|
277
|
-
|
|
198
|
+
isHtmlDraggingRef.current = false;
|
|
278
199
|
document.body.style.cursor = "";
|
|
279
200
|
document.body.style.userSelect = "";
|
|
280
201
|
document.body.style.touchAction = "";
|
|
281
|
-
|
|
282
|
-
|
|
202
|
+
// Flush final positions to parent
|
|
203
|
+
setLocalTasks((prev) => { onTasksUpdate?.(prev); return prev; });
|
|
204
|
+
setLocalDocuments((prev) => { onDocumentsUpdate?.(prev); return prev; });
|
|
283
205
|
};
|
|
284
206
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
285
207
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -293,9 +215,72 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
293
215
|
window.removeEventListener("touchend", handleEnd);
|
|
294
216
|
window.removeEventListener("touchcancel", handleEnd);
|
|
295
217
|
};
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
218
|
+
// Empty deps — attached once on mount, reads everything via refs
|
|
219
|
+
}, [fabricCanvas, onTasksUpdate, onDocumentsUpdate]);
|
|
220
|
+
// ── Drag start ─────────────────────────────────────────────────────────────
|
|
221
|
+
const handleDragStart = useCallback((itemId, e) => {
|
|
222
|
+
if (e.cancelable)
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
const canvas = fabricCanvas?.current;
|
|
225
|
+
if (!canvas)
|
|
226
|
+
return;
|
|
227
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
228
|
+
? e.touches[0]
|
|
229
|
+
: e;
|
|
230
|
+
// Determine drag group
|
|
231
|
+
const currentSelected = selectedIdsRef.current;
|
|
232
|
+
let itemsToDrag;
|
|
233
|
+
if (currentSelected.has(itemId)) {
|
|
234
|
+
itemsToDrag = Array.from(currentSelected);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
itemsToDrag = [itemId];
|
|
238
|
+
setSelectedIds(new Set([itemId]));
|
|
239
|
+
}
|
|
240
|
+
// Read VPT live — zero lag
|
|
241
|
+
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
242
|
+
const liveZoom = vpt[0];
|
|
243
|
+
const liveVpX = vpt[4];
|
|
244
|
+
const liveVpY = vpt[5];
|
|
245
|
+
// Convert pointer → world
|
|
246
|
+
const pointerWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
247
|
+
const pointerWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
248
|
+
// Read positions from ref — synchronously updated every render, NEVER stale
|
|
249
|
+
const clickedPos = nodePositionsRef.current.get(itemId);
|
|
250
|
+
if (!clickedPos)
|
|
251
|
+
return;
|
|
252
|
+
// World-space offset: how far pointer is from node's top-left, in world units
|
|
253
|
+
const worldOffsetX = pointerWorldX - clickedPos.x;
|
|
254
|
+
const worldOffsetY = pointerWorldY - clickedPos.y;
|
|
255
|
+
// Snapshot all start positions from ref (not state — avoids async lag)
|
|
256
|
+
const startPositions = new Map();
|
|
257
|
+
for (const id of itemsToDrag) {
|
|
258
|
+
const pos = nodePositionsRef.current.get(id);
|
|
259
|
+
if (pos)
|
|
260
|
+
startPositions.set(id, { x: pos.x, y: pos.y });
|
|
261
|
+
}
|
|
262
|
+
// Snapshot Fabric object positions
|
|
263
|
+
const canvasObjectsStartPos = new Map();
|
|
264
|
+
selectedCanvasObjects.forEach((obj) => {
|
|
265
|
+
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
266
|
+
});
|
|
267
|
+
// Commit to ref — handleMove reads this, no state update needed
|
|
268
|
+
dragStateRef.current = {
|
|
269
|
+
isDragging: true,
|
|
270
|
+
itemIds: itemsToDrag,
|
|
271
|
+
startPositions,
|
|
272
|
+
canvasObjectsStartPos,
|
|
273
|
+
offsetX: worldOffsetX,
|
|
274
|
+
offsetY: worldOffsetY,
|
|
275
|
+
};
|
|
276
|
+
// Block object:moving from fighting our position updates
|
|
277
|
+
isHtmlDraggingRef.current = true;
|
|
278
|
+
document.body.style.cursor = "grabbing";
|
|
279
|
+
document.body.style.userSelect = "none";
|
|
280
|
+
document.body.style.touchAction = "none";
|
|
281
|
+
}, [fabricCanvas, selectedCanvasObjects]);
|
|
282
|
+
// ── Node select ────────────────────────────────────────────────────────────
|
|
283
|
+
const handleSelect = useCallback((id, e) => {
|
|
299
284
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
300
285
|
setSelectedIds((prev) => {
|
|
301
286
|
const next = new Set(prev);
|
|
@@ -306,64 +291,54 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
306
291
|
else {
|
|
307
292
|
setSelectedIds(new Set([id]));
|
|
308
293
|
}
|
|
309
|
-
};
|
|
310
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
294
|
+
}, []);
|
|
295
|
+
const handleStatusChange = useCallback((taskId, newStatus) => {
|
|
296
|
+
setLocalTasks((prev) => {
|
|
297
|
+
const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
|
|
298
|
+
onTasksUpdate?.(updated);
|
|
299
|
+
return updated;
|
|
300
|
+
});
|
|
301
|
+
}, [onTasksUpdate]);
|
|
302
|
+
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
|
315
303
|
useEffect(() => {
|
|
316
304
|
const handleKeyDown = (e) => {
|
|
317
|
-
// Don't trigger if typing in input
|
|
318
305
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
319
306
|
return;
|
|
320
|
-
// Select All
|
|
321
307
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
322
308
|
e.preventDefault();
|
|
323
|
-
setSelectedIds(new Set([
|
|
309
|
+
setSelectedIds(new Set([
|
|
310
|
+
...localTasks.map((t) => t.id),
|
|
311
|
+
...localDocuments.map((d) => d.id),
|
|
312
|
+
]));
|
|
324
313
|
}
|
|
325
|
-
|
|
326
|
-
if (e.key === "Escape") {
|
|
314
|
+
if (e.key === "Escape")
|
|
327
315
|
setSelectedIds(new Set());
|
|
328
|
-
|
|
329
|
-
// ← ADD THIS: Delete selected nodes
|
|
330
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
316
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
331
317
|
e.preventDefault();
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
setLocalDocuments(updatedDocs);
|
|
318
|
+
const ids = selectedIdsRef.current;
|
|
319
|
+
setLocalTasks((prev) => { const u = prev.filter((t) => !ids.has(t.id)); onTasksUpdate?.(u); return u; });
|
|
320
|
+
setLocalDocuments((prev) => { const u = prev.filter((d) => !ids.has(d.id)); onDocumentsUpdate?.(u); return u; });
|
|
336
321
|
setSelectedIds(new Set());
|
|
337
|
-
onTasksUpdate?.(updatedTasks);
|
|
338
|
-
onDocumentsUpdate?.(updatedDocs);
|
|
339
322
|
}
|
|
340
323
|
};
|
|
341
324
|
window.addEventListener("keydown", handleKeyDown);
|
|
342
325
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
343
|
-
}, [localTasks, localDocuments,
|
|
344
|
-
// ── Render
|
|
326
|
+
}, [localTasks, localDocuments, onTasksUpdate, onDocumentsUpdate]);
|
|
327
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
345
328
|
const renderItem = (id, x, y, children) => {
|
|
346
329
|
const screenX = x * canvasZoom;
|
|
347
330
|
const screenY = y * canvasZoom;
|
|
348
|
-
// 1. Detect if the user is interacting with the canvas at all
|
|
349
|
-
// 'dragging' is your existing state.
|
|
350
|
-
// You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
|
|
351
|
-
const isDragging = dragging?.itemIds.includes(id);
|
|
352
331
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
353
332
|
left: 0,
|
|
354
333
|
top: 0,
|
|
355
|
-
// 2. Use translate3d for GPU performance
|
|
356
334
|
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
357
335
|
transformOrigin: "top left",
|
|
358
|
-
|
|
359
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
360
|
-
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
|
|
361
|
-
// 4. Optimization
|
|
336
|
+
transition: "none",
|
|
362
337
|
willChange: "transform",
|
|
363
|
-
zIndex:
|
|
338
|
+
zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
|
|
364
339
|
}, children: children }, id));
|
|
365
340
|
};
|
|
366
|
-
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 },
|
|
341
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
|
|
367
342
|
if (e.target === e.currentTarget)
|
|
368
343
|
setSelectedIds(new Set());
|
|
369
344
|
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|