@mhamz.01/easyflow-whiteboard 2.9.0 → 2.10.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;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;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,2CAkezB"}
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;AAeD,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,2CA2bzB"}
@@ -1,6 +1,6 @@
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 } from "react";
4
4
  import TaskNode from "./custom-node";
5
5
  import DocumentNode from "./document-node";
6
6
  // ─── Component ────────────────────────────────────────────────────────────────
@@ -8,7 +8,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
8
8
  const [localTasks, setLocalTasks] = useState(tasks);
9
9
  const [localDocuments, setLocalDocuments] = useState(documents);
10
10
  const [selectedIds, setSelectedIds] = useState(new Set());
11
- const [dragging, setDragging] = useState(null);
11
+ // ── Single drag state ref — no useState for dragging ──────────────────────
12
+ // This is the core fix: dragging state lives ONLY in a ref, never triggers
13
+ // re-renders, so the mousemove/mouseup listeners are NEVER torn down mid-drag.
12
14
  const dragStateRef = useRef({
13
15
  isDragging: false,
14
16
  itemIds: [],
@@ -17,92 +19,69 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
17
19
  offsetX: 0,
18
20
  offsetY: 0,
19
21
  });
22
+ // Tracks whether an HTML node drag is active — blocks object:moving sync
23
+ const isHtmlDraggingRef = useRef(false);
20
24
  const rafIdRef = useRef(null);
21
25
  const overlayRef = useRef(null);
22
- // ── Sync props local state ────────────────────────────────────────────────
26
+ // ── Positions ref synchronously updated every render, zero staleness ────
27
+ // Avoids stale closure problem in handleDragStart reading localTasks state
28
+ const nodePositionsRef = useRef(new Map());
29
+ nodePositionsRef.current = new Map([
30
+ ...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
31
+ ...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
32
+ ]);
33
+ // ── selectedIds ref — so object:moving closure always has fresh selectedIds ─
34
+ const selectedIdsRef = useRef(selectedIds);
35
+ selectedIdsRef.current = selectedIds;
36
+ // ── Sync props → local state ───────────────────────────────────────────────
23
37
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
24
38
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
25
- // ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
26
- const handleOverlayWheel = (e) => {
27
- if (e.ctrlKey || e.metaKey || e.shiftKey) {
28
- const canvas = fabricCanvas?.current;
29
- if (!canvas)
30
- return;
31
- const nativeEvent = e.nativeEvent;
32
- // getScenePoint handles the transformation from screen to canvas space
33
- const scenePoint = canvas.getScenePoint(nativeEvent);
34
- // Viewport point is simply the mouse position relative to the canvas element
35
- const rect = canvas.getElement().getBoundingClientRect();
36
- const viewportPoint = {
37
- x: nativeEvent.clientX - rect.left,
38
- y: nativeEvent.clientY - rect.top,
39
- };
40
- // We cast to 'any' here because we are manually triggering an internal
41
- // event bus, and Fabric's internal types for .fire() can be overly strict.
42
- canvas.fire("mouse:wheel", {
43
- e: nativeEvent,
44
- scenePoint,
45
- viewportPoint,
46
- });
47
- e.preventDefault();
48
- e.stopPropagation();
49
- }
50
- };
39
+ // ── Wheel forwarding to Fabric ─────────────────────────────────────────────
51
40
  useEffect(() => {
52
41
  const overlayEl = overlayRef.current;
53
42
  const canvas = fabricCanvas?.current;
54
43
  if (!overlayEl || !canvas)
55
44
  return;
56
45
  const handleGlobalWheel = (e) => {
57
- // Check if the user is hovering over an element that has pointer-events: auto
58
- // (meaning they are hovering over a Task or Document)
59
46
  const target = e.target;
60
47
  const isOverNode = target !== overlayEl;
61
48
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
62
- // 1. Prevent Browser Zoom immediately
63
49
  e.preventDefault();
64
50
  e.stopPropagation();
65
- // 2. Calculate coordinates for Fabric
66
51
  const scenePoint = canvas.getScenePoint(e);
67
52
  const rect = canvas.getElement().getBoundingClientRect();
68
- const viewportPoint = {
69
- x: e.clientX - rect.left,
70
- y: e.clientY - rect.top,
71
- };
72
- // 3. Manually fire the event into Fabric
73
53
  canvas.fire("mouse:wheel", {
74
- e: e,
54
+ e,
75
55
  scenePoint,
76
- viewportPoint,
56
+ viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
77
57
  });
78
58
  }
79
59
  };
80
- // CRITICAL: { passive: false } allows us to cancel the browser's zoom
81
60
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
82
- return () => {
83
- overlayEl.removeEventListener("wheel", handleGlobalWheel);
84
- };
85
- }, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
86
- // ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
61
+ return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
62
+ }, [fabricCanvas]);
63
+ // ── Fabric → Overlay sync (drag Fabric obj, HTML nodes follow) ────────────
87
64
  useEffect(() => {
88
65
  const canvas = fabricCanvas?.current;
89
66
  if (!canvas)
90
67
  return;
91
68
  const handleObjectMoving = (e) => {
69
+ // CRITICAL: Skip entirely during HTML node drag — prevents position fighting
70
+ if (isHtmlDraggingRef.current)
71
+ return;
92
72
  const target = e.transform?.target || e.target;
93
73
  if (!target)
94
74
  return;
95
- // 1. Calculate delta in raw Scene Coordinates
96
- // We do NOT divide by zoom here because target.left/top are world units.
97
75
  const deltaX = target.left - (target._prevLeft ?? target.left);
98
76
  const deltaY = target.top - (target._prevTop ?? target.top);
99
77
  target._prevLeft = target.left;
100
78
  target._prevTop = target.top;
101
79
  if (deltaX === 0 && deltaY === 0)
102
80
  return;
103
- // 2. Apply the raw delta to HTML items
104
- setLocalTasks((prev) => prev.map((t) => (selectedIds.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t)));
105
- setLocalDocuments((prev) => prev.map((d) => (selectedIds.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d)));
81
+ // Use selectedIdsRef always fresh, no stale closure
82
+ const currentSelected = selectedIdsRef.current;
83
+ setLocalTasks((prev) => prev.map((t) => currentSelected.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
84
+ setLocalDocuments((prev) => prev.map((d) => currentSelected.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
106
85
  };
107
86
  const handleMouseDown = (e) => {
108
87
  const target = e.target;
@@ -117,17 +96,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
117
96
  canvas.off("object:moving", handleObjectMoving);
118
97
  canvas.off("mouse:down", handleMouseDown);
119
98
  };
120
- }, [canvasZoom, selectedIds, fabricCanvas]);
121
- // ── Helpers ─────────────────────────────────────────────────────────────────
122
- const getItemPosition = (id) => {
123
- const task = localTasks.find((t) => t.id === id);
124
- if (task)
125
- return { x: task.x, y: task.y };
126
- const doc = localDocuments.find((d) => d.id === id);
127
- if (doc)
128
- return { x: doc.x, y: doc.y };
129
- return undefined;
130
- };
99
+ // No selectedIds in deps — we use selectedIdsRef instead. Stable registration.
100
+ }, [fabricCanvas]);
101
+ // ── Selection box hit detection ────────────────────────────────────────────
131
102
  const isItemInSelectionBox = (x, y, width, height, box) => {
132
103
  const itemX1 = x * canvasZoom + canvasViewport.x;
133
104
  const itemY1 = y * canvasZoom + canvasViewport.y;
@@ -139,11 +110,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
139
110
  const boxY2 = Math.max(box.y1, box.y2);
140
111
  return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
141
112
  };
142
- // ── Selection box detection ──────────────────────────────────────────────────
143
113
  useEffect(() => {
144
114
  if (!selectionBox)
145
115
  return;
146
- // ── O(n) single pass — no sort, no join, no extra allocations ──
147
116
  const newSelected = new Set();
148
117
  for (const task of localTasks) {
149
118
  if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
@@ -153,116 +122,64 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
153
122
  if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
154
123
  newSelected.add(doc.id);
155
124
  }
156
- // ── O(n) equality check: size first (fast path), then membership ──
125
+ // O(n) equality return same ref if unchanged, prevents re-render
157
126
  setSelectedIds((prev) => {
158
127
  if (prev.size !== newSelected.size)
159
128
  return newSelected;
160
- for (const id of newSelected) {
129
+ for (const id of newSelected)
161
130
  if (!prev.has(id))
162
- return newSelected; // found a difference, swap
163
- }
164
- return prev; // identical — return same reference, no re-render
131
+ return newSelected;
132
+ return prev;
165
133
  });
166
134
  }, [selectionBox, localTasks, localDocuments]);
167
- // ── Drag start (HTML Node side) ──────────────────────────────────────────────
168
- // Helper to extract coordinates regardless of event type
169
- const getPointerEvent = (e) => {
170
- if ('touches' in e && e.touches.length > 0)
171
- return e.touches[0];
172
- return e;
173
- };
174
- const handleDragStart = (itemId, e) => {
175
- const canvas = fabricCanvas?.current;
176
- if (!canvas || !e)
177
- return;
178
- // Use the raw native event for the most accurate pointer position
179
- const nativeEvent = 'nativeEvent' in e ? e.nativeEvent : e;
180
- const pointer = getPointerEvent(nativeEvent);
181
- // 1. Get the ABSOLUTE current transform from the canvas engine
182
- const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
183
- const zoom = vpt[0];
184
- const vpX = vpt[4];
185
- const vpY = vpt[5];
186
- // 2. Calculate World Position of the mouse click immediately
187
- const clickWorldX = (pointer.clientX - vpX) / zoom;
188
- const clickWorldY = (pointer.clientY - vpY) / zoom;
189
- // 3. Get the item's current world position
190
- const clickedPos = getItemPosition(itemId);
191
- if (!clickedPos)
192
- return;
193
- // 4. Calculate the WORLD OFFSET
194
- // This is the distance from the mouse to the node's top-left in World Units.
195
- const worldOffsetX = clickWorldX - clickedPos.x;
196
- const worldOffsetY = clickWorldY - clickedPos.y;
197
- // 5. Setup Drag State
198
- let itemsToDrag = selectedIds.has(itemId) ? Array.from(selectedIds) : [itemId];
199
- if (!selectedIds.has(itemId))
200
- setSelectedIds(new Set([itemId]));
201
- const startPositions = new Map();
202
- itemsToDrag.forEach(id => {
203
- const pos = getItemPosition(id);
204
- if (pos)
205
- startPositions.set(id, pos);
206
- });
207
- const canvasObjectsStartPos = new Map();
208
- selectedCanvasObjects.forEach(obj => {
209
- canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
210
- });
211
- dragStateRef.current = {
212
- isDragging: true,
213
- itemIds: itemsToDrag,
214
- startPositions,
215
- canvasObjectsStartPos,
216
- offsetX: worldOffsetX,
217
- offsetY: worldOffsetY,
218
- };
219
- setDragging({ itemIds: itemsToDrag });
220
- };
221
- // ── Drag move (HTML Node side) ───────────────────────────────────────────────
135
+ // ── Global drag listeners attached ONCE, never torn down mid-drag ────────
136
+ // This is the core architectural fix. Instead of putting listeners inside a
137
+ // useEffect that watches [dragging], we attach them ONCE on mount and read
138
+ // dragStateRef inside them. This means there is zero listener churn,
139
+ // zero double-fires, and zero stale closure issues.
222
140
  useEffect(() => {
223
- if (!dragging)
224
- return;
225
- // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
226
141
  const handleMove = (e) => {
227
142
  if (!dragStateRef.current.isDragging)
228
143
  return;
229
144
  if (e.cancelable)
230
145
  e.preventDefault();
231
- const pointer = getPointerEvent(e);
232
- // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
146
+ const pointer = "touches" in e && e.touches.length > 0
147
+ ? e.touches[0]
148
+ : e;
233
149
  if (rafIdRef.current !== null)
234
150
  cancelAnimationFrame(rafIdRef.current);
235
- // Inside handleMove rAF
236
151
  rafIdRef.current = requestAnimationFrame(() => {
237
152
  const { itemIds, startPositions, canvasObjectsStartPos, offsetX, offsetY } = dragStateRef.current;
238
153
  const canvas = fabricCanvas?.current;
239
154
  if (!canvas)
240
155
  return;
156
+ // Read live VPT — frame-perfect, no React state lag
241
157
  const vpt = canvas.viewportTransform;
242
- const zoom = vpt[0];
243
- const vpX = vpt[4];
244
- const vpY = vpt[5];
245
- // Current World position of the mouse
246
- const currentWorldX = (pointer.clientX - vpX) / zoom;
247
- const currentWorldY = (pointer.clientY - vpY) / zoom;
248
- // The new world position of the node we are holding
249
- const newX = currentWorldX - offsetX;
250
- const newY = currentWorldY - offsetY;
251
- const firstId = itemIds[0];
252
- const firstStart = startPositions.get(firstId);
158
+ const liveZoom = vpt[0];
159
+ const liveVpX = vpt[4];
160
+ const liveVpY = vpt[5];
161
+ // Screen World
162
+ const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
163
+ const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
164
+ // Subtract world-space offset → exact world position of node top-left
165
+ const newWorldX = currentWorldX - offsetX;
166
+ const newWorldY = currentWorldY - offsetY;
167
+ // Delta from snapshotted start position of the anchor (first) item
168
+ const firstStart = startPositions.get(itemIds[0]);
253
169
  if (!firstStart)
254
170
  return;
255
- const deltaX = newX - firstStart.x;
256
- const deltaY = newY - firstStart.y;
257
- // UPDATE: Only update if the delta has actually changed to prevent jitter
258
- if (Math.abs(deltaX) < 0.01 && Math.abs(deltaY) < 0.01)
259
- return;
260
- setLocalTasks(prev => prev.map(t => itemIds.includes(t.id) ? {
171
+ const deltaX = newWorldX - firstStart.x;
172
+ const deltaY = newWorldY - firstStart.y;
173
+ setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
261
174
  ...t,
262
175
  x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
263
176
  y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
264
177
  } : t));
265
- // Sync Fabric objects directly
178
+ setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
179
+ ...d,
180
+ x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
181
+ y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
182
+ } : d));
266
183
  canvasObjectsStartPos.forEach((startPos, obj) => {
267
184
  obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
268
185
  obj.setCoords();
@@ -270,16 +187,21 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
270
187
  canvas.requestRenderAll();
271
188
  });
272
189
  };
273
- const handleEnd = () => {
274
- if (rafIdRef.current !== null)
190
+ const handleEnd = (e) => {
191
+ if (!dragStateRef.current.isDragging)
192
+ return;
193
+ if (rafIdRef.current !== null) {
275
194
  cancelAnimationFrame(rafIdRef.current);
195
+ rafIdRef.current = null;
196
+ }
276
197
  dragStateRef.current.isDragging = false;
277
- setDragging(null);
198
+ isHtmlDraggingRef.current = false;
278
199
  document.body.style.cursor = "";
279
200
  document.body.style.userSelect = "";
280
201
  document.body.style.touchAction = "";
281
- onTasksUpdate?.(localTasks);
282
- onDocumentsUpdate?.(localDocuments);
202
+ // Flush final positions to parent
203
+ setLocalTasks((prev) => { onTasksUpdate?.(prev); return prev; });
204
+ setLocalDocuments((prev) => { onDocumentsUpdate?.(prev); return prev; });
283
205
  };
284
206
  window.addEventListener("mousemove", handleMove, { passive: false });
285
207
  window.addEventListener("mouseup", handleEnd);
@@ -293,9 +215,72 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
293
215
  window.removeEventListener("touchend", handleEnd);
294
216
  window.removeEventListener("touchcancel", handleEnd);
295
217
  };
296
- }, [dragging, localTasks, localDocuments, fabricCanvas]);
297
- // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
298
- const handleSelect = (id, e) => {
218
+ // Empty deps — attached once on mount, reads everything via refs
219
+ }, [fabricCanvas, onTasksUpdate, onDocumentsUpdate]);
220
+ // ── Drag start ─────────────────────────────────────────────────────────────
221
+ const handleDragStart = useCallback((itemId, e) => {
222
+ if (e.cancelable)
223
+ e.preventDefault();
224
+ const canvas = fabricCanvas?.current;
225
+ if (!canvas)
226
+ return;
227
+ const pointer = "touches" in e && e.touches.length > 0
228
+ ? e.touches[0]
229
+ : e;
230
+ // Determine drag group
231
+ const currentSelected = selectedIdsRef.current;
232
+ let itemsToDrag;
233
+ if (currentSelected.has(itemId)) {
234
+ itemsToDrag = Array.from(currentSelected);
235
+ }
236
+ else {
237
+ itemsToDrag = [itemId];
238
+ setSelectedIds(new Set([itemId]));
239
+ }
240
+ // Read VPT live — zero lag
241
+ const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
242
+ const liveZoom = vpt[0];
243
+ const liveVpX = vpt[4];
244
+ const liveVpY = vpt[5];
245
+ // Convert pointer → world
246
+ const pointerWorldX = (pointer.clientX - liveVpX) / liveZoom;
247
+ const pointerWorldY = (pointer.clientY - liveVpY) / liveZoom;
248
+ // Read positions from ref — synchronously updated every render, NEVER stale
249
+ const clickedPos = nodePositionsRef.current.get(itemId);
250
+ if (!clickedPos)
251
+ return;
252
+ // World-space offset: how far pointer is from node's top-left, in world units
253
+ const worldOffsetX = pointerWorldX - clickedPos.x;
254
+ const worldOffsetY = pointerWorldY - clickedPos.y;
255
+ // Snapshot all start positions from ref (not state — avoids async lag)
256
+ const startPositions = new Map();
257
+ for (const id of itemsToDrag) {
258
+ const pos = nodePositionsRef.current.get(id);
259
+ if (pos)
260
+ startPositions.set(id, { x: pos.x, y: pos.y });
261
+ }
262
+ // Snapshot Fabric object positions
263
+ const canvasObjectsStartPos = new Map();
264
+ selectedCanvasObjects.forEach((obj) => {
265
+ canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
266
+ });
267
+ // Commit to ref — handleMove reads this, no state update needed
268
+ dragStateRef.current = {
269
+ isDragging: true,
270
+ itemIds: itemsToDrag,
271
+ startPositions,
272
+ canvasObjectsStartPos,
273
+ offsetX: worldOffsetX,
274
+ offsetY: worldOffsetY,
275
+ };
276
+ // Block object:moving from fighting our position updates
277
+ isHtmlDraggingRef.current = true;
278
+ document.body.style.cursor = "grabbing";
279
+ document.body.style.userSelect = "none";
280
+ document.body.style.touchAction = "none";
281
+ }, [fabricCanvas, selectedCanvasObjects]);
282
+ // ── Node select ────────────────────────────────────────────────────────────
283
+ const handleSelect = useCallback((id, e) => {
299
284
  if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
300
285
  setSelectedIds((prev) => {
301
286
  const next = new Set(prev);
@@ -306,64 +291,54 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
306
291
  else {
307
292
  setSelectedIds(new Set([id]));
308
293
  }
309
- };
310
- const handleStatusChange = (taskId, newStatus) => {
311
- const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
312
- setLocalTasks(updated);
313
- onTasksUpdate?.(updated);
314
- };
294
+ }, []);
295
+ const handleStatusChange = useCallback((taskId, newStatus) => {
296
+ setLocalTasks((prev) => {
297
+ const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
298
+ onTasksUpdate?.(updated);
299
+ return updated;
300
+ });
301
+ }, [onTasksUpdate]);
302
+ // ── Keyboard shortcuts ─────────────────────────────────────────────────────
315
303
  useEffect(() => {
316
304
  const handleKeyDown = (e) => {
317
- // Don't trigger if typing in input
318
305
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
319
306
  return;
320
- // Select All
321
307
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
322
308
  e.preventDefault();
323
- setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
309
+ setSelectedIds(new Set([
310
+ ...localTasks.map((t) => t.id),
311
+ ...localDocuments.map((d) => d.id),
312
+ ]));
324
313
  }
325
- // Clear selection
326
- if (e.key === "Escape") {
314
+ if (e.key === "Escape")
327
315
  setSelectedIds(new Set());
328
- }
329
- // ← ADD THIS: Delete selected nodes
330
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
316
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
331
317
  e.preventDefault();
332
- const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
333
- const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
334
- setLocalTasks(updatedTasks);
335
- setLocalDocuments(updatedDocs);
318
+ const ids = selectedIdsRef.current;
319
+ setLocalTasks((prev) => { const u = prev.filter((t) => !ids.has(t.id)); onTasksUpdate?.(u); return u; });
320
+ setLocalDocuments((prev) => { const u = prev.filter((d) => !ids.has(d.id)); onDocumentsUpdate?.(u); return u; });
336
321
  setSelectedIds(new Set());
337
- onTasksUpdate?.(updatedTasks);
338
- onDocumentsUpdate?.(updatedDocs);
339
322
  }
340
323
  };
341
324
  window.addEventListener("keydown", handleKeyDown);
342
325
  return () => window.removeEventListener("keydown", handleKeyDown);
343
- }, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
344
- // ── Render helper ────────────────────────────────────────────────────────────
326
+ }, [localTasks, localDocuments, onTasksUpdate, onDocumentsUpdate]);
327
+ // ── Render ─────────────────────────────────────────────────────────────────
345
328
  const renderItem = (id, x, y, children) => {
346
329
  const screenX = x * canvasZoom;
347
330
  const screenY = y * canvasZoom;
348
- // 1. Detect if the user is interacting with the canvas at all
349
- // 'dragging' is your existing state.
350
- // You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
351
- const isDragging = dragging?.itemIds.includes(id);
352
331
  return (_jsx("div", { className: "pointer-events-auto absolute", style: {
353
332
  left: 0,
354
333
  top: 0,
355
- // 2. Use translate3d for GPU performance
356
334
  transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
357
335
  transformOrigin: "top left",
358
- // 3. THE FIX: Remove transition entirely during any viewport change
359
- // Any 'ease' during zoom causes the "shaking" behavior.
360
- transition: isDragging ? 'none' : 'transform 0.1s ease-out',
361
- // 4. Optimization
336
+ transition: "none",
362
337
  willChange: "transform",
363
- zIndex: isDragging ? 1000 : 1,
338
+ zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
364
339
  }, children: children }, id));
365
340
  };
366
- return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
341
+ return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
367
342
  if (e.target === e.currentTarget)
368
343
  setSelectedIds(new Set());
369
344
  }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "A feature-rich whiteboard component built with Fabric.js and React",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",