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