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