@mhamz.01/easyflow-whiteboard 2.168.0 → 2.171.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.
Files changed (34) hide show
  1. package/dist/components/node/custom-node-overlay-layer.d.ts +2 -43
  2. package/dist/components/node/custom-node-overlay-layer.d.ts.map +1 -1
  3. package/dist/components/node/custom-node-overlay-layer.js +89 -568
  4. package/dist/components/node/custom-node.d.ts +2 -5
  5. package/dist/components/node/custom-node.d.ts.map +1 -1
  6. package/dist/components/node/custom-node.js +11 -22
  7. package/dist/components/node/document-node.d.ts +2 -5
  8. package/dist/components/node/document-node.d.ts.map +1 -1
  9. package/dist/components/node/document-node.js +25 -42
  10. package/dist/components/node/hooks/useFabricSync.d.ts +41 -0
  11. package/dist/components/node/hooks/useFabricSync.d.ts.map +1 -0
  12. package/dist/components/node/hooks/useFabricSync.js +129 -0
  13. package/dist/components/node/hooks/useKeyboardShortcuts.d.ts +22 -8
  14. package/dist/components/node/hooks/useKeyboardShortcuts.d.ts.map +1 -1
  15. package/dist/components/node/hooks/useKeyboardShortcuts.js +30 -21
  16. package/dist/components/node/hooks/useNodeDrag.d.ts +31 -18
  17. package/dist/components/node/hooks/useNodeDrag.d.ts.map +1 -1
  18. package/dist/components/node/hooks/useNodeDrag.js +139 -78
  19. package/dist/components/node/hooks/useNodeSelection.d.ts +28 -0
  20. package/dist/components/node/hooks/useNodeSelection.d.ts.map +1 -0
  21. package/dist/components/node/hooks/useNodeSelection.js +55 -0
  22. package/dist/components/node/hooks/useNodeState.d.ts +15 -0
  23. package/dist/components/node/hooks/useNodeState.d.ts.map +1 -0
  24. package/dist/components/node/hooks/useNodeState.js +24 -0
  25. package/dist/components/node/hooks/useSelectionBox.d.ts +14 -3
  26. package/dist/components/node/hooks/useSelectionBox.d.ts.map +1 -1
  27. package/dist/components/node/hooks/useSelectionBox.js +39 -18
  28. package/dist/components/node/hooks/useWheelZoom.d.ts +16 -6
  29. package/dist/components/node/hooks/useWheelZoom.d.ts.map +1 -1
  30. package/dist/components/node/hooks/useWheelZoom.js +41 -44
  31. package/dist/components/node/types/overlay-types.d.ts +11 -8
  32. package/dist/components/node/types/overlay-types.d.ts.map +1 -1
  33. package/dist/styles.css +0 -3
  34. 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, { useState, useEffect, useRef } from "react";
3
+ import React, { useRef, useCallback } from "react";
4
4
  import TaskNode from "./custom-node";
5
5
  import DocumentNode from "./document-node";
6
- // ─── Component ────────────────────────────────────────────────────────────────
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 [localTasks, setLocalTasks] = useState(tasks);
9
- const [localDocuments, setLocalDocuments] = useState(documents);
10
- const [selectedIds, setSelectedIds] = useState(new Set());
11
- const [dragging, setDragging] = useState(null);
12
- const nodeClipboardRef = useRef({
13
- tasks: [],
14
- documents: [],
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
- // ── Refs for always-current state (Issue 4) ────────────────────────────────
17
- const localTasksRef = useRef(localTasks);
18
- const localDocumentsRef = useRef(localDocuments);
19
- const selectedIdsRef = useRef(selectedIds);
20
- const isHtmlSelectingRef = useRef(false);
21
- const isSelectionBoxActiveRef = useRef(false);
22
- const htmlNodesSelectedByBoxRef = useRef(false);
23
- const dragSelectedIdsRef = useRef(new Set());
24
- // Change the ref to just track whether there's an activeSelection
25
- const fabricHasActiveSelectionRef = useRef(false);
26
- const fabricActiveObjRef = useRef(null);
27
- localTasksRef.current = localTasks;
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
- const rafIdRef = useRef(null);
40
- const fabricMoveRafRef = useRef(null); // Issue 7
41
- const overlayRef = useRef(null);
42
- // ── Sync props → local state ────────────────────────────────────────────────
43
- useEffect(() => {
44
- setLocalTasks(tasks);
45
- }, [tasks]);
46
- useEffect(() => {
47
- setLocalDocuments(documents);
48
- }, [documents]);
49
- // Issue 2: Remove internal canvasReady state and polling — use prop directly
50
- // No longer needed: const [canvasReady, setCanvasReady] = useState(false);
51
- // No longer needed: polling useEffect with setInterval
52
- // ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
53
- const handleOverlayWheel = (e) => {
54
- if (e.ctrlKey || e.metaKey || e.shiftKey) {
55
- const canvas = fabricCanvas?.current;
56
- if (!canvas)
57
- return;
58
- const nativeEvent = e.nativeEvent;
59
- // getScenePoint handles the transformation from screen to canvas space
60
- const scenePoint = canvas.getScenePoint(nativeEvent);
61
- // Viewport point is simply the mouse position relative to the canvas element
62
- const rect = canvas.getElement().getBoundingClientRect();
63
- const viewportPoint = {
64
- x: nativeEvent.clientX - rect.left,
65
- y: nativeEvent.clientY - rect.top,
66
- };
67
- // We cast to 'any' here because we are manually triggering an internal
68
- // event bus, and Fabric's internal types for .fire() can be overly strict.
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
- // Issue 9: Use refs and only depend on stable callbacks
515
- useEffect(() => {
516
- const handleKeyDown = (e) => {
517
- // Don't trigger if typing in input
518
- if (e.target instanceof HTMLInputElement ||
519
- e.target instanceof HTMLTextAreaElement)
520
- return;
521
- // Select All
522
- if ((e.ctrlKey || e.metaKey) && e.key === "a") {
523
- e.preventDefault();
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
- // Custom comparator — skip re-render if props are equal
572
- // Return true to skip re-render, false to re-render
573
- return (prev.tasks === next.tasks &&
574
- prev.documents === next.documents &&
575
- prev.canvasZoom === next.canvasZoom &&
576
- prev.canvasViewport?.x === next.canvasViewport?.x &&
577
- prev.canvasViewport?.y === next.canvasViewport?.y &&
578
- prev.selectionBox === next.selectionBox &&
579
- prev.selectedCanvasObjects === next.selectedCanvasObjects &&
580
- prev.onTasksUpdate === next.onTasksUpdate &&
581
- prev.onDocumentsUpdate === next.onDocumentsUpdate &&
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
+ );