@mhamz.01/easyflow-whiteboard 2.67.0 → 2.69.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;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;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;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,2CAujBzB"}
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;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AA2BD,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,2CAsiBzB"}
@@ -1,8 +1,17 @@
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
+ // ─── Pure helper: world → screen using the live VPT ──────────────────────────
7
+ // This is the SINGLE source of truth for positioning.
8
+ // screenX = worldX * zoom + vpX (same math Fabric uses internally)
9
+ function worldToScreen(worldX, worldY, vpt) {
10
+ return {
11
+ x: worldX * vpt[0] + vpt[4],
12
+ y: worldY * vpt[3] + vpt[5],
13
+ };
14
+ }
6
15
  // ─── Component ────────────────────────────────────────────────────────────────
7
16
  export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
8
17
  const [localTasks, setLocalTasks] = useState(tasks);
@@ -10,10 +19,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
10
19
  const [selectedIds, setSelectedIds] = useState(new Set());
11
20
  const [dragging, setDragging] = useState(null);
12
21
  const [canvasReady, setCanvasReady] = useState(false);
13
- const nodeClipboardRef = useRef({
14
- tasks: [],
15
- documents: [],
16
- });
22
+ // ── Refs (always-fresh values, never stale closures) ─────────────────────────
17
23
  const dragStateRef = useRef({
18
24
  isDragging: false,
19
25
  itemIds: [],
@@ -24,12 +30,24 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
24
30
  });
25
31
  const rafIdRef = useRef(null);
26
32
  const overlayRef = useRef(null);
33
+ const localTasksRef = useRef(localTasks);
34
+ const localDocumentsRef = useRef(localDocuments);
27
35
  const selectedIdsRef = useRef(selectedIds);
36
+ // ── THE KEY REF: always holds the live VPT from Fabric ───────────────────────
37
+ // This is what eliminates the double-transform mismatch.
38
+ // Instead of using canvasZoom/canvasViewport props (which are 1 render behind),
39
+ // we read directly from the canvas during render via this ref.
40
+ const liveVptRef = useRef([1, 0, 0, 1, 0, 0]);
41
+ // Sync all refs on every render — O(1), no cost
28
42
  selectedIdsRef.current = selectedIds;
29
- // ── Sync props → local state ────────────────────────────────────────────────
43
+ localTasksRef.current = localTasks;
44
+ localDocumentsRef.current = localDocuments;
45
+ // Keep liveVptRef up to date from props as a fallback (canvas updates it directly too)
46
+ liveVptRef.current = [canvasZoom, 0, 0, canvasZoom, canvasViewport.x, canvasViewport.y];
47
+ // ── Sync props → local state ──────────────────────────────────────────────────
30
48
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
31
49
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
32
- // effect polls until fabricCanvas.current is available:
50
+ // ── Poll until Fabric canvas is ready ────────────────────────────────────────
33
51
  useEffect(() => {
34
52
  if (canvasReady)
35
53
  return;
@@ -37,7 +55,6 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
37
55
  setCanvasReady(true);
38
56
  return;
39
57
  }
40
- // Poll every 50ms until canvas is ready (only needed on first load)
41
58
  const interval = setInterval(() => {
42
59
  if (fabricCanvas?.current) {
43
60
  setCanvasReady(true);
@@ -46,68 +63,46 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
46
63
  }, 50);
47
64
  return () => clearInterval(interval);
48
65
  }, [fabricCanvas, canvasReady]);
49
- // ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
50
- const handleOverlayWheel = (e) => {
51
- if (e.ctrlKey || e.metaKey || e.shiftKey) {
52
- const canvas = fabricCanvas?.current;
53
- if (!canvas)
54
- return;
55
- const nativeEvent = e.nativeEvent;
56
- // getScenePoint handles the transformation from screen to canvas space
57
- const scenePoint = canvas.getScenePoint(nativeEvent);
58
- // Viewport point is simply the mouse position relative to the canvas element
59
- const rect = canvas.getElement().getBoundingClientRect();
60
- const viewportPoint = {
61
- x: nativeEvent.clientX - rect.left,
62
- y: nativeEvent.clientY - rect.top,
63
- };
64
- // We cast to 'any' here because we are manually triggering an internal
65
- // event bus, and Fabric's internal types for .fire() can be overly strict.
66
- canvas.fire("mouse:wheel", {
67
- e: nativeEvent,
68
- scenePoint,
69
- viewportPoint,
70
- });
71
- e.preventDefault();
72
- e.stopPropagation();
73
- }
74
- };
66
+ // ── Sync liveVptRef directly from Fabric on every viewport change ─────────────
67
+ // This is the critical piece: we hook into Fabric's own viewport events so
68
+ // liveVptRef is updated BEFORE React re-renders, not after.
69
+ useEffect(() => {
70
+ const canvas = fabricCanvas?.current;
71
+ if (!canvas)
72
+ return;
73
+ const syncVpt = () => {
74
+ const vpt = canvas.viewportTransform;
75
+ if (vpt)
76
+ liveVptRef.current = [...vpt];
77
+ };
78
+ // These fire on every pan/zoom frame
79
+ canvas.on("after:render", syncVpt);
80
+ return () => {
81
+ canvas.off("after:render", syncVpt);
82
+ };
83
+ }, [fabricCanvas, canvasReady]);
84
+ // ── Event forwarding: wheel over nodes → Fabric ───────────────────────────────
75
85
  useEffect(() => {
76
86
  const overlayEl = overlayRef.current;
77
87
  const canvas = fabricCanvas?.current;
78
88
  if (!overlayEl || !canvas)
79
89
  return;
80
90
  const handleGlobalWheel = (e) => {
81
- // Check if the user is hovering over an element that has pointer-events: auto
82
- // (meaning they are hovering over a Task or Document)
83
91
  const target = e.target;
84
92
  const isOverNode = target !== overlayEl;
85
93
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
86
- // 1. Prevent Browser Zoom immediately
87
94
  e.preventDefault();
88
95
  e.stopPropagation();
89
- // 2. Calculate coordinates for Fabric
90
96
  const scenePoint = canvas.getScenePoint(e);
91
97
  const rect = canvas.getElement().getBoundingClientRect();
92
- const viewportPoint = {
93
- x: e.clientX - rect.left,
94
- y: e.clientY - rect.top,
95
- };
96
- // 3. Manually fire the event into Fabric
97
- canvas.fire("mouse:wheel", {
98
- e: e,
99
- scenePoint,
100
- viewportPoint,
101
- });
98
+ const viewportPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
99
+ canvas.fire("mouse:wheel", { e, scenePoint, viewportPoint });
102
100
  }
103
101
  };
104
- // CRITICAL: { passive: false } allows us to cancel the browser's zoom
105
102
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
106
- return () => {
107
- overlayEl.removeEventListener("wheel", handleGlobalWheel);
108
- };
109
- }, [fabricCanvas, canvasZoom, canvasReady]); // Re-bind when zoom changes to keep closure fresh
110
- // ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
103
+ return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
104
+ }, [fabricCanvas, canvasReady]);
105
+ // ── Fabric object:moving → sync HTML nodes ────────────────────────────────────
111
106
  useEffect(() => {
112
107
  const canvas = fabricCanvas?.current;
113
108
  if (!canvas)
@@ -122,10 +117,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
122
117
  target._prevTop = target.top;
123
118
  if (deltaX === 0 && deltaY === 0)
124
119
  return;
125
- // ── Read from ref — always fresh, never stale ──
126
120
  const sel = selectedIdsRef.current;
127
- setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
128
- setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
121
+ setLocalTasks((prev) => {
122
+ const next = prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t);
123
+ localTasksRef.current = next;
124
+ return next;
125
+ });
126
+ setLocalDocuments((prev) => {
127
+ const next = prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d);
128
+ localDocumentsRef.current = next;
129
+ return next;
130
+ });
129
131
  };
130
132
  const handleMouseDown = (e) => {
131
133
  const target = e.target;
@@ -137,22 +139,16 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
137
139
  setSelectedIds(new Set());
138
140
  return;
139
141
  }
140
- // At zoom=1 with identity VPT, getActiveObject() can return null before
141
- // Fabric updates _activeObject. Use e.transform as the primary check —
142
- // it is populated by Fabric's hit-test regardless of zoom level.
143
- const transformTarget = e.transform?.target;
144
142
  const activeObject = canvas.getActiveObject();
145
143
  const activeObjects = canvas.getActiveObjects();
146
- const isPartOfActiveSelection = transformTarget === target || // most reliable — direct from event
147
- activeObject === target || // selection box group
148
- activeObjects.includes(target); // individual object in multi-select
149
- if (!isPartOfActiveSelection) {
144
+ const transformTarget = e.transform?.target;
145
+ const isPartOfActiveSelection = transformTarget === target ||
146
+ activeObject === target ||
147
+ activeObjects.includes(target);
148
+ if (!isPartOfActiveSelection)
150
149
  setSelectedIds(new Set());
151
- }
152
- };
153
- const handleSelectionCleared = () => {
154
- setSelectedIds(new Set());
155
150
  };
151
+ const handleSelectionCleared = () => setSelectedIds(new Set());
156
152
  canvas.on("object:moving", handleObjectMoving);
157
153
  canvas.on("mouse:down", handleMouseDown);
158
154
  canvas.on("selection:cleared", handleSelectionCleared);
@@ -161,100 +157,101 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
161
157
  canvas.off("mouse:down", handleMouseDown);
162
158
  canvas.off("selection:cleared", handleSelectionCleared);
163
159
  };
164
- // ── selectedIds REMOVED from deps — read via selectedIdsRef instead ──────
165
- // Having selectedIds here caused the effect to re-register on every selection
166
- // change, creating a new closure each time. The second drag captured a stale
167
- // or empty selectedIds from the closure at re-registration time.
168
- }, [canvasZoom, fabricCanvas, canvasReady]);
169
- // ── Helpers ─────────────────────────────────────────────────────────────────
170
- const getItemPosition = (id) => {
171
- const task = localTasks.find((t) => t.id === id);
160
+ }, [fabricCanvas, canvasReady]);
161
+ // ── Helpers ───────────────────────────────────────────────────────────────────
162
+ // Always reads from refs never from stale closure state
163
+ const getItemPosition = useCallback((id) => {
164
+ const task = localTasksRef.current.find((t) => t.id === id);
172
165
  if (task)
173
166
  return { x: task.x, y: task.y };
174
- const doc = localDocuments.find((d) => d.id === id);
167
+ const doc = localDocumentsRef.current.find((d) => d.id === id);
175
168
  if (doc)
176
169
  return { x: doc.x, y: doc.y };
177
170
  return undefined;
178
- };
179
- const isItemInSelectionBox = (x, y, width, height, box) => {
180
- const itemX1 = x * canvasZoom + canvasViewport.x;
181
- const itemY1 = y * canvasZoom + canvasViewport.y;
182
- const itemX2 = itemX1 + width * canvasZoom;
183
- const itemY2 = itemY1 + height * canvasZoom;
184
- const boxX1 = Math.min(box.x1, box.x2);
185
- const boxY1 = Math.min(box.y1, box.y2);
186
- const boxX2 = Math.max(box.x1, box.x2);
187
- const boxY2 = Math.max(box.y1, box.y2);
188
- return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
189
- };
190
- // ── Selection box detection ──────────────────────────────────────────────────
171
+ }, [] // no deps — reads from refs, always fresh
172
+ );
173
+ // ── Selection box ─────────────────────────────────────────────────────────────
191
174
  useEffect(() => {
192
175
  if (!selectionBox)
193
176
  return;
194
- // ── O(n) single pass — no sort, no join, no extra allocations ──
177
+ const vpt = liveVptRef.current;
178
+ const zoom = vpt[0];
179
+ const vpX = vpt[4];
180
+ const vpY = vpt[5];
195
181
  const newSelected = new Set();
182
+ const check = (x, y, w, h) => {
183
+ // Use the same worldToScreen math for accurate hit testing
184
+ const sx = x * zoom + vpX;
185
+ const sy = y * zoom + vpY;
186
+ const sx2 = sx + w * zoom;
187
+ const sy2 = sy + h * zoom;
188
+ const bx1 = Math.min(selectionBox.x1, selectionBox.x2);
189
+ const by1 = Math.min(selectionBox.y1, selectionBox.y2);
190
+ const bx2 = Math.max(selectionBox.x1, selectionBox.x2);
191
+ const by2 = Math.max(selectionBox.y1, selectionBox.y2);
192
+ return !(bx2 < sx || bx1 > sx2 || by2 < sy || by1 > sy2);
193
+ };
196
194
  for (const task of localTasks) {
197
- if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
195
+ if (check(task.x, task.y, 300, 140))
198
196
  newSelected.add(task.id);
199
197
  }
200
198
  for (const doc of localDocuments) {
201
- if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
199
+ if (check(doc.x, doc.y, 320, 160))
202
200
  newSelected.add(doc.id);
203
201
  }
204
- // ── O(n) equality check: size first (fast path), then membership ──
205
202
  setSelectedIds((prev) => {
206
203
  if (prev.size !== newSelected.size)
207
204
  return newSelected;
208
- for (const id of newSelected) {
205
+ for (const id of newSelected)
209
206
  if (!prev.has(id))
210
- return newSelected; // found a difference, swap
211
- }
212
- return prev; // identical — return same reference, no re-render
207
+ return newSelected;
208
+ return prev;
213
209
  });
214
210
  }, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
215
- // ── Drag start (HTML Node side) ──────────────────────────────────────────────
216
- // Helper to extract coordinates regardless of event type
217
- const getPointerEvent = (e) => {
218
- if ('touches' in e && e.touches.length > 0)
219
- return e.touches[0];
211
+ // ── Drag start ────────────────────────────────────────────────────────────────
212
+ const getPointerCoords = (e) => {
213
+ if ("touches" in e && e.touches.length > 0)
214
+ return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
220
215
  return e;
221
216
  };
222
- const handleDragStart = (itemId, e) => {
217
+ const handleDragStart = useCallback((itemId, e) => {
223
218
  const canvas = fabricCanvas?.current;
224
219
  if (!canvas)
225
220
  return;
226
221
  if (e.cancelable)
227
222
  e.preventDefault();
228
- const pointer = getPointerEvent(e);
229
- let itemsToDrag;
230
- if (selectedIds.has(itemId)) {
231
- itemsToDrag = Array.from(selectedIds);
232
- }
233
- else {
234
- itemsToDrag = [itemId];
235
- setSelectedIds(new Set([itemId]));
236
- }
223
+ const pointer = getPointerCoords(e);
224
+ // ✅ Use selectedIdsRef — never the closure's selectedIds
225
+ const currentSelected = selectedIdsRef.current;
226
+ const itemsToDrag = currentSelected.has(itemId)
227
+ ? Array.from(currentSelected)
228
+ : [itemId];
229
+ // Read VPT directly from canvas — zero-frame-lag, not from props
237
230
  const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
238
- const liveZoom = vpt[0];
239
- const liveVpX = vpt[4];
240
- const liveVpY = vpt[5];
241
- const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
242
- const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
231
+ const zoom = vpt[0];
232
+ const vpX = vpt[4];
233
+ const vpY = vpt[5];
234
+ // Click position in world space
235
+ const clickWorldX = (pointer.clientX - vpX) / zoom;
236
+ const clickWorldY = (pointer.clientY - vpY) / zoom;
237
+ // ✅ Use getItemPosition which reads from refs
243
238
  const clickedPos = getItemPosition(itemId);
244
239
  if (!clickedPos)
245
240
  return;
246
241
  const worldOffsetX = clickWorldX - clickedPos.x;
247
242
  const worldOffsetY = clickWorldY - clickedPos.y;
243
+ // Snapshot all dragged items' start positions from refs
248
244
  const startPositions = new Map();
249
- itemsToDrag.forEach((id) => {
245
+ for (const id of itemsToDrag) {
250
246
  const pos = getItemPosition(id);
251
247
  if (pos)
252
- startPositions.set(id, pos);
253
- });
248
+ startPositions.set(id, { ...pos });
249
+ }
250
+ // Snapshot Fabric objects
254
251
  const canvasObjectsStartPos = new Map();
255
- selectedCanvasObjects.forEach((obj) => {
256
- canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
257
- });
252
+ for (const obj of selectedCanvasObjects) {
253
+ canvasObjectsStartPos.set(obj, { left: obj.left ?? 0, top: obj.top ?? 0 });
254
+ }
258
255
  dragStateRef.current = {
259
256
  isDragging: true,
260
257
  itemIds: itemsToDrag,
@@ -263,23 +260,25 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
263
260
  offsetX: worldOffsetX,
264
261
  offsetY: worldOffsetY,
265
262
  };
263
+ // Select solo item if it wasn't already selected
264
+ if (!currentSelected.has(itemId)) {
265
+ setSelectedIds(new Set([itemId]));
266
+ }
266
267
  setDragging({ itemIds: itemsToDrag });
267
- // ── ATTACH LISTENERS DIRECTLY ──
268
268
  document.body.style.cursor = "grabbing";
269
269
  document.body.style.userSelect = "none";
270
- };
271
- // ── Drag move (HTML Node side) ───────────────────────────────────────────────
270
+ document.body.style.touchAction = "none";
271
+ }, [fabricCanvas, selectedCanvasObjects, getItemPosition]);
272
+ // ── Drag move ─────────────────────────────────────────────────────────────────
272
273
  useEffect(() => {
273
274
  if (!dragging)
274
275
  return;
275
- // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
276
276
  const handleMove = (e) => {
277
277
  if (!dragStateRef.current.isDragging)
278
278
  return;
279
279
  if (e.cancelable)
280
280
  e.preventDefault();
281
- const pointer = getPointerEvent(e);
282
- // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
281
+ const pointer = getPointerCoords(e);
283
282
  if (rafIdRef.current !== null)
284
283
  cancelAnimationFrame(rafIdRef.current);
285
284
  rafIdRef.current = requestAnimationFrame(() => {
@@ -287,64 +286,69 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
287
286
  const canvas = fabricCanvas?.current;
288
287
  if (!canvas)
289
288
  return;
290
- // 2. Read the "Source of Truth" transform from the canvas
289
+ // ALWAYS read VPT from canvas this is the fix for the jump.
290
+ // Props (canvasZoom, canvasViewport) are one React render behind.
291
+ // canvas.viewportTransform is synchronous and always current.
291
292
  const vpt = canvas.viewportTransform;
292
- const liveZoom = vpt[0]; // Scale
293
- const liveVpX = vpt[4]; // Pan X
294
- const liveVpY = vpt[5]; // Pan Y
295
- // 3. Convert current Mouse Screen Position → World Position
296
- const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
297
- const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
298
- // 4. Calculate where the "Anchor" node should be in World Units
299
- // (Current Mouse World - Initial World Offset from Start)
293
+ const zoom = vpt[0];
294
+ const vpX = vpt[4];
295
+ const vpY = vpt[5];
296
+ // Mouse in world space
297
+ const currentWorldX = (pointer.clientX - vpX) / zoom;
298
+ const currentWorldY = (pointer.clientY - vpY) / zoom;
299
+ // Where the anchor node's top-left should be
300
300
  const newWorldX = currentWorldX - offsetX;
301
301
  const newWorldY = currentWorldY - offsetY;
302
- // 5. Calculate the Movement Delta in World Units
303
- // We compare where the first item started vs where it is now.
304
- const firstId = itemIds[0];
305
- const firstStart = startPositions.get(firstId);
302
+ // Delta from each item's snapshot position
303
+ const firstStart = startPositions.get(itemIds[0]);
306
304
  if (!firstStart)
307
305
  return;
308
306
  const deltaX = newWorldX - firstStart.x;
309
307
  const deltaY = newWorldY - firstStart.y;
310
- // 6. Update HTML Nodes (Batching these into one state update)
311
- setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
312
- ...t,
313
- x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
314
- y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
315
- } : t));
316
- setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
317
- ...d,
318
- x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
319
- y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
320
- } : d));
321
- // 7. Sync Fabric Objects (Imperative update for performance)
308
+ // Update HTML nodes write-through to ref for handleEnd
309
+ setLocalTasks((prev) => {
310
+ const next = prev.map((t) => itemIds.includes(t.id)
311
+ ? {
312
+ ...t,
313
+ x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
314
+ y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
315
+ }
316
+ : t);
317
+ localTasksRef.current = next;
318
+ return next;
319
+ });
320
+ setLocalDocuments((prev) => {
321
+ const next = prev.map((d) => itemIds.includes(d.id)
322
+ ? {
323
+ ...d,
324
+ x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
325
+ y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
326
+ }
327
+ : d);
328
+ localDocumentsRef.current = next;
329
+ return next;
330
+ });
331
+ // Sync Fabric objects
322
332
  canvasObjectsStartPos.forEach((startPos, obj) => {
323
- obj.set({
324
- left: startPos.left + deltaX,
325
- top: startPos.top + deltaY,
326
- });
327
- obj.setCoords(); // Required for selection/intersection accuracy
333
+ obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
334
+ obj.setCoords();
328
335
  });
329
- // 8. Single render call for all Fabric changes
330
336
  canvas.requestRenderAll();
331
337
  });
332
338
  };
333
339
  const handleEnd = () => {
334
- if (rafIdRef.current !== null)
340
+ if (rafIdRef.current !== null) {
335
341
  cancelAnimationFrame(rafIdRef.current);
336
- // 1. Kill the flag first
342
+ rafIdRef.current = null;
343
+ }
337
344
  dragStateRef.current.isDragging = false;
338
345
  setDragging(null);
339
- // 2. Remove the listeners immediately
340
- window.removeEventListener("mousemove", handleMove);
341
- window.removeEventListener("mouseup", handleEnd);
342
- window.removeEventListener("touchmove", handleMove);
343
- window.removeEventListener("touchend", handleEnd);
344
346
  document.body.style.cursor = "";
345
347
  document.body.style.userSelect = "";
346
- onTasksUpdate?.(localTasks);
347
- onDocumentsUpdate?.(localDocuments);
348
+ document.body.style.touchAction = "";
349
+ // ✅ Always read from refs — never stale closure values
350
+ onTasksUpdate?.(localTasksRef.current);
351
+ onDocumentsUpdate?.(localDocumentsRef.current);
348
352
  };
349
353
  window.addEventListener("mousemove", handleMove, { passive: false });
350
354
  window.addEventListener("mouseup", handleEnd);
@@ -358,46 +362,33 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
358
362
  window.removeEventListener("touchend", handleEnd);
359
363
  window.removeEventListener("touchcancel", handleEnd);
360
364
  };
361
- }, [dragging, localTasks, localDocuments, fabricCanvas]);
362
- // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
363
- const handleSelect = (id, e) => {
364
- if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
365
- setSelectedIds((prev) => {
366
- const next = new Set(prev);
367
- next.has(id) ? next.delete(id) : next.add(id);
368
- return next;
369
- });
370
- }
371
- else {
372
- setSelectedIds(new Set([id]));
373
- }
374
- };
375
- const handleStatusChange = (taskId, newStatus) => {
376
- const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
377
- setLocalTasks(updated);
378
- onTasksUpdate?.(updated);
379
- };
365
+ // localTasks/localDocuments removed from deps — read via refs in handleEnd.
366
+ // Only 'dragging' and 'fabricCanvas' needed here.
367
+ }, [dragging, fabricCanvas]);
368
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────────
380
369
  useEffect(() => {
381
370
  const handleKeyDown = (e) => {
382
- // Don't trigger if typing in input
383
371
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
384
372
  return;
385
- // Select All
386
373
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
387
374
  e.preventDefault();
388
- setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
375
+ // Read from refs avoids stale closure on localTasks/localDocuments
376
+ setSelectedIds(new Set([
377
+ ...localTasksRef.current.map((t) => t.id),
378
+ ...localDocumentsRef.current.map((d) => d.id),
379
+ ]));
389
380
  }
390
- // Clear selection
391
- if (e.key === "Escape") {
381
+ if (e.key === "Escape")
392
382
  setSelectedIds(new Set());
393
- }
394
- // ← ADD THIS: Delete selected nodes
395
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
383
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
396
384
  e.preventDefault();
397
- const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
398
- const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
385
+ const sel = selectedIdsRef.current;
386
+ const updatedTasks = localTasksRef.current.filter((t) => !sel.has(t.id));
387
+ const updatedDocs = localDocumentsRef.current.filter((d) => !sel.has(d.id));
399
388
  setLocalTasks(updatedTasks);
400
389
  setLocalDocuments(updatedDocs);
390
+ localTasksRef.current = updatedTasks;
391
+ localDocumentsRef.current = updatedDocs;
401
392
  setSelectedIds(new Set());
402
393
  onTasksUpdate?.(updatedTasks);
403
394
  onDocumentsUpdate?.(updatedDocs);
@@ -405,34 +396,63 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
405
396
  };
406
397
  window.addEventListener("keydown", handleKeyDown);
407
398
  return () => window.removeEventListener("keydown", handleKeyDown);
408
- }, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
409
- // ── Render helper ────────────────────────────────────────────────────────────
410
- const renderItem = (id, x, y, children) => {
411
- const screenX = x * canvasZoom;
412
- const screenY = y * canvasZoom;
413
- // 1. Detect if the user is interacting with the canvas at all
414
- // 'dragging' is your existing state.
415
- // You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
416
- const isDragging = dragging?.itemIds.includes(id);
399
+ // ✅ No localTasks/localDocuments/selectedIds in deps — all read via refs
400
+ }, [onTasksUpdate, onDocumentsUpdate]);
401
+ // ── handleSelect / handleStatusChange ─────────────────────────────────────────
402
+ const handleSelect = useCallback((id, e) => {
403
+ if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
404
+ setSelectedIds((prev) => {
405
+ const next = new Set(prev);
406
+ next.has(id) ? next.delete(id) : next.add(id);
407
+ return next;
408
+ });
409
+ }
410
+ else {
411
+ setSelectedIds(new Set([id]));
412
+ }
413
+ }, []);
414
+ const handleStatusChange = useCallback((taskId, newStatus) => {
415
+ setLocalTasks((prev) => {
416
+ const next = prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
417
+ localTasksRef.current = next;
418
+ onTasksUpdate?.(next);
419
+ return next;
420
+ });
421
+ }, [onTasksUpdate]);
422
+ // ── Render each node ──────────────────────────────────────────────────────────
423
+ // ✅ THE CORE FIX: Single collapsed transform.
424
+ // Old approach had TWO transforms:
425
+ // parent: translate(vpX, vpY)
426
+ // child: translate(x*zoom, y*zoom) scale(zoom)
427
+ // These two transforms use props that update at different React render cycles,
428
+ // causing a 1-frame mismatch = visible jump on multi-select drag.
429
+ //
430
+ // New approach: ONE transform on each node using the live VPT matrix directly.
431
+ // Formula: screenPos = worldPos * zoom + vpOffset (identical to Fabric's math)
432
+ const renderItem = (id, worldX, worldY, children) => {
433
+ const vpt = liveVptRef.current;
434
+ const zoom = vpt[0];
435
+ const vpX = vpt[4];
436
+ const vpY = vpt[5];
437
+ // Exact same calculation Fabric uses for its own objects
438
+ const screenX = worldX * zoom + vpX;
439
+ const screenY = worldY * zoom + vpY;
440
+ const isDraggingThis = dragging?.itemIds.includes(id) ?? false;
417
441
  return (_jsx("div", { className: "pointer-events-auto absolute", style: {
418
442
  left: 0,
419
443
  top: 0,
420
- // 2. Use translate3d for GPU performance
421
- transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
444
+ // Single transform no double-transform mismatch possible
445
+ transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${zoom})`,
422
446
  transformOrigin: "top left",
423
- // 3. THE FIX: Remove transition entirely during any viewport change
424
- // Any 'ease' during zoom causes the "shaking" behavior.
425
447
  transition: "none",
426
- // 4. Optimization
427
448
  willChange: "transform",
428
- zIndex: isDragging ? 1000 : 1,
449
+ zIndex: isDraggingThis ? 1000 : 1,
429
450
  }, children: children }, id));
430
451
  };
431
- return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
452
+ return (
453
+ // ✅ No wrapper div with viewport transform anymore — it's collapsed into each node
454
+ _jsxs("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
432
455
  if (e.target === e.currentTarget)
433
456
  setSelectedIds(new Set());
434
- }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
435
- transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
436
- transformOrigin: "top left",
437
- }, 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 })))] }) }));
457
+ }, 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 })))] }));
438
458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.67.0",
3
+ "version": "2.69.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",