@mhamz.01/easyflow-whiteboard 2.69.0 → 2.70.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;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
+ {"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,2CAulBzB"}
@@ -1,17 +1,8 @@
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
- // ─── 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
- }
15
6
  // ─── Component ────────────────────────────────────────────────────────────────
16
7
  export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
17
8
  const [localTasks, setLocalTasks] = useState(tasks);
@@ -19,7 +10,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
19
10
  const [selectedIds, setSelectedIds] = useState(new Set());
20
11
  const [dragging, setDragging] = useState(null);
21
12
  const [canvasReady, setCanvasReady] = useState(false);
22
- // ── Refs (always-fresh values, never stale closures) ─────────────────────────
13
+ const nodeClipboardRef = useRef({
14
+ tasks: [],
15
+ documents: [],
16
+ });
23
17
  const dragStateRef = useRef({
24
18
  isDragging: false,
25
19
  itemIds: [],
@@ -28,26 +22,19 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
28
22
  offsetX: 0,
29
23
  offsetY: 0,
30
24
  });
25
+ const finalPositionsRef = useRef(null);
31
26
  const rafIdRef = useRef(null);
32
27
  const overlayRef = useRef(null);
33
28
  const localTasksRef = useRef(localTasks);
34
29
  const localDocumentsRef = useRef(localDocuments);
35
30
  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
42
31
  selectedIdsRef.current = selectedIds;
43
32
  localTasksRef.current = localTasks;
44
33
  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 ──────────────────────────────────────────────────
34
+ // ── Sync props local state ────────────────────────────────────────────────
48
35
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
49
36
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
50
- // ── Poll until Fabric canvas is ready ────────────────────────────────────────
37
+ // effect polls until fabricCanvas.current is available:
51
38
  useEffect(() => {
52
39
  if (canvasReady)
53
40
  return;
@@ -55,6 +42,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
55
42
  setCanvasReady(true);
56
43
  return;
57
44
  }
45
+ // Poll every 50ms until canvas is ready (only needed on first load)
58
46
  const interval = setInterval(() => {
59
47
  if (fabricCanvas?.current) {
60
48
  setCanvasReady(true);
@@ -63,46 +51,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
63
51
  }, 50);
64
52
  return () => clearInterval(interval);
65
53
  }, [fabricCanvas, canvasReady]);
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 ───────────────────────────────
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
+ };
85
80
  useEffect(() => {
86
81
  const overlayEl = overlayRef.current;
87
82
  const canvas = fabricCanvas?.current;
88
83
  if (!overlayEl || !canvas)
89
84
  return;
90
85
  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)
91
88
  const target = e.target;
92
89
  const isOverNode = target !== overlayEl;
93
90
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
91
+ // 1. Prevent Browser Zoom immediately
94
92
  e.preventDefault();
95
93
  e.stopPropagation();
94
+ // 2. Calculate coordinates for Fabric
96
95
  const scenePoint = canvas.getScenePoint(e);
97
96
  const rect = canvas.getElement().getBoundingClientRect();
98
- const viewportPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
99
- canvas.fire("mouse:wheel", { e, scenePoint, viewportPoint });
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
+ });
100
107
  }
101
108
  };
109
+ // CRITICAL: { passive: false } allows us to cancel the browser's zoom
102
110
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
103
- return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
104
- }, [fabricCanvas, canvasReady]);
105
- // ── Fabric object:moving → sync HTML nodes ────────────────────────────────────
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) ──────────────────
106
116
  useEffect(() => {
107
117
  const canvas = fabricCanvas?.current;
108
118
  if (!canvas)
@@ -117,17 +127,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
117
127
  target._prevTop = target.top;
118
128
  if (deltaX === 0 && deltaY === 0)
119
129
  return;
130
+ // ── Read from ref — always fresh, never stale ──
120
131
  const sel = selectedIdsRef.current;
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
- });
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));
131
134
  };
132
135
  const handleMouseDown = (e) => {
133
136
  const target = e.target;
@@ -139,16 +142,22 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
139
142
  setSelectedIds(new Set());
140
143
  return;
141
144
  }
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;
142
149
  const activeObject = canvas.getActiveObject();
143
150
  const activeObjects = canvas.getActiveObjects();
144
- const transformTarget = e.transform?.target;
145
- const isPartOfActiveSelection = transformTarget === target ||
146
- activeObject === target ||
147
- activeObjects.includes(target);
148
- if (!isPartOfActiveSelection)
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) {
149
155
  setSelectedIds(new Set());
156
+ }
157
+ };
158
+ const handleSelectionCleared = () => {
159
+ setSelectedIds(new Set());
150
160
  };
151
- const handleSelectionCleared = () => setSelectedIds(new Set());
152
161
  canvas.on("object:moving", handleObjectMoving);
153
162
  canvas.on("mouse:down", handleMouseDown);
154
163
  canvas.on("selection:cleared", handleSelectionCleared);
@@ -157,128 +166,142 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
157
166
  canvas.off("mouse:down", handleMouseDown);
158
167
  canvas.off("selection:cleared", handleSelectionCleared);
159
168
  };
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);
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);
165
177
  if (task)
166
178
  return { x: task.x, y: task.y };
167
- const doc = localDocumentsRef.current.find((d) => d.id === id);
179
+ const doc = localDocuments.find((d) => d.id === id);
168
180
  if (doc)
169
181
  return { x: doc.x, y: doc.y };
170
182
  return undefined;
171
- }, [] // no deps — reads from refs, always fresh
172
- );
173
- // ── Selection box ─────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────
174
196
  useEffect(() => {
175
197
  if (!selectionBox)
176
198
  return;
177
- const vpt = liveVptRef.current;
178
- const zoom = vpt[0];
179
- const vpX = vpt[4];
180
- const vpY = vpt[5];
199
+ // ── O(n) single pass — no sort, no join, no extra allocations ──
181
200
  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
- };
194
201
  for (const task of localTasks) {
195
- if (check(task.x, task.y, 300, 140))
202
+ if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
196
203
  newSelected.add(task.id);
197
204
  }
198
205
  for (const doc of localDocuments) {
199
- if (check(doc.x, doc.y, 320, 160))
206
+ if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
200
207
  newSelected.add(doc.id);
201
208
  }
209
+ // ── O(n) equality check: size first (fast path), then membership ──
202
210
  setSelectedIds((prev) => {
203
211
  if (prev.size !== newSelected.size)
204
212
  return newSelected;
205
- for (const id of newSelected)
213
+ for (const id of newSelected) {
206
214
  if (!prev.has(id))
207
- return newSelected;
208
- return prev;
215
+ return newSelected; // found a difference, swap
216
+ }
217
+ return prev; // identical — return same reference, no re-render
209
218
  });
210
219
  }, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
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
+ // ── 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];
215
225
  return e;
216
226
  };
217
- const handleDragStart = useCallback((itemId, e) => {
227
+ const handleDragStart = (itemId, e) => {
228
+ // 1. Safety check for the Fabric instance
218
229
  const canvas = fabricCanvas?.current;
219
230
  if (!canvas)
220
231
  return;
232
+ // 2. Normalize the event (Touch vs Mouse)
221
233
  if (e.cancelable)
222
234
  e.preventDefault();
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
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
230
247
  const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
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
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
238
255
  const clickedPos = getItemPosition(itemId);
239
256
  if (!clickedPos)
240
257
  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.
241
261
  const worldOffsetX = clickWorldX - clickedPos.x;
242
262
  const worldOffsetY = clickWorldY - clickedPos.y;
243
- // Snapshot all dragged items' start positions from refs
263
+ // 8. Snapshot starting positions for all selected HTML nodes
244
264
  const startPositions = new Map();
245
- for (const id of itemsToDrag) {
265
+ itemsToDrag.forEach((id) => {
246
266
  const pos = getItemPosition(id);
247
267
  if (pos)
248
- startPositions.set(id, { ...pos });
249
- }
250
- // Snapshot Fabric objects
268
+ startPositions.set(id, pos);
269
+ });
270
+ // 9. Snapshot starting positions for all selected Fabric objects
251
271
  const canvasObjectsStartPos = new Map();
252
- for (const obj of selectedCanvasObjects) {
253
- canvasObjectsStartPos.set(obj, { left: obj.left ?? 0, top: obj.top ?? 0 });
254
- }
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
255
276
  dragStateRef.current = {
256
277
  isDragging: true,
257
278
  itemIds: itemsToDrag,
258
279
  startPositions,
259
280
  canvasObjectsStartPos,
260
- offsetX: worldOffsetX,
261
- offsetY: worldOffsetY,
281
+ offsetX: worldOffsetX, // Now stored as World Units
282
+ offsetY: worldOffsetY, // Now stored as World Units
262
283
  };
263
- // Select solo item if it wasn't already selected
264
- if (!currentSelected.has(itemId)) {
284
+ if (!selectedIdsRef.current.has(itemId) && dragStateRef.current.itemIds.length === 0) {
265
285
  setSelectedIds(new Set([itemId]));
266
286
  }
287
+ // 11. Trigger UI states
267
288
  setDragging({ itemIds: itemsToDrag });
268
289
  document.body.style.cursor = "grabbing";
269
290
  document.body.style.userSelect = "none";
270
291
  document.body.style.touchAction = "none";
271
- }, [fabricCanvas, selectedCanvasObjects, getItemPosition]);
272
- // ── Drag move ─────────────────────────────────────────────────────────────────
292
+ };
293
+ // ── Drag move (HTML Node side) ───────────────────────────────────────────────
273
294
  useEffect(() => {
274
295
  if (!dragging)
275
296
  return;
297
+ // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
276
298
  const handleMove = (e) => {
277
299
  if (!dragStateRef.current.isDragging)
278
300
  return;
279
301
  if (e.cancelable)
280
302
  e.preventDefault();
281
- const pointer = getPointerCoords(e);
303
+ const pointer = getPointerEvent(e);
304
+ // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
282
305
  if (rafIdRef.current !== null)
283
306
  cancelAnimationFrame(rafIdRef.current);
284
307
  rafIdRef.current = requestAnimationFrame(() => {
@@ -286,53 +309,56 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
286
309
  const canvas = fabricCanvas?.current;
287
310
  if (!canvas)
288
311
  return;
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.
312
+ // 2. Read the "Source of Truth" transform from the canvas
292
313
  const vpt = canvas.viewportTransform;
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
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)
300
322
  const newWorldX = currentWorldX - offsetX;
301
323
  const newWorldY = currentWorldY - offsetY;
302
- // Delta from each item's snapshot position
303
- const firstStart = startPositions.get(itemIds[0]);
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);
304
328
  if (!firstStart)
305
329
  return;
306
330
  const deltaX = newWorldX - firstStart.x;
307
331
  const deltaY = newWorldY - firstStart.y;
308
- // Update HTML nodes write-through to ref for handleEnd
332
+ // 6. Update HTML Nodes (Batching these into one state update)
309
333
  setLocalTasks((prev) => {
310
334
  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
- }
335
+ ? { ...t, x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
336
+ y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
316
337
  : t);
317
- localTasksRef.current = next;
338
+ localTasksRef.current = next; // ← write-through: ref always has latest
318
339
  return next;
319
340
  });
320
341
  setLocalDocuments((prev) => {
321
342
  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
- }
343
+ ? { ...d, x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
344
+ y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
327
345
  : d);
328
- localDocumentsRef.current = next;
346
+ localDocumentsRef.current = next; // ← write-through
329
347
  return next;
330
348
  });
331
- // Sync Fabric objects
349
+ // 7. Sync Fabric Objects (Imperative update for performance)
332
350
  canvasObjectsStartPos.forEach((startPos, obj) => {
333
- obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
334
- obj.setCoords();
351
+ obj.set({
352
+ left: startPos.left + deltaX,
353
+ top: startPos.top + deltaY,
354
+ });
355
+ obj.setCoords(); // Required for selection/intersection accuracy
335
356
  });
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
336
362
  canvas.requestRenderAll();
337
363
  });
338
364
  };
@@ -346,7 +372,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
346
372
  document.body.style.cursor = "";
347
373
  document.body.style.userSelect = "";
348
374
  document.body.style.touchAction = "";
349
- // ✅ Always read from refs — never stale closure values
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.
350
378
  onTasksUpdate?.(localTasksRef.current);
351
379
  onDocumentsUpdate?.(localDocumentsRef.current);
352
380
  };
@@ -362,33 +390,46 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
362
390
  window.removeEventListener("touchend", handleEnd);
363
391
  window.removeEventListener("touchcancel", handleEnd);
364
392
  };
365
- // localTasks/localDocuments removed from deps — read via refs in handleEnd.
366
- // Only 'dragging' and 'fabricCanvas' needed here.
367
- }, [dragging, fabricCanvas]);
368
- // ── Keyboard shortcuts ────────────────────────────────────────────────────────
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
+ };
369
412
  useEffect(() => {
370
413
  const handleKeyDown = (e) => {
414
+ // Don't trigger if typing in input
371
415
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
372
416
  return;
417
+ // Select All
373
418
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
374
419
  e.preventDefault();
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
- ]));
420
+ setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
380
421
  }
381
- if (e.key === "Escape")
422
+ // Clear selection
423
+ if (e.key === "Escape") {
382
424
  setSelectedIds(new Set());
383
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
425
+ }
426
+ // ← ADD THIS: Delete selected nodes
427
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
384
428
  e.preventDefault();
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));
429
+ const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
430
+ const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
388
431
  setLocalTasks(updatedTasks);
389
432
  setLocalDocuments(updatedDocs);
390
- localTasksRef.current = updatedTasks;
391
- localDocumentsRef.current = updatedDocs;
392
433
  setSelectedIds(new Set());
393
434
  onTasksUpdate?.(updatedTasks);
394
435
  onDocumentsUpdate?.(updatedDocs);
@@ -396,63 +437,28 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
396
437
  };
397
438
  window.addEventListener("keydown", handleKeyDown);
398
439
  return () => window.removeEventListener("keydown", handleKeyDown);
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;
440
+ }, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
441
+ const getAbsoluteTransform = (x, y) => {
442
+ // We calculate the screen position in one go, matching Fabric's internal VPT logic
443
+ const screenX = x * canvasZoom + canvasViewport.x;
444
+ const screenY = y * canvasZoom + canvasViewport.y;
445
+ return `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`;
446
+ };
447
+ // ── Render helper ────────────────────────────────────────────────────────────
448
+ const renderItem = (id, x, y, children) => {
449
+ const isDragging = dragging?.itemIds.includes(id);
441
450
  return (_jsx("div", { className: "pointer-events-auto absolute", style: {
442
451
  left: 0,
443
452
  top: 0,
444
- // Single transform no double-transform mismatch possible
445
- transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${zoom})`,
453
+ // FIX: Combined Transform prevents the "Pan vs Zoom" lag
454
+ transform: getAbsoluteTransform(x, y),
446
455
  transformOrigin: "top left",
447
- transition: "none",
456
+ transition: "none", // Critical: prevents animation-induced jitter
448
457
  willChange: "transform",
449
- zIndex: isDraggingThis ? 1000 : 1,
458
+ zIndex: isDragging ? 1000 : 1,
450
459
  }, children: children }, id));
451
460
  };
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) => {
455
- if (e.target === e.currentTarget)
456
- setSelectedIds(new Set());
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 })))] }));
461
+ return (_jsxs("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none overflow-hidden", style: { zIndex: 50 }, 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 })))] })
462
+ // </div>
463
+ );
458
464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.69.0",
3
+ "version": "2.70.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",