@mhamz.01/easyflow-whiteboard 2.168.0 → 2.170.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.
- package/dist/components/node/custom-node-overlay-layer.d.ts +2 -43
- package/dist/components/node/custom-node-overlay-layer.d.ts.map +1 -1
- package/dist/components/node/custom-node-overlay-layer.js +89 -568
- package/dist/components/node/custom-node.d.ts +2 -5
- package/dist/components/node/custom-node.d.ts.map +1 -1
- package/dist/components/node/custom-node.js +11 -22
- package/dist/components/node/document-node.d.ts +2 -5
- package/dist/components/node/document-node.d.ts.map +1 -1
- package/dist/components/node/document-node.js +25 -42
- package/dist/components/node/hooks/useFabricSync.d.ts +30 -0
- package/dist/components/node/hooks/useFabricSync.d.ts.map +1 -0
- package/dist/components/node/hooks/useFabricSync.js +89 -0
- package/dist/components/node/hooks/useKeyboardShortcuts.d.ts +22 -8
- package/dist/components/node/hooks/useKeyboardShortcuts.d.ts.map +1 -1
- package/dist/components/node/hooks/useKeyboardShortcuts.js +30 -21
- package/dist/components/node/hooks/useNodeDrag.d.ts +31 -18
- package/dist/components/node/hooks/useNodeDrag.d.ts.map +1 -1
- package/dist/components/node/hooks/useNodeDrag.js +128 -78
- package/dist/components/node/hooks/useNodeSelection.d.ts +28 -0
- package/dist/components/node/hooks/useNodeSelection.d.ts.map +1 -0
- package/dist/components/node/hooks/useNodeSelection.js +55 -0
- package/dist/components/node/hooks/useNodeState.d.ts +15 -0
- package/dist/components/node/hooks/useNodeState.d.ts.map +1 -0
- package/dist/components/node/hooks/useNodeState.js +24 -0
- package/dist/components/node/hooks/useSelectionBox.d.ts +14 -3
- package/dist/components/node/hooks/useSelectionBox.d.ts.map +1 -1
- package/dist/components/node/hooks/useSelectionBox.js +39 -18
- package/dist/components/node/hooks/useWheelZoom.d.ts +16 -6
- package/dist/components/node/hooks/useWheelZoom.d.ts.map +1 -1
- package/dist/components/node/hooks/useWheelZoom.js +41 -44
- package/dist/components/node/types/overlay-types.d.ts +11 -8
- package/dist/components/node/types/overlay-types.d.ts.map +1 -1
- package/dist/styles.css +0 -3
- package/package.json +1 -1
|
@@ -1,565 +1,90 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useRef, useCallback } from "react";
|
|
4
4
|
import TaskNode from "./custom-node";
|
|
5
5
|
import DocumentNode from "./document-node";
|
|
6
|
-
|
|
6
|
+
import { useNodeState } from "./hooks/useNodeState";
|
|
7
|
+
import { useNodeSelection } from "./hooks/useNodeSelection";
|
|
8
|
+
import { useNodeDrag } from "./hooks/useNodeDrag";
|
|
9
|
+
import { useFabricSync } from "./hooks/useFabricSync";
|
|
10
|
+
import { useWheelZoom } from "./hooks/useWheelZoom";
|
|
11
|
+
import { useSelectionBox } from "./hooks/useSelectionBox";
|
|
12
|
+
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
|
7
13
|
export default React.memo(function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, canvasReady: canvasReadyProp = false, clearHtmlSelectionRef, }) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
const overlayRef = useRef(null);
|
|
15
|
+
// ── Data layer ────────────────────────────────────────────────────────────────
|
|
16
|
+
const { localTasks, setLocalTasks, localDocuments, setLocalDocuments, localTasksRef, localDocumentsRef, } = useNodeState(tasks, documents);
|
|
17
|
+
// ── Selection layer ───────────────────────────────────────────────────────────
|
|
18
|
+
const { selectedIds, setSelectedIds, selectedIdsRef, isHtmlSelectingRef, isSelectionBoxActiveRef, htmlNodesSelectedByBoxRef, dragSelectedIdsRef, handleSelect, } = useNodeSelection({ fabricCanvas, clearHtmlSelectionRef });
|
|
19
|
+
// ── Drag layer ────────────────────────────────────────────────────────────────
|
|
20
|
+
const { dragging, handleDragStart } = useNodeDrag({
|
|
21
|
+
selectedIdsRef,
|
|
22
|
+
dragSelectedIdsRef,
|
|
23
|
+
localTasksRef,
|
|
24
|
+
localDocumentsRef,
|
|
25
|
+
fabricCanvas,
|
|
26
|
+
setLocalTasks,
|
|
27
|
+
setLocalDocuments,
|
|
28
|
+
onTasksUpdate,
|
|
29
|
+
onDocumentsUpdate,
|
|
15
30
|
});
|
|
16
|
-
// ──
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
localDocumentsRef.current = localDocuments;
|
|
29
|
-
selectedIdsRef.current = selectedIds;
|
|
30
|
-
const dragStateRef = useRef({
|
|
31
|
-
isDragging: false,
|
|
32
|
-
itemIds: [],
|
|
33
|
-
startPositions: new Map(),
|
|
34
|
-
canvasObjectsStartPos: new Map(),
|
|
35
|
-
offsetX: 0,
|
|
36
|
-
offsetY: 0,
|
|
37
|
-
groupStartPos: null, // ← add this
|
|
31
|
+
// ── Fabric→HTML sync ──────────────────────────────────────────────────────────
|
|
32
|
+
useFabricSync({
|
|
33
|
+
fabricCanvas,
|
|
34
|
+
canvasReady: canvasReadyProp,
|
|
35
|
+
dragSelectedIdsRef,
|
|
36
|
+
selectedIdsRef,
|
|
37
|
+
isHtmlSelectingRef,
|
|
38
|
+
isSelectionBoxActiveRef,
|
|
39
|
+
htmlNodesSelectedByBoxRef,
|
|
40
|
+
setSelectedIds,
|
|
41
|
+
setLocalTasks,
|
|
42
|
+
setLocalDocuments,
|
|
38
43
|
});
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
canvas.fire("mouse:wheel", {
|
|
70
|
-
e: nativeEvent,
|
|
71
|
-
scenePoint,
|
|
72
|
-
viewportPoint,
|
|
73
|
-
});
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
e.stopPropagation();
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (!clearHtmlSelectionRef)
|
|
80
|
-
return;
|
|
81
|
-
clearHtmlSelectionRef.current = () => setSelectedIds(new Set());
|
|
82
|
-
}, [clearHtmlSelectionRef]);
|
|
83
|
-
// Issue 6: Remove canvasZoom from deps — not used in handler
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
const overlayEl = overlayRef.current;
|
|
86
|
-
const canvas = fabricCanvas?.current;
|
|
87
|
-
if (!overlayEl || !canvas)
|
|
88
|
-
return;
|
|
89
|
-
const handleGlobalWheel = (e) => {
|
|
90
|
-
// Check if the user is hovering over an element that has pointer-events: auto
|
|
91
|
-
// (meaning they are hovering over a Task or Document)
|
|
92
|
-
const target = e.target;
|
|
93
|
-
const isOverNode = target !== overlayEl;
|
|
94
|
-
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
95
|
-
// 1. Prevent Browser Zoom immediately
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
e.stopPropagation();
|
|
98
|
-
// 2. Calculate coordinates for Fabric
|
|
99
|
-
const scenePoint = canvas.getScenePoint(e);
|
|
100
|
-
const rect = canvas.getElement().getBoundingClientRect();
|
|
101
|
-
const viewportPoint = {
|
|
102
|
-
x: e.clientX - rect.left,
|
|
103
|
-
y: e.clientY - rect.top,
|
|
104
|
-
};
|
|
105
|
-
// 3. Manually fire the event into Fabric
|
|
106
|
-
canvas.fire("mouse:wheel", {
|
|
107
|
-
e: e,
|
|
108
|
-
scenePoint,
|
|
109
|
-
viewportPoint,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
114
|
-
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
115
|
-
return () => {
|
|
116
|
-
overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
117
|
-
};
|
|
118
|
-
}, [fabricCanvas, canvasReadyProp]); // Issue 6: removed canvasZoom
|
|
119
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
120
|
-
// Issue 5: Remove canvasZoom from deps — not used in handlers
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
const canvas = fabricCanvas?.current;
|
|
123
|
-
if (!canvas)
|
|
124
|
-
return;
|
|
125
|
-
// Issue 7: Throttle handleObjectMoving with rAF gate
|
|
126
|
-
const handleObjectMoving = (e) => {
|
|
127
|
-
const target = e.transform?.target || e.target;
|
|
128
|
-
if (!target)
|
|
129
|
-
return;
|
|
130
|
-
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
131
|
-
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
132
|
-
target._prevLeft = target.left;
|
|
133
|
-
target._prevTop = target.top;
|
|
134
|
-
if (deltaX === 0 && deltaY === 0)
|
|
135
|
-
return;
|
|
136
|
-
// const sel = selectedIdsRef.current;
|
|
137
|
-
const sel = dragSelectedIdsRef.current;
|
|
138
|
-
// Skip if frame already queued
|
|
139
|
-
if (fabricMoveRafRef.current !== null)
|
|
140
|
-
return;
|
|
141
|
-
fabricMoveRafRef.current = requestAnimationFrame(() => {
|
|
142
|
-
fabricMoveRafRef.current = null;
|
|
143
|
-
setLocalTasks((prev) => prev.map((t) => sel.has(t.id)
|
|
144
|
-
? { ...t, x: t.x + deltaX, y: t.y + deltaY }
|
|
145
|
-
: t));
|
|
146
|
-
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id)
|
|
147
|
-
? { ...d, x: d.x + deltaX, y: d.y + deltaY }
|
|
148
|
-
: d));
|
|
149
|
-
});
|
|
150
|
-
};
|
|
151
|
-
const handleMouseDown = (e) => {
|
|
152
|
-
const target = e.target;
|
|
153
|
-
if (target) {
|
|
154
|
-
target._prevLeft = target.left;
|
|
155
|
-
target._prevTop = target.top;
|
|
156
|
-
}
|
|
157
|
-
// Only preserve HTML selection if we're clicking INTO an existing multi-selection
|
|
158
|
-
// Don't use activeObjects.length > 1 alone — it's true even when clicking a NEW object
|
|
159
|
-
const activeObjects = canvas.getActiveObjects();
|
|
160
|
-
const isClickingIntoActiveSelection = target &&
|
|
161
|
-
activeObjects.length > 1 &&
|
|
162
|
-
activeObjects.includes(target); // ← target must be IN the selection, not just any click
|
|
163
|
-
if (isClickingIntoActiveSelection) {
|
|
164
|
-
// ← Snapshot BEFORE any selection events fire
|
|
165
|
-
dragSelectedIdsRef.current = new Set(selectedIdsRef.current);
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (isSelectionBoxActiveRef.current)
|
|
169
|
-
return;
|
|
170
|
-
dragSelectedIdsRef.current = new Set(selectedIdsRef.current);
|
|
171
|
-
htmlNodesSelectedByBoxRef.current = false;
|
|
172
|
-
// dragSelectedIdsRef.current = new Set();
|
|
173
|
-
setSelectedIds(new Set());
|
|
174
|
-
};
|
|
175
|
-
// const handleSelectionCleared = () => {
|
|
176
|
-
// setSelectedIds(new Set());
|
|
177
|
-
// };
|
|
178
|
-
const handleFabricSelectionCreated = () => {
|
|
179
|
-
if (isHtmlSelectingRef.current)
|
|
180
|
-
return; // ← skip if HTML node initiated this
|
|
181
|
-
if (isSelectionBoxActiveRef.current)
|
|
182
|
-
return; // ← add this
|
|
183
|
-
if (htmlNodesSelectedByBoxRef.current)
|
|
184
|
-
return;
|
|
185
|
-
setSelectedIds(new Set());
|
|
186
|
-
};
|
|
187
|
-
const handleFabricSelectionUpdated = () => {
|
|
188
|
-
if (isHtmlSelectingRef.current)
|
|
189
|
-
return; // ← skip if HTML node initiated this
|
|
190
|
-
if (isSelectionBoxActiveRef.current)
|
|
191
|
-
return; // ← add this
|
|
192
|
-
if (htmlNodesSelectedByBoxRef.current)
|
|
193
|
-
return;
|
|
194
|
-
setSelectedIds(new Set());
|
|
195
|
-
};
|
|
196
|
-
canvas.on("object:moving", handleObjectMoving);
|
|
197
|
-
canvas.on("mouse:down", handleMouseDown);
|
|
198
|
-
canvas.on("selection:created", (e) => {
|
|
199
|
-
const activeObj = canvas.getActiveObject();
|
|
200
|
-
fabricHasActiveSelectionRef.current = activeObj?.type === "activeSelection";
|
|
201
|
-
fabricActiveObjRef.current = activeObj ?? null;
|
|
202
|
-
handleFabricSelectionCreated();
|
|
203
|
-
}); // ← add
|
|
204
|
-
canvas.on("selection:updated", (e) => {
|
|
205
|
-
fabricHasActiveSelectionRef.current = false;
|
|
206
|
-
fabricActiveObjRef.current = null;
|
|
207
|
-
}); // ← add
|
|
208
|
-
canvas.on("selection:cleared", () => {
|
|
209
|
-
if (!isSelectionBoxActiveRef.current) {
|
|
210
|
-
fabricActiveObjRef.current = null;
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
return () => {
|
|
214
|
-
canvas.off("object:moving", handleObjectMoving);
|
|
215
|
-
canvas.off("mouse:down", handleMouseDown);
|
|
216
|
-
canvas.off("selection:created", handleFabricSelectionCreated); // ← add
|
|
217
|
-
canvas.off("selection:updated", handleFabricSelectionUpdated); // ← add
|
|
218
|
-
// canvas.off("selection:cleared", handleSelectionCleared);
|
|
219
|
-
if (fabricMoveRafRef.current !== null) {
|
|
220
|
-
cancelAnimationFrame(fabricMoveRafRef.current);
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
}, [fabricCanvas, canvasReadyProp]); // Issue 5: removed canvasZoom
|
|
224
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
225
|
-
const getItemPosition = (id) => {
|
|
226
|
-
const task = localTasksRef.current.find((t) => t.id === id);
|
|
227
|
-
if (task)
|
|
228
|
-
return { x: task.x, y: task.y };
|
|
229
|
-
const doc = localDocumentsRef.current.find((d) => d.id === id);
|
|
230
|
-
if (doc)
|
|
231
|
-
return { x: doc.x, y: doc.y };
|
|
232
|
-
return undefined;
|
|
233
|
-
};
|
|
234
|
-
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
235
|
-
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
236
|
-
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
237
|
-
const itemX2 = itemX1 + width * canvasZoom;
|
|
238
|
-
const itemY2 = itemY1 + height * canvasZoom;
|
|
239
|
-
const boxX1 = Math.min(box.x1, box.x2);
|
|
240
|
-
const boxY1 = Math.min(box.y1, box.y2);
|
|
241
|
-
const boxX2 = Math.max(box.x1, box.x2);
|
|
242
|
-
const boxY2 = Math.max(box.y1, box.y2);
|
|
243
|
-
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
244
|
-
};
|
|
245
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
if (!selectionBox) {
|
|
248
|
-
isSelectionBoxActiveRef.current = false;
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
isSelectionBoxActiveRef.current = true;
|
|
252
|
-
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
253
|
-
const newSelected = new Set();
|
|
254
|
-
for (const task of localTasksRef.current) {
|
|
255
|
-
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
256
|
-
newSelected.add(task.id);
|
|
257
|
-
}
|
|
258
|
-
for (const doc of localDocumentsRef.current) {
|
|
259
|
-
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
260
|
-
newSelected.add(doc.id);
|
|
261
|
-
}
|
|
262
|
-
// ── O(n) equality check: size first (fast path), then membership ──
|
|
263
|
-
setSelectedIds((prev) => {
|
|
264
|
-
if (prev.size !== newSelected.size) {
|
|
265
|
-
htmlNodesSelectedByBoxRef.current = newSelected.size > 0; // ← track if any HTML nodes selected
|
|
266
|
-
return newSelected;
|
|
267
|
-
}
|
|
268
|
-
for (const id of newSelected) {
|
|
269
|
-
if (!prev.has(id)) {
|
|
270
|
-
htmlNodesSelectedByBoxRef.current = newSelected.size > 0;
|
|
271
|
-
return newSelected;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return prev;
|
|
275
|
-
});
|
|
276
|
-
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
277
|
-
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
278
|
-
// Helper to extract coordinates regardless of event type
|
|
279
|
-
const getPointerEvent = (e) => {
|
|
280
|
-
if ("touches" in e && e.touches.length > 0)
|
|
281
|
-
return e.touches[0];
|
|
282
|
-
return e;
|
|
283
|
-
};
|
|
284
|
-
const handleDragStart = (itemId, e) => {
|
|
285
|
-
// 1. Safety check for the Fabric instance
|
|
286
|
-
const canvas = fabricCanvas?.current;
|
|
287
|
-
if (!canvas)
|
|
288
|
-
return;
|
|
289
|
-
// In handleDragStart — always read live position:
|
|
290
|
-
const liveGroupObj = canvas.getActiveObject();
|
|
291
|
-
const hasActiveSelection = fabricHasActiveSelectionRef.current || liveGroupObj?.type === "activeSelection";
|
|
292
|
-
const groupObj = hasActiveSelection ? (liveGroupObj ?? fabricActiveObjRef.current) : null;
|
|
293
|
-
const liveActiveObjects = canvas.getActiveObjects();
|
|
294
|
-
// 2. Normalize the event (Touch vs Mouse)
|
|
295
|
-
if (e.cancelable)
|
|
296
|
-
e.preventDefault();
|
|
297
|
-
const pointer = getPointerEvent(e);
|
|
298
|
-
// 3. Determine which items are being dragged
|
|
299
|
-
// selection update DOES NOT trigger before drag snapshot
|
|
300
|
-
let itemsToDrag;
|
|
301
|
-
if (selectedIdsRef.current.has(itemId)) {
|
|
302
|
-
itemsToDrag = Array.from(selectedIdsRef.current);
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
itemsToDrag = [itemId];
|
|
306
|
-
}
|
|
307
|
-
// 4. Capture current World Transform (Zoom & Pan)
|
|
308
|
-
// We read directly from the canvas to ensure zero-frame lag
|
|
309
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
310
|
-
const liveZoom = vpt[0];
|
|
311
|
-
const liveVpX = vpt[4];
|
|
312
|
-
const liveVpY = vpt[5];
|
|
313
|
-
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
314
|
-
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
315
|
-
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
316
|
-
// 6. Get the clicked item's current World Position
|
|
317
|
-
const clickedPos = getItemPosition(itemId);
|
|
318
|
-
if (!clickedPos)
|
|
319
|
-
return;
|
|
320
|
-
// 7. Calculate the Offset in WORLD UNITS
|
|
321
|
-
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
322
|
-
// This value remains constant even if you zoom during the drag.
|
|
323
|
-
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
324
|
-
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
325
|
-
// 8. Snapshot starting positions for all selected HTML nodes
|
|
326
|
-
const startPositions = new Map();
|
|
327
|
-
itemsToDrag.forEach((id) => {
|
|
328
|
-
const pos = getItemPosition(id);
|
|
329
|
-
if (pos)
|
|
330
|
-
startPositions.set(id, pos);
|
|
331
|
-
});
|
|
332
|
-
const canvasObjectsStartPos = new Map();
|
|
333
|
-
// snapshot members too
|
|
334
|
-
console.log("groupObj at drag start:", groupObj?.type, groupObj?.left, groupObj?.top);
|
|
335
|
-
console.log("clickedPos:", clickedPos.x, clickedPos.y);
|
|
336
|
-
console.log("groupObj.left/top:", groupObj?.left, groupObj?.top);
|
|
337
|
-
console.log("deltaFromAnchor:", (groupObj?.left || 0) - clickedPos.x, (groupObj?.top || 0) - clickedPos.y);
|
|
338
|
-
const groupStartPos = (groupObj && groupObj.type === "activeSelection")
|
|
339
|
-
? {
|
|
340
|
-
left: groupObj.left || 0,
|
|
341
|
-
top: groupObj.top || 0,
|
|
342
|
-
deltaFromAnchor: {
|
|
343
|
-
x: (groupObj.left || 0) - clickedPos.x,
|
|
344
|
-
y: (groupObj.top || 0) - clickedPos.y,
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
: null;
|
|
348
|
-
if (!groupStartPos) {
|
|
349
|
-
liveActiveObjects.forEach((obj) => {
|
|
350
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
354
|
-
dragStateRef.current = {
|
|
355
|
-
isDragging: true,
|
|
356
|
-
itemIds: itemsToDrag,
|
|
357
|
-
startPositions,
|
|
358
|
-
canvasObjectsStartPos,
|
|
359
|
-
groupStartPos, // ← add this field
|
|
360
|
-
offsetX: worldOffsetX,
|
|
361
|
-
offsetY: worldOffsetY,
|
|
362
|
-
};
|
|
363
|
-
// if (!selectedIdsRef.current.has(itemId) && dragStateRef.current.itemIds.length === 0) {
|
|
364
|
-
// setSelectedIds(new Set([itemId]));
|
|
365
|
-
// }
|
|
366
|
-
dragSelectedIdsRef.current = new Set(itemsToDrag);
|
|
367
|
-
// 11. Trigger UI states
|
|
368
|
-
setDragging({ itemIds: itemsToDrag });
|
|
369
|
-
document.body.style.cursor = "grabbing";
|
|
370
|
-
document.body.style.userSelect = "none";
|
|
371
|
-
document.body.style.touchAction = "none";
|
|
372
|
-
};
|
|
373
|
-
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
374
|
-
// Issue 3: Remove localTasks/localDocuments from deps — use refs instead
|
|
375
|
-
useEffect(() => {
|
|
376
|
-
if (!dragging)
|
|
377
|
-
return;
|
|
378
|
-
const handleMove = (e) => {
|
|
379
|
-
if (!dragStateRef.current.isDragging)
|
|
380
|
-
return;
|
|
381
|
-
if (e.cancelable)
|
|
382
|
-
e.preventDefault();
|
|
383
|
-
const pointer = getPointerEvent(e);
|
|
384
|
-
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
385
|
-
if (rafIdRef.current !== null)
|
|
386
|
-
cancelAnimationFrame(rafIdRef.current);
|
|
387
|
-
rafIdRef.current = requestAnimationFrame(() => {
|
|
388
|
-
const { itemIds, startPositions, canvasObjectsStartPos, offsetX, offsetY, } = dragStateRef.current;
|
|
389
|
-
const canvas = fabricCanvas?.current;
|
|
390
|
-
if (!canvas)
|
|
391
|
-
return;
|
|
392
|
-
// 2. Read the "Source of Truth" transform from the canvas
|
|
393
|
-
const vpt = canvas.viewportTransform;
|
|
394
|
-
const liveZoom = vpt[0]; // Scale
|
|
395
|
-
const liveVpX = vpt[4]; // Pan X
|
|
396
|
-
const liveVpY = vpt[5]; // Pan Y
|
|
397
|
-
// 3. Convert current Mouse Screen Position → World Position
|
|
398
|
-
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
399
|
-
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
400
|
-
// 4. Calculate where the "Anchor" node should be in World Units
|
|
401
|
-
// (Current Mouse World - Initial World Offset from Start)
|
|
402
|
-
// const deltaX = currentWorldX - offsetX;
|
|
403
|
-
// const deltaY = currentWorldY - offsetY;
|
|
404
|
-
// 5. Calculate the Movement Delta in World Units
|
|
405
|
-
// We compare where the first item started vs where it is now.
|
|
406
|
-
const firstId = itemIds[0];
|
|
407
|
-
const firstStart = startPositions.get(firstId);
|
|
408
|
-
if (!firstStart)
|
|
409
|
-
return;
|
|
410
|
-
const anchorTargetX = currentWorldX - offsetX;
|
|
411
|
-
const anchorTargetY = currentWorldY - offsetY;
|
|
412
|
-
const deltaX = anchorTargetX - firstStart.x;
|
|
413
|
-
const deltaY = anchorTargetY - firstStart.y;
|
|
414
|
-
// 6. Update HTML Nodes (Batching these into one state update)
|
|
415
|
-
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
416
|
-
? {
|
|
417
|
-
...t,
|
|
418
|
-
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
419
|
-
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
420
|
-
}
|
|
421
|
-
: t));
|
|
422
|
-
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
|
|
423
|
-
? {
|
|
424
|
-
...d,
|
|
425
|
-
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
426
|
-
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
427
|
-
}
|
|
428
|
-
: d));
|
|
429
|
-
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
430
|
-
// canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
431
|
-
// obj.set({
|
|
432
|
-
// left: startPos.left + deltaX,
|
|
433
|
-
// top: startPos.top + deltaY,
|
|
434
|
-
// });
|
|
435
|
-
// obj.setCoords(); // Required for selection/intersection accuracy
|
|
436
|
-
// });
|
|
437
|
-
// 7. Sync Fabric Objects — trust the snapshot, not live canvas state
|
|
438
|
-
const { groupStartPos } = dragStateRef.current;
|
|
439
|
-
if (groupStartPos) {
|
|
440
|
-
const activeObj = canvas.getActiveObject();
|
|
441
|
-
console.log("anchorTargetX:", anchorTargetX, "anchorTargetY:", anchorTargetY);
|
|
442
|
-
console.log("setting group to:", anchorTargetX + groupStartPos.deltaFromAnchor.x, anchorTargetY + groupStartPos.deltaFromAnchor.y);
|
|
443
|
-
console.log("activeObj type:", activeObj?.type, "activeObj left/top:", activeObj?.left, activeObj?.top);
|
|
444
|
-
if (activeObj) {
|
|
445
|
-
// anchorTargetX is where the HTML anchor node IS right now in world units
|
|
446
|
-
activeObj.set({
|
|
447
|
-
left: anchorTargetX + groupStartPos.deltaFromAnchor.x,
|
|
448
|
-
top: anchorTargetY + groupStartPos.deltaFromAnchor.y,
|
|
449
|
-
});
|
|
450
|
-
activeObj.setCoords();
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
else {
|
|
454
|
-
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
455
|
-
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
456
|
-
obj.setCoords();
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
canvas.requestRenderAll();
|
|
460
|
-
});
|
|
461
|
-
};
|
|
462
|
-
// Issue 4: Use refs for always-current values
|
|
463
|
-
const handleEnd = () => {
|
|
464
|
-
if (rafIdRef.current !== null)
|
|
465
|
-
cancelAnimationFrame(rafIdRef.current);
|
|
466
|
-
dragStateRef.current.isDragging = false;
|
|
467
|
-
dragSelectedIdsRef.current = new Set();
|
|
468
|
-
setDragging(null);
|
|
469
|
-
document.body.style.cursor = "";
|
|
470
|
-
document.body.style.userSelect = "";
|
|
471
|
-
document.body.style.touchAction = "";
|
|
472
|
-
onTasksUpdate?.(localTasksRef.current);
|
|
473
|
-
onDocumentsUpdate?.(localDocumentsRef.current);
|
|
474
|
-
};
|
|
475
|
-
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
476
|
-
window.addEventListener("mouseup", handleEnd);
|
|
477
|
-
window.addEventListener("touchmove", handleMove, { passive: false });
|
|
478
|
-
window.addEventListener("touchend", handleEnd);
|
|
479
|
-
window.addEventListener("touchcancel", handleEnd);
|
|
480
|
-
return () => {
|
|
481
|
-
window.removeEventListener("mousemove", handleMove);
|
|
482
|
-
window.removeEventListener("mouseup", handleEnd);
|
|
483
|
-
window.removeEventListener("touchmove", handleMove);
|
|
484
|
-
window.removeEventListener("touchend", handleEnd);
|
|
485
|
-
window.removeEventListener("touchcancel", handleEnd);
|
|
486
|
-
};
|
|
487
|
-
}, [dragging, fabricCanvas]); // Issue 3: removed localTasks/localDocuments
|
|
488
|
-
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
489
|
-
const handleSelect = (id, e) => {
|
|
490
|
-
const canvas = fabricCanvas?.current;
|
|
491
|
-
if (canvas) {
|
|
492
|
-
isHtmlSelectingRef.current = true; // ← guard: we're initiating this
|
|
493
|
-
canvas.discardActiveObject();
|
|
494
|
-
canvas.requestRenderAll();
|
|
495
|
-
isHtmlSelectingRef.current = false; // ← reset immediately
|
|
496
|
-
}
|
|
497
|
-
htmlNodesSelectedByBoxRef.current = false;
|
|
498
|
-
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
499
|
-
setSelectedIds((prev) => {
|
|
500
|
-
const next = new Set(prev);
|
|
501
|
-
next.has(id) ? next.delete(id) : next.add(id);
|
|
502
|
-
return next;
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
setSelectedIds(new Set([id]));
|
|
507
|
-
}
|
|
508
|
-
};
|
|
509
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
44
|
+
// ── Wheel zoom forwarding ─────────────────────────────────────────────────────
|
|
45
|
+
const { handleOverlayWheel } = useWheelZoom({
|
|
46
|
+
overlayRef,
|
|
47
|
+
fabricCanvas,
|
|
48
|
+
canvasReady: canvasReadyProp,
|
|
49
|
+
});
|
|
50
|
+
// ── Selection box hit detection ───────────────────────────────────────────────
|
|
51
|
+
useSelectionBox({
|
|
52
|
+
selectionBox,
|
|
53
|
+
localTasks,
|
|
54
|
+
localDocuments,
|
|
55
|
+
canvasZoom,
|
|
56
|
+
canvasViewport,
|
|
57
|
+
isSelectionBoxActiveRef,
|
|
58
|
+
htmlNodesSelectedByBoxRef,
|
|
59
|
+
setSelectedIds,
|
|
60
|
+
});
|
|
61
|
+
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
62
|
+
useKeyboardShortcuts({
|
|
63
|
+
localTasksRef,
|
|
64
|
+
localDocumentsRef,
|
|
65
|
+
selectedIdsRef,
|
|
66
|
+
setSelectedIds,
|
|
67
|
+
setLocalTasks,
|
|
68
|
+
setLocalDocuments,
|
|
69
|
+
onTasksUpdate,
|
|
70
|
+
onDocumentsUpdate,
|
|
71
|
+
});
|
|
72
|
+
// ── Status change (task-specific business logic, stays in orchestrator) ───────
|
|
73
|
+
const handleStatusChange = useCallback((taskId, newStatus) => {
|
|
510
74
|
const updated = localTasksRef.current.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
|
|
511
75
|
setLocalTasks(updated);
|
|
512
76
|
onTasksUpdate?.(updated);
|
|
513
|
-
};
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
setSelectedIds(new Set([
|
|
525
|
-
...localTasksRef.current.map((t) => t.id),
|
|
526
|
-
...localDocumentsRef.current.map((d) => d.id),
|
|
527
|
-
]));
|
|
528
|
-
}
|
|
529
|
-
// Clear selection
|
|
530
|
-
if (e.key === "Escape") {
|
|
531
|
-
setSelectedIds(new Set());
|
|
532
|
-
}
|
|
533
|
-
// Delete selected nodes
|
|
534
|
-
if ((e.key === "Delete" || e.key === "Backspace") &&
|
|
535
|
-
selectedIdsRef.current.size > 0) {
|
|
536
|
-
e.preventDefault();
|
|
537
|
-
const sel = selectedIdsRef.current;
|
|
538
|
-
const updatedTasks = localTasksRef.current.filter((t) => !sel.has(t.id));
|
|
539
|
-
const updatedDocs = localDocumentsRef.current.filter((d) => !sel.has(d.id));
|
|
540
|
-
setLocalTasks(updatedTasks);
|
|
541
|
-
setLocalDocuments(updatedDocs);
|
|
542
|
-
setSelectedIds(new Set());
|
|
543
|
-
onTasksUpdate?.(updatedTasks);
|
|
544
|
-
onDocumentsUpdate?.(updatedDocs);
|
|
545
|
-
}
|
|
546
|
-
};
|
|
547
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
548
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
549
|
-
}, [onTasksUpdate, onDocumentsUpdate]); // Issue 9: only stable callbacks
|
|
550
|
-
// ── Render helper ────────────────────────────────────────────────────────────
|
|
551
|
-
const renderItem = (id, x, y, children) => {
|
|
552
|
-
const isDragging = dragging?.itemIds.includes(id);
|
|
553
|
-
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
554
|
-
left: 0,
|
|
555
|
-
top: 0,
|
|
556
|
-
transform: `translate3d(${x}px, ${y}px, 0)`, // world units only
|
|
557
|
-
transformOrigin: "top left",
|
|
558
|
-
transition: "none",
|
|
559
|
-
willChange: "transform",
|
|
560
|
-
zIndex: isDragging ? 1000 : 1,
|
|
561
|
-
}, children: children }, id));
|
|
562
|
-
};
|
|
77
|
+
}, [localTasksRef, setLocalTasks, onTasksUpdate]);
|
|
78
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
79
|
+
const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
80
|
+
left: 0,
|
|
81
|
+
top: 0,
|
|
82
|
+
transform: `translate3d(${x}px, ${y}px, 0)`,
|
|
83
|
+
transformOrigin: "top left",
|
|
84
|
+
transition: "none",
|
|
85
|
+
willChange: "transform",
|
|
86
|
+
zIndex: dragging?.itemIds.includes(id) ? 1000 : 1,
|
|
87
|
+
}, children: children }, id));
|
|
563
88
|
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
564
89
|
if (e.target === e.currentTarget)
|
|
565
90
|
setSelectedIds(new Set());
|
|
@@ -567,19 +92,15 @@ export default React.memo(function CanvasOverlayLayer({ tasks, documents, onTask
|
|
|
567
92
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px) scale(${canvasZoom})`,
|
|
568
93
|
transformOrigin: "top left",
|
|
569
94
|
}, 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 })))] }) }));
|
|
570
|
-
}, (prev, next) =>
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
prev.canvasReady === next.canvasReady
|
|
583
|
-
// fabricCanvas ref intentionally omitted — it's stable and doesn't need comparison
|
|
584
|
-
);
|
|
585
|
-
});
|
|
95
|
+
}, (prev, next) => prev.tasks === next.tasks &&
|
|
96
|
+
prev.documents === next.documents &&
|
|
97
|
+
prev.canvasZoom === next.canvasZoom &&
|
|
98
|
+
prev.canvasViewport?.x === next.canvasViewport?.x &&
|
|
99
|
+
prev.canvasViewport?.y === next.canvasViewport?.y &&
|
|
100
|
+
prev.selectionBox === next.selectionBox &&
|
|
101
|
+
prev.selectedCanvasObjects === next.selectedCanvasObjects &&
|
|
102
|
+
prev.onTasksUpdate === next.onTasksUpdate &&
|
|
103
|
+
prev.onDocumentsUpdate === next.onDocumentsUpdate &&
|
|
104
|
+
prev.canvasReady === next.canvasReady
|
|
105
|
+
// fabricCanvas ref is a stable object — intentionally omitted
|
|
106
|
+
);
|