@mhamz.01/easyflow-whiteboard 2.36.0 → 2.38.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;AAS9C,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;AAaD,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,2CA6hBzB"}
|
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef
|
|
3
|
+
import { useState, useEffect, useRef } 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);
|
|
11
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
12
7
|
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
13
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
14
9
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
15
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
16
|
-
|
|
17
|
-
// which tore down/reattached the mousemove listener mid-gesture (jump bug).
|
|
18
|
-
// All drag lifecycle now lives exclusively in dragStateRef.
|
|
19
|
-
// ─── Refs ──────────────────────────────────────────────────────────────────
|
|
11
|
+
const [dragging, setDragging] = useState(null);
|
|
20
12
|
const dragStateRef = useRef({
|
|
21
13
|
isDragging: false,
|
|
22
14
|
itemIds: [],
|
|
@@ -24,59 +16,76 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
24
16
|
canvasObjectsStartPos: new Map(),
|
|
25
17
|
offsetX: 0,
|
|
26
18
|
offsetY: 0,
|
|
27
|
-
fabricDragHtmlIds: new Set(),
|
|
28
19
|
});
|
|
29
20
|
const rafIdRef = useRef(null);
|
|
30
21
|
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.
|
|
34
22
|
const selectedIdsRef = useRef(selectedIds);
|
|
35
23
|
selectedIdsRef.current = selectedIds;
|
|
36
|
-
//
|
|
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 ──────────────────────────────────────────────
|
|
24
|
+
// ── Sync props → local state ────────────────────────────────────────────────
|
|
51
25
|
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
52
26
|
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
27
|
+
// ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
|
|
28
|
+
const handleOverlayWheel = (e) => {
|
|
29
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
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
|
+
};
|
|
56
53
|
useEffect(() => {
|
|
57
54
|
const overlayEl = overlayRef.current;
|
|
58
55
|
const canvas = fabricCanvas?.current;
|
|
59
56
|
if (!overlayEl || !canvas)
|
|
60
57
|
return;
|
|
61
58
|
const handleGlobalWheel = (e) => {
|
|
62
|
-
|
|
59
|
+
// Check if the user is hovering over an element that has pointer-events: auto
|
|
60
|
+
// (meaning they are hovering over a Task or Document)
|
|
61
|
+
const target = e.target;
|
|
62
|
+
const isOverNode = target !== overlayEl;
|
|
63
63
|
if ((e.ctrlKey || e.metaKey) && isOverNode) {
|
|
64
|
+
// 1. Prevent Browser Zoom immediately
|
|
64
65
|
e.preventDefault();
|
|
65
66
|
e.stopPropagation();
|
|
67
|
+
// 2. Calculate coordinates for Fabric
|
|
66
68
|
const scenePoint = canvas.getScenePoint(e);
|
|
67
69
|
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
|
|
68
75
|
canvas.fire("mouse:wheel", {
|
|
69
|
-
e,
|
|
76
|
+
e: e,
|
|
70
77
|
scenePoint,
|
|
71
|
-
viewportPoint
|
|
78
|
+
viewportPoint,
|
|
72
79
|
});
|
|
73
80
|
}
|
|
74
81
|
};
|
|
82
|
+
// CRITICAL: { passive: false } allows us to cancel the browser's zoom
|
|
75
83
|
overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
|
76
|
-
return () =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
84
|
+
return () => {
|
|
85
|
+
overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
86
|
+
};
|
|
87
|
+
}, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
|
|
88
|
+
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
80
89
|
useEffect(() => {
|
|
81
90
|
const canvas = fabricCanvas?.current;
|
|
82
91
|
if (!canvas)
|
|
@@ -85,39 +94,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
85
94
|
const target = e.transform?.target || e.target;
|
|
86
95
|
if (!target)
|
|
87
96
|
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
|
-
}
|
|
100
97
|
const deltaX = target.left - (target._prevLeft ?? target.left);
|
|
101
98
|
const deltaY = target.top - (target._prevTop ?? target.top);
|
|
102
99
|
target._prevLeft = target.left;
|
|
103
100
|
target._prevTop = target.top;
|
|
104
101
|
if (deltaX === 0 && deltaY === 0)
|
|
105
102
|
return;
|
|
106
|
-
//
|
|
107
|
-
const sel =
|
|
108
|
-
if (sel.size === 0)
|
|
109
|
-
return;
|
|
103
|
+
// ── Read from ref — always fresh, never stale ──
|
|
104
|
+
const sel = selectedIdsRef.current;
|
|
110
105
|
setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
|
|
111
106
|
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
|
|
112
107
|
};
|
|
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
|
-
};
|
|
121
108
|
const handleMouseDown = (e) => {
|
|
122
109
|
const target = e.target;
|
|
123
110
|
if (target) {
|
|
@@ -125,84 +112,160 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
125
112
|
target._prevTop = target.top;
|
|
126
113
|
}
|
|
127
114
|
if (!target) {
|
|
128
|
-
// Clicked empty canvas — always deselect HTML nodes
|
|
129
115
|
setSelectedIds(new Set());
|
|
130
116
|
return;
|
|
131
117
|
}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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) {
|
|
118
|
+
// ── Read from ref — not stale closure ──
|
|
119
|
+
const activeObject = canvas.getActiveObject(); // the group/selection box
|
|
120
|
+
const activeObjects = canvas.getActiveObjects(); // individual objects inside it
|
|
121
|
+
const isTargetAlreadySelected = activeObjects.includes(target) || // clicked an individual selected object
|
|
122
|
+
activeObject === target; // clicked the selection box itself
|
|
123
|
+
if (!isTargetAlreadySelected) {
|
|
148
124
|
setSelectedIds(new Set());
|
|
149
125
|
}
|
|
150
126
|
};
|
|
151
127
|
canvas.on("object:moving", handleObjectMoving);
|
|
152
|
-
canvas.on("object:modified", handleObjectModified);
|
|
153
128
|
canvas.on("mouse:down", handleMouseDown);
|
|
154
|
-
canvas.on("selection:created", handleFabricSelection);
|
|
155
|
-
canvas.on("selection:updated", handleFabricSelection);
|
|
156
129
|
return () => {
|
|
157
130
|
canvas.off("object:moving", handleObjectMoving);
|
|
158
|
-
canvas.off("object:modified", handleObjectModified);
|
|
159
131
|
canvas.off("mouse:down", handleMouseDown);
|
|
160
|
-
canvas.off("selection:created", handleFabricSelection);
|
|
161
|
-
canvas.off("selection:updated", handleFabricSelection);
|
|
162
132
|
};
|
|
163
|
-
|
|
164
|
-
|
|
133
|
+
// ── selectedIds REMOVED from deps — read via selectedIdsRef instead ──────
|
|
134
|
+
// Having selectedIds here caused the effect to re-register on every selection
|
|
135
|
+
// change, creating a new closure each time. The second drag captured a stale
|
|
136
|
+
// or empty selectedIds from the closure at re-registration time.
|
|
137
|
+
}, [canvasZoom, fabricCanvas]);
|
|
138
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
139
|
+
const getItemPosition = (id) => {
|
|
140
|
+
const task = localTasks.find((t) => t.id === id);
|
|
141
|
+
if (task)
|
|
142
|
+
return { x: task.x, y: task.y };
|
|
143
|
+
const doc = localDocuments.find((d) => d.id === id);
|
|
144
|
+
if (doc)
|
|
145
|
+
return { x: doc.x, y: doc.y };
|
|
146
|
+
return undefined;
|
|
147
|
+
};
|
|
148
|
+
const isItemInSelectionBox = (x, y, width, height, box) => {
|
|
149
|
+
const itemX1 = x * canvasZoom + canvasViewport.x;
|
|
150
|
+
const itemY1 = y * canvasZoom + canvasViewport.y;
|
|
151
|
+
const itemX2 = itemX1 + width * canvasZoom;
|
|
152
|
+
const itemY2 = itemY1 + height * canvasZoom;
|
|
153
|
+
const boxX1 = Math.min(box.x1, box.x2);
|
|
154
|
+
const boxY1 = Math.min(box.y1, box.y2);
|
|
155
|
+
const boxX2 = Math.max(box.x1, box.x2);
|
|
156
|
+
const boxY2 = Math.max(box.y1, box.y2);
|
|
157
|
+
return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
|
|
158
|
+
};
|
|
159
|
+
// ── Selection box detection ──────────────────────────────────────────────────
|
|
165
160
|
useEffect(() => {
|
|
166
161
|
if (!selectionBox)
|
|
167
162
|
return;
|
|
168
|
-
|
|
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
|
-
};
|
|
163
|
+
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
177
164
|
const newSelected = new Set();
|
|
178
|
-
for (const task of localTasks)
|
|
179
|
-
if (
|
|
165
|
+
for (const task of localTasks) {
|
|
166
|
+
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
180
167
|
newSelected.add(task.id);
|
|
181
|
-
|
|
182
|
-
|
|
168
|
+
}
|
|
169
|
+
for (const doc of localDocuments) {
|
|
170
|
+
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
183
171
|
newSelected.add(doc.id);
|
|
184
|
-
|
|
172
|
+
}
|
|
173
|
+
// ── O(n) equality check: size first (fast path), then membership ──
|
|
185
174
|
setSelectedIds((prev) => {
|
|
186
175
|
if (prev.size !== newSelected.size)
|
|
187
176
|
return newSelected;
|
|
188
|
-
for (const id of newSelected)
|
|
177
|
+
for (const id of newSelected) {
|
|
189
178
|
if (!prev.has(id))
|
|
190
|
-
return newSelected;
|
|
191
|
-
|
|
179
|
+
return newSelected; // found a difference, swap
|
|
180
|
+
}
|
|
181
|
+
return prev; // identical — return same reference, no re-render
|
|
192
182
|
});
|
|
193
183
|
}, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
184
|
+
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
185
|
+
// Helper to extract coordinates regardless of event type
|
|
186
|
+
const getPointerEvent = (e) => {
|
|
187
|
+
if ('touches' in e && e.touches.length > 0)
|
|
188
|
+
return e.touches[0];
|
|
189
|
+
return e;
|
|
190
|
+
};
|
|
191
|
+
const handleDragStart = (itemId, e) => {
|
|
192
|
+
// 1. Safety check for the Fabric instance
|
|
193
|
+
const canvas = fabricCanvas?.current;
|
|
194
|
+
if (!canvas)
|
|
195
|
+
return;
|
|
196
|
+
// 2. Normalize the event (Touch vs Mouse)
|
|
197
|
+
if (e.cancelable)
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const pointer = getPointerEvent(e);
|
|
200
|
+
// 3. Determine which items are being dragged
|
|
201
|
+
// selection update DOES NOT trigger before drag snapshot
|
|
202
|
+
let itemsToDrag;
|
|
203
|
+
if (selectedIds.has(itemId)) {
|
|
204
|
+
itemsToDrag = Array.from(selectedIds);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
itemsToDrag = [itemId];
|
|
208
|
+
}
|
|
209
|
+
// 4. Capture current World Transform (Zoom & Pan)
|
|
210
|
+
// We read directly from the canvas to ensure zero-frame lag
|
|
211
|
+
const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
212
|
+
const liveZoom = vpt[0];
|
|
213
|
+
const liveVpX = vpt[4];
|
|
214
|
+
const liveVpY = vpt[5];
|
|
215
|
+
// 5. Convert the Click Position from Screen Pixels to World Units
|
|
216
|
+
const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
217
|
+
const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
218
|
+
// 6. Get the clicked item's current World Position
|
|
219
|
+
const clickedPos = getItemPosition(itemId);
|
|
220
|
+
if (!clickedPos)
|
|
221
|
+
return;
|
|
222
|
+
// 7. Calculate the Offset in WORLD UNITS
|
|
223
|
+
// This is the distance from the mouse to the node's top-left in the infinite grid.
|
|
224
|
+
// This value remains constant even if you zoom during the drag.
|
|
225
|
+
const worldOffsetX = clickWorldX - clickedPos.x;
|
|
226
|
+
const worldOffsetY = clickWorldY - clickedPos.y;
|
|
227
|
+
// 8. Snapshot starting positions for all selected HTML nodes
|
|
228
|
+
const startPositions = new Map();
|
|
229
|
+
itemsToDrag.forEach((id) => {
|
|
230
|
+
const pos = getItemPosition(id);
|
|
231
|
+
if (pos)
|
|
232
|
+
startPositions.set(id, pos);
|
|
233
|
+
});
|
|
234
|
+
// 9. Snapshot starting positions for all selected Fabric objects
|
|
235
|
+
const canvasObjectsStartPos = new Map();
|
|
236
|
+
selectedCanvasObjects.forEach((obj) => {
|
|
237
|
+
canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
|
|
238
|
+
});
|
|
239
|
+
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
240
|
+
dragStateRef.current = {
|
|
241
|
+
isDragging: true,
|
|
242
|
+
itemIds: itemsToDrag,
|
|
243
|
+
startPositions,
|
|
244
|
+
canvasObjectsStartPos,
|
|
245
|
+
offsetX: worldOffsetX, // Now stored as World Units
|
|
246
|
+
offsetY: worldOffsetY, // Now stored as World Units
|
|
247
|
+
};
|
|
248
|
+
if (!selectedIds.has(itemId) && dragStateRef.current.itemIds.length === 0) {
|
|
249
|
+
setSelectedIds(new Set([itemId]));
|
|
250
|
+
}
|
|
251
|
+
// 11. Trigger UI states
|
|
252
|
+
setDragging({ itemIds: itemsToDrag });
|
|
253
|
+
document.body.style.cursor = "grabbing";
|
|
254
|
+
document.body.style.userSelect = "none";
|
|
255
|
+
document.body.style.touchAction = "none";
|
|
256
|
+
};
|
|
257
|
+
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
198
258
|
useEffect(() => {
|
|
259
|
+
if (!dragging)
|
|
260
|
+
return;
|
|
261
|
+
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
199
262
|
const handleMove = (e) => {
|
|
200
263
|
if (!dragStateRef.current.isDragging)
|
|
201
264
|
return;
|
|
202
265
|
if (e.cancelable)
|
|
203
266
|
e.preventDefault();
|
|
204
|
-
const pointer =
|
|
205
|
-
|
|
267
|
+
const pointer = getPointerEvent(e);
|
|
268
|
+
// 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
|
|
206
269
|
if (rafIdRef.current !== null)
|
|
207
270
|
cancelAnimationFrame(rafIdRef.current);
|
|
208
271
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
@@ -210,48 +273,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
210
273
|
const canvas = fabricCanvas?.current;
|
|
211
274
|
if (!canvas)
|
|
212
275
|
return;
|
|
276
|
+
// 2. Read the "Source of Truth" transform from the canvas
|
|
213
277
|
const vpt = canvas.viewportTransform;
|
|
214
|
-
const liveZoom = vpt[0];
|
|
215
|
-
const liveVpX = vpt[4];
|
|
216
|
-
const liveVpY = vpt[5];
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
const
|
|
278
|
+
const liveZoom = vpt[0]; // Scale
|
|
279
|
+
const liveVpX = vpt[4]; // Pan X
|
|
280
|
+
const liveVpY = vpt[5]; // Pan Y
|
|
281
|
+
// 3. Convert current Mouse Screen Position → World Position
|
|
282
|
+
const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
|
|
283
|
+
const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
|
|
284
|
+
// 4. Calculate where the "Anchor" node should be in World Units
|
|
285
|
+
// (Current Mouse World - Initial World Offset from Start)
|
|
286
|
+
const newWorldX = currentWorldX - offsetX;
|
|
287
|
+
const newWorldY = currentWorldY - offsetY;
|
|
288
|
+
// 5. Calculate the Movement Delta in World Units
|
|
289
|
+
// We compare where the first item started vs where it is now.
|
|
290
|
+
const firstId = itemIds[0];
|
|
291
|
+
const firstStart = startPositions.get(firstId);
|
|
220
292
|
if (!firstStart)
|
|
221
293
|
return;
|
|
222
294
|
const deltaX = newWorldX - firstStart.x;
|
|
223
295
|
const deltaY = newWorldY - firstStart.y;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
: t))
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
: d))
|
|
296
|
+
// 6. Update HTML Nodes (Batching these into one state update)
|
|
297
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
|
|
298
|
+
...t,
|
|
299
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
300
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
301
|
+
} : t));
|
|
302
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
|
|
303
|
+
...d,
|
|
304
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
305
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
306
|
+
} : d));
|
|
307
|
+
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
234
308
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
235
|
-
obj.set({
|
|
236
|
-
|
|
309
|
+
obj.set({
|
|
310
|
+
left: startPos.left + deltaX,
|
|
311
|
+
top: startPos.top + deltaY,
|
|
312
|
+
});
|
|
313
|
+
obj.setCoords(); // Required for selection/intersection accuracy
|
|
237
314
|
});
|
|
315
|
+
// 8. Single render call for all Fabric changes
|
|
238
316
|
canvas.requestRenderAll();
|
|
239
317
|
});
|
|
240
318
|
};
|
|
241
319
|
const handleEnd = () => {
|
|
242
|
-
if (
|
|
243
|
-
return;
|
|
244
|
-
if (rafIdRef.current !== null) {
|
|
320
|
+
if (rafIdRef.current !== null)
|
|
245
321
|
cancelAnimationFrame(rafIdRef.current);
|
|
246
|
-
rafIdRef.current = null;
|
|
247
|
-
}
|
|
248
322
|
dragStateRef.current.isDragging = false;
|
|
323
|
+
setDragging(null);
|
|
249
324
|
document.body.style.cursor = "";
|
|
250
325
|
document.body.style.userSelect = "";
|
|
251
326
|
document.body.style.touchAction = "";
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
|
|
327
|
+
onTasksUpdate?.(localTasks);
|
|
328
|
+
onDocumentsUpdate?.(localDocuments);
|
|
255
329
|
};
|
|
256
330
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
257
331
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -265,62 +339,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
265
339
|
window.removeEventListener("touchend", handleEnd);
|
|
266
340
|
window.removeEventListener("touchcancel", handleEnd);
|
|
267
341
|
};
|
|
268
|
-
}, []);
|
|
269
|
-
//
|
|
270
|
-
|
|
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) => {
|
|
342
|
+
}, [dragging, localTasks, localDocuments, fabricCanvas]);
|
|
343
|
+
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
344
|
+
const handleSelect = (id, e) => {
|
|
324
345
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
325
346
|
setSelectedIds((prev) => {
|
|
326
347
|
const next = new Set(prev);
|
|
@@ -331,65 +352,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
331
352
|
else {
|
|
332
353
|
setSelectedIds(new Set([id]));
|
|
333
354
|
}
|
|
334
|
-
}
|
|
335
|
-
const handleStatusChange =
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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)
|
|
355
|
+
};
|
|
356
|
+
const handleStatusChange = (taskId, newStatus) => {
|
|
357
|
+
const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
|
|
358
|
+
setLocalTasks(updated);
|
|
359
|
+
onTasksUpdate?.(updated);
|
|
360
|
+
};
|
|
345
361
|
useEffect(() => {
|
|
346
362
|
const handleKeyDown = (e) => {
|
|
363
|
+
// Don't trigger if typing in input
|
|
347
364
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
348
365
|
return;
|
|
366
|
+
// Select All
|
|
349
367
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
350
368
|
e.preventDefault();
|
|
351
|
-
setSelectedIds(new Set([
|
|
352
|
-
...localTasks.map((t) => t.id),
|
|
353
|
-
...localDocuments.map((d) => d.id),
|
|
354
|
-
]));
|
|
369
|
+
setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
|
|
355
370
|
}
|
|
371
|
+
// Clear selection
|
|
356
372
|
if (e.key === "Escape") {
|
|
357
373
|
setSelectedIds(new Set());
|
|
358
374
|
}
|
|
359
|
-
|
|
375
|
+
// ← ADD THIS: Delete selected nodes
|
|
376
|
+
if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
|
|
360
377
|
e.preventDefault();
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
});
|
|
378
|
+
const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
|
|
379
|
+
const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
|
|
380
|
+
setLocalTasks(updatedTasks);
|
|
381
|
+
setLocalDocuments(updatedDocs);
|
|
372
382
|
setSelectedIds(new Set());
|
|
383
|
+
onTasksUpdate?.(updatedTasks);
|
|
384
|
+
onDocumentsUpdate?.(updatedDocs);
|
|
373
385
|
}
|
|
374
386
|
};
|
|
375
387
|
window.addEventListener("keydown", handleKeyDown);
|
|
376
388
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
377
|
-
}, [localTasks, localDocuments]);
|
|
378
|
-
//
|
|
379
|
-
const renderItem = (id, x, y, children) =>
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
}, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
|
|
390
|
+
// ── Render helper ────────────────────────────────────────────────────────────
|
|
391
|
+
const renderItem = (id, x, y, children) => {
|
|
392
|
+
const screenX = x * canvasZoom;
|
|
393
|
+
const screenY = y * canvasZoom;
|
|
394
|
+
// 1. Detect if the user is interacting with the canvas at all
|
|
395
|
+
// 'dragging' is your existing state.
|
|
396
|
+
// You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
|
|
397
|
+
const isDragging = dragging?.itemIds.includes(id);
|
|
398
|
+
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
399
|
+
left: 0,
|
|
400
|
+
top: 0,
|
|
401
|
+
// 2. Use translate3d for GPU performance
|
|
402
|
+
transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
|
|
403
|
+
transformOrigin: "top left",
|
|
404
|
+
// 3. THE FIX: Remove transition entirely during any viewport change
|
|
405
|
+
// Any 'ease' during zoom causes the "shaking" behavior.
|
|
406
|
+
transition: "none",
|
|
407
|
+
// 4. Optimization
|
|
408
|
+
willChange: "transform",
|
|
409
|
+
zIndex: isDragging ? 1000 : 1,
|
|
410
|
+
}, children: children }, id));
|
|
411
|
+
};
|
|
412
|
+
return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
|
|
389
413
|
if (e.target === e.currentTarget)
|
|
390
414
|
setSelectedIds(new Set());
|
|
391
415
|
}, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
|
|
392
416
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
393
417
|
transformOrigin: "top left",
|
|
394
|
-
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(
|
|
418
|
+
}, 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 })))] }) }));
|
|
395
419
|
}
|