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