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