@mhamz.01/easyflow-whiteboard 2.34.0 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAM9C,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC;IACxC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,UAAU,uBAAuB;IAC/B,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACxC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,YAAY,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACzE,qBAAqB,CAAC,EAAE,YAAY,EAAE,CAAC;IACvC,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAwBD,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,EACzC,KAAK,EACL,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,UAAc,EACd,cAA+B,EAC/B,YAAmB,EACnB,qBAA0B,EAC1B,YAAY,GACb,EAAE,uBAAuB,2CAmezB"}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, memo } from "react";
|
|
4
4
|
import TaskNode from "./custom-node";
|
|
5
5
|
import DocumentNode from "./document-node";
|
|
6
|
+
// ─── Memoized node wrappers ───────────────────────────────────────────────────
|
|
7
|
+
// Prevents all sibling nodes re-rendering when one node's position or
|
|
8
|
+
// selection state changes. Props compared shallowly by React.memo.
|
|
9
|
+
const MemoTaskNode = memo(TaskNode);
|
|
10
|
+
const MemoDocumentNode = memo(DocumentNode);
|
|
6
11
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
7
12
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
8
13
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
9
14
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
10
15
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
|
-
|
|
16
|
+
// `dragging` state removed entirely — it caused a re-render on drag start
|
|
17
|
+
// which tore down/reattached the mousemove listener mid-gesture (jump bug).
|
|
18
|
+
// All drag lifecycle now lives exclusively in dragStateRef.
|
|
19
|
+
// ─── Refs ──────────────────────────────────────────────────────────────────
|
|
12
20
|
const dragStateRef = useRef({
|
|
13
21
|
isDragging: false,
|
|
14
22
|
itemIds: [],
|
|
@@ -16,76 +24,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
16
24
|
canvasObjectsStartPos: new Map(),
|
|
17
25
|
offsetX: 0,
|
|
18
26
|
offsetY: 0,
|
|
27
|
+
fabricDragHtmlIds: new Set(),
|
|
19
28
|
});
|
|
20
29
|
const rafIdRef = useRef(null);
|
|
21
30
|
const overlayRef = useRef(null);
|
|
31
|
+
// ── Always-fresh refs — assigned synchronously in render body ─────────────
|
|
32
|
+
// Read inside stable [] effect closures. Assigned during render (not useEffect)
|
|
33
|
+
// so they hold the latest value before any event handler can fire.
|
|
22
34
|
const selectedIdsRef = useRef(selectedIds);
|
|
23
35
|
selectedIdsRef.current = selectedIds;
|
|
24
|
-
//
|
|
36
|
+
// O(1) world-position lookup — replaces O(n) getItemPosition() scans
|
|
37
|
+
const nodePositionsRef = useRef(new Map());
|
|
38
|
+
nodePositionsRef.current = new Map([
|
|
39
|
+
...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
|
|
40
|
+
...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
|
|
41
|
+
]);
|
|
42
|
+
// Avoids stale prop capture of selectedCanvasObjects in handleDragStart
|
|
43
|
+
const selectedCanvasObjectsRef = useRef(selectedCanvasObjects);
|
|
44
|
+
selectedCanvasObjectsRef.current = selectedCanvasObjects;
|
|
45
|
+
// Stable callback refs — handleEnd never closes over stale prop functions
|
|
46
|
+
const onTasksUpdateRef = useRef(onTasksUpdate);
|
|
47
|
+
const onDocumentsUpdateRef = useRef(onDocumentsUpdate);
|
|
48
|
+
onTasksUpdateRef.current = onTasksUpdate;
|
|
49
|
+
onDocumentsUpdateRef.current = onDocumentsUpdate;
|
|
50
|
+
// ─── Sync props → local state ──────────────────────────────────────────────
|
|
25
51
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
26
52
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const canvas = fabricCanvas?.current;
|
|
31
|
-
if (!canvas)
|
|
32
|
-
return;
|
|
33
|
-
const nativeEvent = e.nativeEvent;
|
|
34
|
-
// getScenePoint handles the transformation from screen to canvas space
|
|
35
|
-
const scenePoint = canvas.getScenePoint(nativeEvent);
|
|
36
|
-
// Viewport point is simply the mouse position relative to the canvas element
|
|
37
|
-
const rect = canvas.getElement().getBoundingClientRect();
|
|
38
|
-
const viewportPoint = {
|
|
39
|
-
x: nativeEvent.clientX - rect.left,
|
|
40
|
-
y: nativeEvent.clientY - rect.top,
|
|
41
|
-
};
|
|
42
|
-
// We cast to 'any' here because we are manually triggering an internal
|
|
43
|
-
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
44
|
-
canvas.fire("mouse:wheel", {
|
|
45
|
-
e: nativeEvent,
|
|
46
|
-
scenePoint,
|
|
47
|
-
viewportPoint,
|
|
48
|
-
});
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
e.stopPropagation();
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
+
// ─── Wheel forwarding to Fabric ────────────────────────────────────────────
|
|
54
|
+
// `canvasZoom` removed from deps — handler reads canvas state imperatively,
|
|
55
|
+
// no stale data risk. Registered once per canvas mount.
|
|
53
56
|
useEffect(() => {
|
|
54
57
|
const overlayEl = overlayRef.current;
|
|
55
58
|
const canvas = fabricCanvas?.current;
|
|
56
59
|
if (!overlayEl || !canvas)
|
|
57
60
|
return;
|
|
58
61
|
const handleGlobalWheel = (e) => {
|
|
59
|
-
|
|
60
|
-
// (meaning they are hovering over a Task or Document)
|
|
61
|
-
const target = e.target;
|
|
62
|
-
const isOverNode = target !== overlayEl;
|
|
62
|
+
const isOverNode = e.target !== overlayEl;
|
|
63
63
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
64
|
-
// 1. Prevent Browser Zoom immediately
|
|
65
64
|
e.preventDefault();
|
|
66
65
|
e.stopPropagation();
|
|
67
|
-
// 2. Calculate coordinates for Fabric
|
|
68
66
|
const scenePoint = canvas.getScenePoint(e);
|
|
69
67
|
const rect = canvas.getElement().getBoundingClientRect();
|
|
70
|
-
const viewportPoint = {
|
|
71
|
-
x: e.clientX - rect.left,
|
|
72
|
-
y: e.clientY - rect.top,
|
|
73
|
-
};
|
|
74
|
-
// 3. Manually fire the event into Fabric
|
|
75
68
|
canvas.fire("mouse:wheel", {
|
|
76
|
-
e
|
|
69
|
+
e,
|
|
77
70
|
scenePoint,
|
|
78
|
-
viewportPoint,
|
|
71
|
+
viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
79
72
|
});
|
|
80
73
|
}
|
|
81
74
|
};
|
|
82
|
-
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
83
75
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
84
|
-
return () =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
76
|
+
return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
77
|
+
}, [fabricCanvas]);
|
|
78
|
+
// ─── Fabric → Overlay sync + deselection ───────────────────────────────────
|
|
79
|
+
// Registered once — selectedIds always read via selectedIdsRef.
|
|
89
80
|
useEffect(() => {
|
|
90
81
|
const canvas = fabricCanvas?.current;
|
|
91
82
|
if (!canvas)
|
|
@@ -94,17 +85,39 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
94
85
|
const target = e.transform?.target || e.target;
|
|
95
86
|
if (!target)
|
|
96
87
|
return;
|
|
88
|
+
// If an HTML-node drag is already in progress (handleDragStart was called),
|
|
89
|
+
// object:moving should not also move HTML nodes — the HTML drag loop owns that.
|
|
90
|
+
// itemIds.length > 0 means HTML drag is active.
|
|
91
|
+
if (dragStateRef.current.isDragging && dragStateRef.current.itemIds.length > 0)
|
|
92
|
+
return;
|
|
93
|
+
// On the very first object:moving frame of a FABRIC-initiated drag,
|
|
94
|
+
// snapshot which HTML nodes were selected at mousedown time — BEFORE
|
|
95
|
+
// selection:created/updated had a chance to clear them.
|
|
96
|
+
if (!dragStateRef.current.isDragging) {
|
|
97
|
+
dragStateRef.current.isDragging = true;
|
|
98
|
+
dragStateRef.current.fabricDragHtmlIds = new Set(selectedIdsRef.current);
|
|
99
|
+
}
|
|
97
100
|
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
98
101
|
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
99
102
|
target._prevLeft = target.left;
|
|
100
103
|
target._prevTop = target.top;
|
|
101
104
|
if (deltaX === 0 && deltaY === 0)
|
|
102
105
|
return;
|
|
103
|
-
//
|
|
104
|
-
const sel =
|
|
106
|
+
// Use the snapshotted ids — immune to any post-mousedown selection clearing
|
|
107
|
+
const sel = dragStateRef.current.fabricDragHtmlIds;
|
|
108
|
+
if (sel.size === 0)
|
|
109
|
+
return;
|
|
105
110
|
setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
106
111
|
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
107
112
|
};
|
|
113
|
+
// When Fabric drag ends, reset so next gesture gets a fresh snapshot
|
|
114
|
+
const handleObjectModified = () => {
|
|
115
|
+
// Only reset if this was a Fabric-initiated drag (not HTML drag)
|
|
116
|
+
if (dragStateRef.current.isDragging && dragStateRef.current.itemIds.length === 0) {
|
|
117
|
+
dragStateRef.current.isDragging = false;
|
|
118
|
+
dragStateRef.current.fabricDragHtmlIds = new Set();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
108
121
|
const handleMouseDown = (e) => {
|
|
109
122
|
const target = e.target;
|
|
110
123
|
if (target) {
|
|
@@ -112,158 +125,84 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
112
125
|
target._prevTop = target.top;
|
|
113
126
|
}
|
|
114
127
|
if (!target) {
|
|
128
|
+
// Clicked empty canvas — always deselect HTML nodes
|
|
115
129
|
setSelectedIds(new Set());
|
|
116
130
|
return;
|
|
117
131
|
}
|
|
118
|
-
//
|
|
132
|
+
// Clicked a Fabric object — only deselect HTML nodes if NOT already in
|
|
133
|
+
// the active selection (preserves mixed-group drag behaviour).
|
|
134
|
+
// Note: getActiveObjects() here reflects the selection BEFORE this click,
|
|
135
|
+
// which is exactly what we want — it tells us if this is a continuation
|
|
136
|
+
// of an existing selection (drag) vs a fresh click (deselect).
|
|
119
137
|
const activeObjects = canvas.getActiveObjects();
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
if (!activeObjects.includes(target)) {
|
|
139
|
+
setSelectedIds(new Set());
|
|
140
|
+
}
|
|
141
|
+
// If already selected: selectedIds stays intact.
|
|
142
|
+
// handleObjectMoving will snapshot it on the first move frame.
|
|
143
|
+
};
|
|
144
|
+
// Clear HTML selection on fresh Fabric selection, but never while a
|
|
145
|
+
// Fabric drag is active (fabricDragHtmlIds.size > 0 = drag in progress)
|
|
146
|
+
const handleFabricSelection = () => {
|
|
147
|
+
if (!dragStateRef.current.isDragging) {
|
|
122
148
|
setSelectedIds(new Set());
|
|
123
149
|
}
|
|
124
150
|
};
|
|
125
151
|
canvas.on("object:moving", handleObjectMoving);
|
|
152
|
+
canvas.on("object:modified", handleObjectModified);
|
|
126
153
|
canvas.on("mouse:down", handleMouseDown);
|
|
154
|
+
canvas.on("selection:created", handleFabricSelection);
|
|
155
|
+
canvas.on("selection:updated", handleFabricSelection);
|
|
127
156
|
return () => {
|
|
128
157
|
canvas.off("object:moving", handleObjectMoving);
|
|
158
|
+
canvas.off("object:modified", handleObjectModified);
|
|
129
159
|
canvas.off("mouse:down", handleMouseDown);
|
|
160
|
+
canvas.off("selection:created", handleFabricSelection);
|
|
161
|
+
canvas.off("selection:updated", handleFabricSelection);
|
|
130
162
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// change, creating a new closure each time. The second drag captured a stale
|
|
134
|
-
// or empty selectedIds from the closure at re-registration time.
|
|
135
|
-
}, [canvasZoom, fabricCanvas]);
|
|
136
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
137
|
-
const getItemPosition = (id) => {
|
|
138
|
-
const task = localTasks.find((t) => t.id === id);
|
|
139
|
-
if (task)
|
|
140
|
-
return { x: task.x, y: task.y };
|
|
141
|
-
const doc = localDocuments.find((d) => d.id === id);
|
|
142
|
-
if (doc)
|
|
143
|
-
return { x: doc.x, y: doc.y };
|
|
144
|
-
return undefined;
|
|
145
|
-
};
|
|
146
|
-
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
147
|
-
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
148
|
-
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
149
|
-
const itemX2 = itemX1 + width * canvasZoom;
|
|
150
|
-
const itemY2 = itemY1 + height * canvasZoom;
|
|
151
|
-
const boxX1 = Math.min(box.x1, box.x2);
|
|
152
|
-
const boxY1 = Math.min(box.y1, box.y2);
|
|
153
|
-
const boxX2 = Math.max(box.x1, box.x2);
|
|
154
|
-
const boxY2 = Math.max(box.y1, box.y2);
|
|
155
|
-
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
156
|
-
};
|
|
157
|
-
// ── Selection box detection ──────────────────────────────────────────────────
|
|
163
|
+
}, [fabricCanvas]); // selectedIds NOT in deps — read via ref
|
|
164
|
+
// ─── Selection box hit detection ───────────────────────────────────────────
|
|
158
165
|
useEffect(() => {
|
|
159
166
|
if (!selectionBox)
|
|
160
167
|
return;
|
|
161
|
-
|
|
168
|
+
const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
|
|
169
|
+
const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
|
|
170
|
+
const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
|
|
171
|
+
const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
|
|
172
|
+
const hits = (x, y, w, h) => {
|
|
173
|
+
const x1 = x * canvasZoom + canvasViewport.x;
|
|
174
|
+
const y1 = y * canvasZoom + canvasViewport.y;
|
|
175
|
+
return !(bX2 < x1 || bX1 > x1 + w * canvasZoom || bY2 < y1 || bY1 > y1 + h * canvasZoom);
|
|
176
|
+
};
|
|
162
177
|
const newSelected = new Set();
|
|
163
|
-
for (const task of localTasks)
|
|
164
|
-
if (
|
|
178
|
+
for (const task of localTasks)
|
|
179
|
+
if (hits(task.x, task.y, 300, 140))
|
|
165
180
|
newSelected.add(task.id);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
181
|
+
for (const doc of localDocuments)
|
|
182
|
+
if (hits(doc.x, doc.y, 320, 160))
|
|
169
183
|
newSelected.add(doc.id);
|
|
170
|
-
|
|
171
|
-
// ── O(n) equality check: size first (fast path), then membership ──
|
|
184
|
+
// O(n) equality — same Set ref if unchanged → no re-render
|
|
172
185
|
setSelectedIds((prev) => {
|
|
173
186
|
if (prev.size !== newSelected.size)
|
|
174
187
|
return newSelected;
|
|
175
|
-
for (const id of newSelected)
|
|
188
|
+
for (const id of newSelected)
|
|
176
189
|
if (!prev.has(id))
|
|
177
|
-
return newSelected;
|
|
178
|
-
|
|
179
|
-
return prev; // identical — return same reference, no re-render
|
|
190
|
+
return newSelected;
|
|
191
|
+
return prev;
|
|
180
192
|
});
|
|
181
193
|
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return e.touches[0];
|
|
187
|
-
return e;
|
|
188
|
-
};
|
|
189
|
-
const handleDragStart = (itemId, e) => {
|
|
190
|
-
// 1. Safety check for the Fabric instance
|
|
191
|
-
const canvas = fabricCanvas?.current;
|
|
192
|
-
if (!canvas)
|
|
193
|
-
return;
|
|
194
|
-
// 2. Normalize the event (Touch vs Mouse)
|
|
195
|
-
if (e.cancelable)
|
|
196
|
-
e.preventDefault();
|
|
197
|
-
const pointer = getPointerEvent(e);
|
|
198
|
-
// 3. Determine which items are being dragged
|
|
199
|
-
// selection update DOES NOT trigger before drag snapshot
|
|
200
|
-
let itemsToDrag;
|
|
201
|
-
if (selectedIds.has(itemId)) {
|
|
202
|
-
itemsToDrag = Array.from(selectedIds);
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
itemsToDrag = [itemId];
|
|
206
|
-
}
|
|
207
|
-
// 4. Capture current World Transform (Zoom & Pan)
|
|
208
|
-
// We read directly from the canvas to ensure zero-frame lag
|
|
209
|
-
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
210
|
-
const liveZoom = vpt[0];
|
|
211
|
-
const liveVpX = vpt[4];
|
|
212
|
-
const liveVpY = vpt[5];
|
|
213
|
-
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
214
|
-
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
215
|
-
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
216
|
-
// 6. Get the clicked item's current World Position
|
|
217
|
-
const clickedPos = getItemPosition(itemId);
|
|
218
|
-
if (!clickedPos)
|
|
219
|
-
return;
|
|
220
|
-
// 7. Calculate the Offset in WORLD UNITS
|
|
221
|
-
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
222
|
-
// This value remains constant even if you zoom during the drag.
|
|
223
|
-
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
224
|
-
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
225
|
-
// 8. Snapshot starting positions for all selected HTML nodes
|
|
226
|
-
const startPositions = new Map();
|
|
227
|
-
itemsToDrag.forEach((id) => {
|
|
228
|
-
const pos = getItemPosition(id);
|
|
229
|
-
if (pos)
|
|
230
|
-
startPositions.set(id, pos);
|
|
231
|
-
});
|
|
232
|
-
// 9. Snapshot starting positions for all selected Fabric objects
|
|
233
|
-
const canvasObjectsStartPos = new Map();
|
|
234
|
-
selectedCanvasObjects.forEach((obj) => {
|
|
235
|
-
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
236
|
-
});
|
|
237
|
-
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
238
|
-
dragStateRef.current = {
|
|
239
|
-
isDragging: true,
|
|
240
|
-
itemIds: itemsToDrag,
|
|
241
|
-
startPositions,
|
|
242
|
-
canvasObjectsStartPos,
|
|
243
|
-
offsetX: worldOffsetX, // Now stored as World Units
|
|
244
|
-
offsetY: worldOffsetY, // Now stored as World Units
|
|
245
|
-
};
|
|
246
|
-
if (!selectedIds.has(itemId) && dragStateRef.current.itemIds.length === 0) {
|
|
247
|
-
setSelectedIds(new Set([itemId]));
|
|
248
|
-
}
|
|
249
|
-
// 11. Trigger UI states
|
|
250
|
-
setDragging({ itemIds: itemsToDrag });
|
|
251
|
-
document.body.style.cursor = "grabbing";
|
|
252
|
-
document.body.style.userSelect = "none";
|
|
253
|
-
document.body.style.touchAction = "none";
|
|
254
|
-
};
|
|
255
|
-
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
194
|
+
// ─── Global drag listeners — registered ONCE on mount ──────────────────────
|
|
195
|
+
// Critical: must NOT be in a useEffect with [localTasks] or any state deps.
|
|
196
|
+
// Every setLocalTasks during drag would tear down + reattach mousemove → jump.
|
|
197
|
+
// All values read via refs so the [] closure is always correct.
|
|
256
198
|
useEffect(() => {
|
|
257
|
-
if (!dragging)
|
|
258
|
-
return;
|
|
259
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
260
199
|
const handleMove = (e) => {
|
|
261
200
|
if (!dragStateRef.current.isDragging)
|
|
262
201
|
return;
|
|
263
202
|
if (e.cancelable)
|
|
264
203
|
e.preventDefault();
|
|
265
|
-
const pointer =
|
|
266
|
-
|
|
204
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
205
|
+
? e.touches[0] : e;
|
|
267
206
|
if (rafIdRef.current !== null)
|
|
268
207
|
cancelAnimationFrame(rafIdRef.current);
|
|
269
208
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -271,59 +210,48 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
271
210
|
const canvas = fabricCanvas?.current;
|
|
272
211
|
if (!canvas)
|
|
273
212
|
return;
|
|
274
|
-
// 2. Read the "Source of Truth" transform from the canvas
|
|
275
213
|
const vpt = canvas.viewportTransform;
|
|
276
|
-
const liveZoom = vpt[0];
|
|
277
|
-
const liveVpX = vpt[4];
|
|
278
|
-
const liveVpY = vpt[5];
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
// 4. Calculate where the "Anchor" node should be in World Units
|
|
283
|
-
// (Current Mouse World - Initial World Offset from Start)
|
|
284
|
-
const newWorldX = currentWorldX - offsetX;
|
|
285
|
-
const newWorldY = currentWorldY - offsetY;
|
|
286
|
-
// 5. Calculate the Movement Delta in World Units
|
|
287
|
-
// We compare where the first item started vs where it is now.
|
|
288
|
-
const firstId = itemIds[0];
|
|
289
|
-
const firstStart = startPositions.get(firstId);
|
|
214
|
+
const liveZoom = vpt[0];
|
|
215
|
+
const liveVpX = vpt[4];
|
|
216
|
+
const liveVpY = vpt[5];
|
|
217
|
+
const newWorldX = (pointer.clientX - liveVpX) / liveZoom - offsetX;
|
|
218
|
+
const newWorldY = (pointer.clientY - liveVpY) / liveZoom - offsetY;
|
|
219
|
+
const firstStart = startPositions.get(itemIds[0]);
|
|
290
220
|
if (!firstStart)
|
|
291
221
|
return;
|
|
292
222
|
const deltaX = newWorldX - firstStart.x;
|
|
293
223
|
const deltaY = newWorldY - firstStart.y;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
} : d));
|
|
305
|
-
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
224
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
225
|
+
? { ...t,
|
|
226
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
227
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
|
|
228
|
+
: t));
|
|
229
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
|
|
230
|
+
? { ...d,
|
|
231
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
232
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
|
|
233
|
+
: d));
|
|
306
234
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
307
|
-
obj.set({
|
|
308
|
-
|
|
309
|
-
top: startPos.top + deltaY,
|
|
310
|
-
});
|
|
311
|
-
obj.setCoords(); // Required for selection/intersection accuracy
|
|
235
|
+
obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
|
|
236
|
+
obj.setCoords();
|
|
312
237
|
});
|
|
313
|
-
// 8. Single render call for all Fabric changes
|
|
314
238
|
canvas.requestRenderAll();
|
|
315
239
|
});
|
|
316
240
|
};
|
|
317
241
|
const handleEnd = () => {
|
|
318
|
-
if (
|
|
242
|
+
if (!dragStateRef.current.isDragging)
|
|
243
|
+
return;
|
|
244
|
+
if (rafIdRef.current !== null) {
|
|
319
245
|
cancelAnimationFrame(rafIdRef.current);
|
|
246
|
+
rafIdRef.current = null;
|
|
247
|
+
}
|
|
320
248
|
dragStateRef.current.isDragging = false;
|
|
321
|
-
setDragging(null);
|
|
322
249
|
document.body.style.cursor = "";
|
|
323
250
|
document.body.style.userSelect = "";
|
|
324
251
|
document.body.style.touchAction = "";
|
|
325
|
-
|
|
326
|
-
|
|
252
|
+
// Flush via functional updater (reads latest state) + callback ref (never stale)
|
|
253
|
+
setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
|
|
254
|
+
setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
|
|
327
255
|
};
|
|
328
256
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
329
257
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -337,9 +265,62 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
337
265
|
window.removeEventListener("touchend", handleEnd);
|
|
338
266
|
window.removeEventListener("touchcancel", handleEnd);
|
|
339
267
|
};
|
|
340
|
-
}, [
|
|
341
|
-
//
|
|
342
|
-
|
|
268
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
269
|
+
// ─── Drag start ────────────────────────────────────────────────────────────
|
|
270
|
+
// useCallback: stable ref so MemoTaskNode/MemoDocumentNode don't re-render
|
|
271
|
+
// on unrelated parent state changes.
|
|
272
|
+
const handleDragStart = useCallback((itemId, e) => {
|
|
273
|
+
const canvas = fabricCanvas?.current;
|
|
274
|
+
if (!canvas)
|
|
275
|
+
return;
|
|
276
|
+
if (e.cancelable)
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const pointer = "touches" in e && e.touches.length > 0
|
|
279
|
+
? e.touches[0] : e;
|
|
280
|
+
// Read from ref — never stale even if setSelectedIds was called this render
|
|
281
|
+
const currentSelected = selectedIdsRef.current;
|
|
282
|
+
const itemsToDrag = currentSelected.has(itemId)
|
|
283
|
+
? Array.from(currentSelected)
|
|
284
|
+
: [itemId];
|
|
285
|
+
if (!currentSelected.has(itemId)) {
|
|
286
|
+
setSelectedIds(new Set([itemId]));
|
|
287
|
+
}
|
|
288
|
+
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
289
|
+
const liveZoom = vpt[0];
|
|
290
|
+
const liveVpX = vpt[4];
|
|
291
|
+
const liveVpY = vpt[5];
|
|
292
|
+
// O(1) ref lookup — no stale state scan
|
|
293
|
+
const clickedPos = nodePositionsRef.current.get(itemId);
|
|
294
|
+
if (!clickedPos)
|
|
295
|
+
return;
|
|
296
|
+
const startPositions = new Map();
|
|
297
|
+
for (const id of itemsToDrag) {
|
|
298
|
+
const pos = nodePositionsRef.current.get(id);
|
|
299
|
+
if (pos)
|
|
300
|
+
startPositions.set(id, { x: pos.x, y: pos.y });
|
|
301
|
+
}
|
|
302
|
+
// Read Fabric objects from ref — not stale prop
|
|
303
|
+
const canvasObjectsStartPos = new Map();
|
|
304
|
+
for (const obj of selectedCanvasObjectsRef.current) {
|
|
305
|
+
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
306
|
+
}
|
|
307
|
+
// Write only to ref — zero re-render on drag start (eliminates listener churn)
|
|
308
|
+
dragStateRef.current = {
|
|
309
|
+
isDragging: true,
|
|
310
|
+
itemIds: itemsToDrag,
|
|
311
|
+
startPositions,
|
|
312
|
+
canvasObjectsStartPos,
|
|
313
|
+
offsetX: (pointer.clientX - liveVpX) / liveZoom - clickedPos.x,
|
|
314
|
+
offsetY: (pointer.clientY - liveVpY) / liveZoom - clickedPos.y,
|
|
315
|
+
fabricDragHtmlIds: new Set(),
|
|
316
|
+
};
|
|
317
|
+
document.body.style.cursor = "grabbing";
|
|
318
|
+
document.body.style.userSelect = "none";
|
|
319
|
+
document.body.style.touchAction = "none";
|
|
320
|
+
}, [fabricCanvas]);
|
|
321
|
+
// ─── Node interaction handlers ─────────────────────────────────────────────
|
|
322
|
+
// useCallback: stable refs prevent unnecessary re-renders of memoized nodes
|
|
323
|
+
const handleSelect = useCallback((id, e) => {
|
|
343
324
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
344
325
|
setSelectedIds((prev) => {
|
|
345
326
|
const next = new Set(prev);
|
|
@@ -350,68 +331,65 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
350
331
|
else {
|
|
351
332
|
setSelectedIds(new Set([id]));
|
|
352
333
|
}
|
|
353
|
-
};
|
|
354
|
-
const handleStatusChange = (taskId, newStatus) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
334
|
+
}, []);
|
|
335
|
+
const handleStatusChange = useCallback((taskId, newStatus) => {
|
|
336
|
+
setLocalTasks((prev) => {
|
|
337
|
+
const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
|
|
338
|
+
onTasksUpdateRef.current?.(updated);
|
|
339
|
+
return updated;
|
|
340
|
+
});
|
|
341
|
+
}, []);
|
|
342
|
+
// ─── Keyboard shortcuts ────────────────────────────────────────────────────
|
|
343
|
+
// selectedIds removed from deps — read via selectedIdsRef (prevents
|
|
344
|
+
// re-registering the keydown listener on every single selection change)
|
|
359
345
|
useEffect(() => {
|
|
360
346
|
const handleKeyDown = (e) => {
|
|
361
|
-
// Don't trigger if typing in input
|
|
362
347
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
363
348
|
return;
|
|
364
|
-
// Select All
|
|
365
349
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
366
350
|
e.preventDefault();
|
|
367
|
-
setSelectedIds(new Set([
|
|
351
|
+
setSelectedIds(new Set([
|
|
352
|
+
...localTasks.map((t) => t.id),
|
|
353
|
+
...localDocuments.map((d) => d.id),
|
|
354
|
+
]));
|
|
368
355
|
}
|
|
369
|
-
// Clear selection
|
|
370
356
|
if (e.key === "Escape") {
|
|
371
357
|
setSelectedIds(new Set());
|
|
372
358
|
}
|
|
373
|
-
|
|
374
|
-
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
359
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
|
|
375
360
|
e.preventDefault();
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
361
|
+
const ids = selectedIdsRef.current;
|
|
362
|
+
setLocalTasks((prev) => {
|
|
363
|
+
const u = prev.filter((t) => !ids.has(t.id));
|
|
364
|
+
onTasksUpdateRef.current?.(u);
|
|
365
|
+
return u;
|
|
366
|
+
});
|
|
367
|
+
setLocalDocuments((prev) => {
|
|
368
|
+
const u = prev.filter((d) => !ids.has(d.id));
|
|
369
|
+
onDocumentsUpdateRef.current?.(u);
|
|
370
|
+
return u;
|
|
371
|
+
});
|
|
380
372
|
setSelectedIds(new Set());
|
|
381
|
-
onTasksUpdate?.(updatedTasks);
|
|
382
|
-
onDocumentsUpdate?.(updatedDocs);
|
|
383
373
|
}
|
|
384
374
|
};
|
|
385
375
|
window.addEventListener("keydown", handleKeyDown);
|
|
386
376
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
387
|
-
}, [localTasks, localDocuments
|
|
388
|
-
//
|
|
389
|
-
const renderItem = (id, x, y, children) => {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// 2. Use translate3d for GPU performance
|
|
400
|
-
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
401
|
-
transformOrigin: "top left",
|
|
402
|
-
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
403
|
-
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
404
|
-
transition: "none",
|
|
405
|
-
// 4. Optimization
|
|
406
|
-
willChange: "transform",
|
|
407
|
-
zIndex: isDragging ? 1000 : 1,
|
|
408
|
-
}, children: children }, id));
|
|
409
|
-
};
|
|
410
|
-
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
377
|
+
}, [localTasks, localDocuments]);
|
|
378
|
+
// ─── Render ────────────────────────────────────────────────────────────────
|
|
379
|
+
const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
380
|
+
left: 0,
|
|
381
|
+
top: 0,
|
|
382
|
+
transform: `translate3d(${x * canvasZoom}px, ${y * canvasZoom}px, 0) scale(${canvasZoom})`,
|
|
383
|
+
transformOrigin: "top left",
|
|
384
|
+
transition: "none",
|
|
385
|
+
willChange: "transform",
|
|
386
|
+
zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
|
|
387
|
+
}, children: children }, id));
|
|
388
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
|
|
411
389
|
if (e.target === e.currentTarget)
|
|
412
390
|
setSelectedIds(new Set());
|
|
413
391
|
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|
|
414
392
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
415
393
|
transformOrigin: "top left",
|
|
416
|
-
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(
|
|
394
|
+
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(MemoTaskNode, { ...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(MemoDocumentNode, { ...doc, isSelected: selectedIds.has(doc.id), onSelect: handleSelect, onDragStart: handleDragStart })))] }) }));
|
|
417
395
|
}
|