@mhamz.01/easyflow-whiteboard 2.35.0 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAM9C,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC;IACxC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,UAAU,uBAAuB;IAC/B,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACxC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,YAAY,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACzE,qBAAqB,CAAC,EAAE,YAAY,EAAE,CAAC;IACvC,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAoBD,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,2CAgczB"}
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;CAC/C;AAaD,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,EACzC,KAAK,EACL,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,UAAc,EACd,cAA+B,EAC/B,YAAmB,EACnB,qBAA0B,EAC1B,YAAY,GACb,EAAE,uBAAuB,2CA0hBzB"}
@@ -1,22 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useState, useEffect, useRef, useCallback, memo } from "react";
3
+ import { useState, useEffect, useRef } from "react";
4
4
  import TaskNode from "./custom-node";
5
5
  import DocumentNode from "./document-node";
6
- // ─── Memoized node wrappers ───────────────────────────────────────────────────
7
- // Prevents all sibling nodes re-rendering when one node's position or
8
- // selection state changes. Props compared shallowly by React.memo.
9
- const MemoTaskNode = memo(TaskNode);
10
- const MemoDocumentNode = memo(DocumentNode);
11
6
  // ─── Component ────────────────────────────────────────────────────────────────
12
7
  export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
13
8
  const [localTasks, setLocalTasks] = useState(tasks);
14
9
  const [localDocuments, setLocalDocuments] = useState(documents);
15
10
  const [selectedIds, setSelectedIds] = useState(new Set());
16
- // `dragging` state removed entirely — it caused a re-render on drag start
17
- // which tore down/reattached the mousemove listener mid-gesture (jump bug).
18
- // All drag lifecycle now lives exclusively in dragStateRef.
19
- // ─── Refs ──────────────────────────────────────────────────────────────────
11
+ const [dragging, setDragging] = useState(null);
20
12
  const dragStateRef = useRef({
21
13
  isDragging: false,
22
14
  itemIds: [],
@@ -27,55 +19,73 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
27
19
  });
28
20
  const rafIdRef = useRef(null);
29
21
  const overlayRef = useRef(null);
30
- // ── Always-fresh refs — assigned synchronously in render body ─────────────
31
- // Read inside stable [] effect closures. Assigned during render (not useEffect)
32
- // so they hold the latest value before any event handler can fire.
33
22
  const selectedIdsRef = useRef(selectedIds);
34
23
  selectedIdsRef.current = selectedIds;
35
- // O(1) world-position lookup replaces O(n) getItemPosition() scans
36
- const nodePositionsRef = useRef(new Map());
37
- nodePositionsRef.current = new Map([
38
- ...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
39
- ...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
40
- ]);
41
- // Avoids stale prop capture of selectedCanvasObjects in handleDragStart
42
- const selectedCanvasObjectsRef = useRef(selectedCanvasObjects);
43
- selectedCanvasObjectsRef.current = selectedCanvasObjects;
44
- // Stable callback refs — handleEnd never closes over stale prop functions
45
- const onTasksUpdateRef = useRef(onTasksUpdate);
46
- const onDocumentsUpdateRef = useRef(onDocumentsUpdate);
47
- onTasksUpdateRef.current = onTasksUpdate;
48
- onDocumentsUpdateRef.current = onDocumentsUpdate;
49
- // ─── Sync props → local state ──────────────────────────────────────────────
24
+ // ── Sync props local state ────────────────────────────────────────────────
50
25
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
51
26
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
52
- // ─── Wheel forwarding to Fabric ────────────────────────────────────────────
53
- // `canvasZoom` removed from deps — handler reads canvas state imperatively,
54
- // no stale data risk. Registered once per canvas mount.
27
+ // ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
28
+ const handleOverlayWheel = (e) => {
29
+ if (e.ctrlKey || e.metaKey || e.shiftKey) {
30
+ const canvas = fabricCanvas?.current;
31
+ if (!canvas)
32
+ return;
33
+ const nativeEvent = e.nativeEvent;
34
+ // getScenePoint handles the transformation from screen to canvas space
35
+ const scenePoint = canvas.getScenePoint(nativeEvent);
36
+ // Viewport point is simply the mouse position relative to the canvas element
37
+ const rect = canvas.getElement().getBoundingClientRect();
38
+ const viewportPoint = {
39
+ x: nativeEvent.clientX - rect.left,
40
+ y: nativeEvent.clientY - rect.top,
41
+ };
42
+ // We cast to 'any' here because we are manually triggering an internal
43
+ // event bus, and Fabric's internal types for .fire() can be overly strict.
44
+ canvas.fire("mouse:wheel", {
45
+ e: nativeEvent,
46
+ scenePoint,
47
+ viewportPoint,
48
+ });
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+ }
52
+ };
55
53
  useEffect(() => {
56
54
  const overlayEl = overlayRef.current;
57
55
  const canvas = fabricCanvas?.current;
58
56
  if (!overlayEl || !canvas)
59
57
  return;
60
58
  const handleGlobalWheel = (e) => {
61
- const isOverNode = e.target !== overlayEl;
59
+ // Check if the user is hovering over an element that has pointer-events: auto
60
+ // (meaning they are hovering over a Task or Document)
61
+ const target = e.target;
62
+ const isOverNode = target !== overlayEl;
62
63
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
64
+ // 1. Prevent Browser Zoom immediately
63
65
  e.preventDefault();
64
66
  e.stopPropagation();
67
+ // 2. Calculate coordinates for Fabric
65
68
  const scenePoint = canvas.getScenePoint(e);
66
69
  const rect = canvas.getElement().getBoundingClientRect();
70
+ const viewportPoint = {
71
+ x: e.clientX - rect.left,
72
+ y: e.clientY - rect.top,
73
+ };
74
+ // 3. Manually fire the event into Fabric
67
75
  canvas.fire("mouse:wheel", {
68
- e,
76
+ e: e,
69
77
  scenePoint,
70
- viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
78
+ viewportPoint,
71
79
  });
72
80
  }
73
81
  };
82
+ // CRITICAL: { passive: false } allows us to cancel the browser's zoom
74
83
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
75
- return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
76
- }, [fabricCanvas]);
77
- // ─── Fabric → Overlay sync + deselection ───────────────────────────────────
78
- // Registered once selectedIds always read via selectedIdsRef.
84
+ return () => {
85
+ overlayEl.removeEventListener("wheel", handleGlobalWheel);
86
+ };
87
+ }, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
88
+ // ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
79
89
  useEffect(() => {
80
90
  const canvas = fabricCanvas?.current;
81
91
  if (!canvas)
@@ -90,7 +100,8 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
90
100
  target._prevTop = target.top;
91
101
  if (deltaX === 0 && deltaY === 0)
92
102
  return;
93
- const sel = selectedIdsRef.current; // always fresh
103
+ // ── Read from ref always fresh, never stale ──
104
+ const sel = selectedIdsRef.current;
94
105
  setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
95
106
  setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
96
107
  };
@@ -101,76 +112,158 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
101
112
  target._prevTop = target.top;
102
113
  }
103
114
  if (!target) {
104
- // Clicked empty canvas — always deselect HTML nodes
105
115
  setSelectedIds(new Set());
106
116
  return;
107
117
  }
108
- // Clicked a Fabric objectonly deselect HTML nodes if NOT already in
109
- // the active selection (preserves mixed-group drag behaviour)
118
+ // ── Read from refnot stale closure ──
110
119
  const activeObjects = canvas.getActiveObjects();
111
- if (!activeObjects.includes(target)) {
112
- setSelectedIds(new Set());
113
- }
114
- };
115
- // Clear HTML selection on fresh Fabric selection, but never mid-drag
116
- const handleFabricSelection = () => {
117
- if (!dragStateRef.current.isDragging) {
120
+ const isTargetAlreadySelected = activeObjects.includes(target);
121
+ if (!isTargetAlreadySelected) {
118
122
  setSelectedIds(new Set());
119
123
  }
120
124
  };
121
125
  canvas.on("object:moving", handleObjectMoving);
122
126
  canvas.on("mouse:down", handleMouseDown);
123
- canvas.on("selection:created", handleFabricSelection);
124
- canvas.on("selection:updated", handleFabricSelection);
125
127
  return () => {
126
128
  canvas.off("object:moving", handleObjectMoving);
127
129
  canvas.off("mouse:down", handleMouseDown);
128
- canvas.off("selection:created", handleFabricSelection);
129
- canvas.off("selection:updated", handleFabricSelection);
130
130
  };
131
- }, [fabricCanvas]); // selectedIds NOT in deps — read via ref
132
- // ─── Selection box hit detection ───────────────────────────────────────────
131
+ // ── selectedIds REMOVED from deps — read via selectedIdsRef instead ──────
132
+ // Having selectedIds here caused the effect to re-register on every selection
133
+ // change, creating a new closure each time. The second drag captured a stale
134
+ // or empty selectedIds from the closure at re-registration time.
135
+ }, [canvasZoom, fabricCanvas]);
136
+ // ── Helpers ─────────────────────────────────────────────────────────────────
137
+ const getItemPosition = (id) => {
138
+ const task = localTasks.find((t) => t.id === id);
139
+ if (task)
140
+ return { x: task.x, y: task.y };
141
+ const doc = localDocuments.find((d) => d.id === id);
142
+ if (doc)
143
+ return { x: doc.x, y: doc.y };
144
+ return undefined;
145
+ };
146
+ const isItemInSelectionBox = (x, y, width, height, box) => {
147
+ const itemX1 = x * canvasZoom + canvasViewport.x;
148
+ const itemY1 = y * canvasZoom + canvasViewport.y;
149
+ const itemX2 = itemX1 + width * canvasZoom;
150
+ const itemY2 = itemY1 + height * canvasZoom;
151
+ const boxX1 = Math.min(box.x1, box.x2);
152
+ const boxY1 = Math.min(box.y1, box.y2);
153
+ const boxX2 = Math.max(box.x1, box.x2);
154
+ const boxY2 = Math.max(box.y1, box.y2);
155
+ return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
156
+ };
157
+ // ── Selection box detection ──────────────────────────────────────────────────
133
158
  useEffect(() => {
134
159
  if (!selectionBox)
135
160
  return;
136
- const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
137
- const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
138
- const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
139
- const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
140
- const hits = (x, y, w, h) => {
141
- const x1 = x * canvasZoom + canvasViewport.x;
142
- const y1 = y * canvasZoom + canvasViewport.y;
143
- return !(bX2 < x1 || bX1 > x1 + w * canvasZoom || bY2 < y1 || bY1 > y1 + h * canvasZoom);
144
- };
161
+ // ── O(n) single pass — no sort, no join, no extra allocations ──
145
162
  const newSelected = new Set();
146
- for (const task of localTasks)
147
- if (hits(task.x, task.y, 300, 140))
163
+ for (const task of localTasks) {
164
+ if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
148
165
  newSelected.add(task.id);
149
- for (const doc of localDocuments)
150
- if (hits(doc.x, doc.y, 320, 160))
166
+ }
167
+ for (const doc of localDocuments) {
168
+ if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
151
169
  newSelected.add(doc.id);
152
- // O(n) equality — same Set ref if unchanged → no re-render
170
+ }
171
+ // ── O(n) equality check: size first (fast path), then membership ──
153
172
  setSelectedIds((prev) => {
154
173
  if (prev.size !== newSelected.size)
155
174
  return newSelected;
156
- for (const id of newSelected)
175
+ for (const id of newSelected) {
157
176
  if (!prev.has(id))
158
- return newSelected;
159
- return prev;
177
+ return newSelected; // found a difference, swap
178
+ }
179
+ return prev; // identical — return same reference, no re-render
160
180
  });
161
181
  }, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
162
- // ─── Global drag listeners registered ONCE on mount ──────────────────────
163
- // Critical: must NOT be in a useEffect with [localTasks] or any state deps.
164
- // Every setLocalTasks during drag would tear down + reattach mousemove → jump.
165
- // All values read via refs so the [] closure is always correct.
182
+ // ── Drag start (HTML Node side) ──────────────────────────────────────────────
183
+ // Helper to extract coordinates regardless of event type
184
+ const getPointerEvent = (e) => {
185
+ if ('touches' in e && e.touches.length > 0)
186
+ return e.touches[0];
187
+ return e;
188
+ };
189
+ const handleDragStart = (itemId, e) => {
190
+ // 1. Safety check for the Fabric instance
191
+ const canvas = fabricCanvas?.current;
192
+ if (!canvas)
193
+ return;
194
+ // 2. Normalize the event (Touch vs Mouse)
195
+ if (e.cancelable)
196
+ e.preventDefault();
197
+ const pointer = getPointerEvent(e);
198
+ // 3. Determine which items are being dragged
199
+ // selection update DOES NOT trigger before drag snapshot
200
+ let itemsToDrag;
201
+ if (selectedIds.has(itemId)) {
202
+ itemsToDrag = Array.from(selectedIds);
203
+ }
204
+ else {
205
+ itemsToDrag = [itemId];
206
+ }
207
+ // 4. Capture current World Transform (Zoom & Pan)
208
+ // We read directly from the canvas to ensure zero-frame lag
209
+ const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
210
+ const liveZoom = vpt[0];
211
+ const liveVpX = vpt[4];
212
+ const liveVpY = vpt[5];
213
+ // 5. Convert the Click Position from Screen Pixels to World Units
214
+ const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
215
+ const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
216
+ // 6. Get the clicked item's current World Position
217
+ const clickedPos = getItemPosition(itemId);
218
+ if (!clickedPos)
219
+ return;
220
+ // 7. Calculate the Offset in WORLD UNITS
221
+ // This is the distance from the mouse to the node's top-left in the infinite grid.
222
+ // This value remains constant even if you zoom during the drag.
223
+ const worldOffsetX = clickWorldX - clickedPos.x;
224
+ const worldOffsetY = clickWorldY - clickedPos.y;
225
+ // 8. Snapshot starting positions for all selected HTML nodes
226
+ const startPositions = new Map();
227
+ itemsToDrag.forEach((id) => {
228
+ const pos = getItemPosition(id);
229
+ if (pos)
230
+ startPositions.set(id, pos);
231
+ });
232
+ // 9. Snapshot starting positions for all selected Fabric objects
233
+ const canvasObjectsStartPos = new Map();
234
+ selectedCanvasObjects.forEach((obj) => {
235
+ canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
236
+ });
237
+ // 10. Commit to the ref for the requestAnimationFrame loop
238
+ dragStateRef.current = {
239
+ isDragging: true,
240
+ itemIds: itemsToDrag,
241
+ startPositions,
242
+ canvasObjectsStartPos,
243
+ offsetX: worldOffsetX, // Now stored as World Units
244
+ offsetY: worldOffsetY, // Now stored as World Units
245
+ };
246
+ if (!selectedIds.has(itemId) && dragStateRef.current.itemIds.length === 0) {
247
+ setSelectedIds(new Set([itemId]));
248
+ }
249
+ // 11. Trigger UI states
250
+ setDragging({ itemIds: itemsToDrag });
251
+ document.body.style.cursor = "grabbing";
252
+ document.body.style.userSelect = "none";
253
+ document.body.style.touchAction = "none";
254
+ };
255
+ // ── Drag move (HTML Node side) ───────────────────────────────────────────────
166
256
  useEffect(() => {
257
+ if (!dragging)
258
+ return;
259
+ // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
167
260
  const handleMove = (e) => {
168
261
  if (!dragStateRef.current.isDragging)
169
262
  return;
170
263
  if (e.cancelable)
171
264
  e.preventDefault();
172
- const pointer = "touches" in e && e.touches.length > 0
173
- ? e.touches[0] : e;
265
+ const pointer = getPointerEvent(e);
266
+ // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
174
267
  if (rafIdRef.current !== null)
175
268
  cancelAnimationFrame(rafIdRef.current);
176
269
  rafIdRef.current = requestAnimationFrame(() => {
@@ -178,48 +271,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
178
271
  const canvas = fabricCanvas?.current;
179
272
  if (!canvas)
180
273
  return;
274
+ // 2. Read the "Source of Truth" transform from the canvas
181
275
  const vpt = canvas.viewportTransform;
182
- const liveZoom = vpt[0];
183
- const liveVpX = vpt[4];
184
- const liveVpY = vpt[5];
185
- const newWorldX = (pointer.clientX - liveVpX) / liveZoom - offsetX;
186
- const newWorldY = (pointer.clientY - liveVpY) / liveZoom - offsetY;
187
- const firstStart = startPositions.get(itemIds[0]);
276
+ const liveZoom = vpt[0]; // Scale
277
+ const liveVpX = vpt[4]; // Pan X
278
+ const liveVpY = vpt[5]; // Pan Y
279
+ // 3. Convert current Mouse Screen Position World Position
280
+ const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
281
+ const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
282
+ // 4. Calculate where the "Anchor" node should be in World Units
283
+ // (Current Mouse World - Initial World Offset from Start)
284
+ const newWorldX = currentWorldX - offsetX;
285
+ const newWorldY = currentWorldY - offsetY;
286
+ // 5. Calculate the Movement Delta in World Units
287
+ // We compare where the first item started vs where it is now.
288
+ const firstId = itemIds[0];
289
+ const firstStart = startPositions.get(firstId);
188
290
  if (!firstStart)
189
291
  return;
190
292
  const deltaX = newWorldX - firstStart.x;
191
293
  const deltaY = newWorldY - firstStart.y;
192
- setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
193
- ? { ...t,
194
- x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
195
- y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
196
- : t));
197
- setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
198
- ? { ...d,
199
- x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
200
- y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
201
- : d));
294
+ // 6. Update HTML Nodes (Batching these into one state update)
295
+ setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
296
+ ...t,
297
+ x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
298
+ y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
299
+ } : t));
300
+ setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
301
+ ...d,
302
+ x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
303
+ y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
304
+ } : d));
305
+ // 7. Sync Fabric Objects (Imperative update for performance)
202
306
  canvasObjectsStartPos.forEach((startPos, obj) => {
203
- obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
204
- obj.setCoords();
307
+ obj.set({
308
+ left: startPos.left + deltaX,
309
+ top: startPos.top + deltaY,
310
+ });
311
+ obj.setCoords(); // Required for selection/intersection accuracy
205
312
  });
313
+ // 8. Single render call for all Fabric changes
206
314
  canvas.requestRenderAll();
207
315
  });
208
316
  };
209
317
  const handleEnd = () => {
210
- if (!dragStateRef.current.isDragging)
211
- return;
212
- if (rafIdRef.current !== null) {
318
+ if (rafIdRef.current !== null)
213
319
  cancelAnimationFrame(rafIdRef.current);
214
- rafIdRef.current = null;
215
- }
216
320
  dragStateRef.current.isDragging = false;
321
+ setDragging(null);
217
322
  document.body.style.cursor = "";
218
323
  document.body.style.userSelect = "";
219
324
  document.body.style.touchAction = "";
220
- // Flush via functional updater (reads latest state) + callback ref (never stale)
221
- setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
222
- setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
325
+ onTasksUpdate?.(localTasks);
326
+ onDocumentsUpdate?.(localDocuments);
223
327
  };
224
328
  window.addEventListener("mousemove", handleMove, { passive: false });
225
329
  window.addEventListener("mouseup", handleEnd);
@@ -233,61 +337,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
233
337
  window.removeEventListener("touchend", handleEnd);
234
338
  window.removeEventListener("touchcancel", handleEnd);
235
339
  };
236
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
237
- // ─── Drag start ────────────────────────────────────────────────────────────
238
- // useCallback: stable ref so MemoTaskNode/MemoDocumentNode don't re-render
239
- // on unrelated parent state changes.
240
- const handleDragStart = useCallback((itemId, e) => {
241
- const canvas = fabricCanvas?.current;
242
- if (!canvas)
243
- return;
244
- if (e.cancelable)
245
- e.preventDefault();
246
- const pointer = "touches" in e && e.touches.length > 0
247
- ? e.touches[0] : e;
248
- // Read from ref — never stale even if setSelectedIds was called this render
249
- const currentSelected = selectedIdsRef.current;
250
- const itemsToDrag = currentSelected.has(itemId)
251
- ? Array.from(currentSelected)
252
- : [itemId];
253
- if (!currentSelected.has(itemId)) {
254
- setSelectedIds(new Set([itemId]));
255
- }
256
- const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
257
- const liveZoom = vpt[0];
258
- const liveVpX = vpt[4];
259
- const liveVpY = vpt[5];
260
- // O(1) ref lookup — no stale state scan
261
- const clickedPos = nodePositionsRef.current.get(itemId);
262
- if (!clickedPos)
263
- return;
264
- const startPositions = new Map();
265
- for (const id of itemsToDrag) {
266
- const pos = nodePositionsRef.current.get(id);
267
- if (pos)
268
- startPositions.set(id, { x: pos.x, y: pos.y });
269
- }
270
- // Read Fabric objects from ref — not stale prop
271
- const canvasObjectsStartPos = new Map();
272
- for (const obj of selectedCanvasObjectsRef.current) {
273
- canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
274
- }
275
- // Write only to ref — zero re-render on drag start (eliminates listener churn)
276
- dragStateRef.current = {
277
- isDragging: true,
278
- itemIds: itemsToDrag,
279
- startPositions,
280
- canvasObjectsStartPos,
281
- offsetX: (pointer.clientX - liveVpX) / liveZoom - clickedPos.x,
282
- offsetY: (pointer.clientY - liveVpY) / liveZoom - clickedPos.y,
283
- };
284
- document.body.style.cursor = "grabbing";
285
- document.body.style.userSelect = "none";
286
- document.body.style.touchAction = "none";
287
- }, [fabricCanvas]);
288
- // ─── Node interaction handlers ─────────────────────────────────────────────
289
- // useCallback: stable refs prevent unnecessary re-renders of memoized nodes
290
- const handleSelect = useCallback((id, e) => {
340
+ }, [dragging, localTasks, localDocuments, fabricCanvas]);
341
+ // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
342
+ const handleSelect = (id, e) => {
291
343
  if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
292
344
  setSelectedIds((prev) => {
293
345
  const next = new Set(prev);
@@ -298,65 +350,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
298
350
  else {
299
351
  setSelectedIds(new Set([id]));
300
352
  }
301
- }, []);
302
- const handleStatusChange = useCallback((taskId, newStatus) => {
303
- setLocalTasks((prev) => {
304
- const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
305
- onTasksUpdateRef.current?.(updated);
306
- return updated;
307
- });
308
- }, []);
309
- // ─── Keyboard shortcuts ────────────────────────────────────────────────────
310
- // selectedIds removed from deps — read via selectedIdsRef (prevents
311
- // re-registering the keydown listener on every single selection change)
353
+ };
354
+ const handleStatusChange = (taskId, newStatus) => {
355
+ const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
356
+ setLocalTasks(updated);
357
+ onTasksUpdate?.(updated);
358
+ };
312
359
  useEffect(() => {
313
360
  const handleKeyDown = (e) => {
361
+ // Don't trigger if typing in input
314
362
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
315
363
  return;
364
+ // Select All
316
365
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
317
366
  e.preventDefault();
318
- setSelectedIds(new Set([
319
- ...localTasks.map((t) => t.id),
320
- ...localDocuments.map((d) => d.id),
321
- ]));
367
+ setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
322
368
  }
369
+ // Clear selection
323
370
  if (e.key === "Escape") {
324
371
  setSelectedIds(new Set());
325
372
  }
326
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
373
+ // ADD THIS: Delete selected nodes
374
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
327
375
  e.preventDefault();
328
- const ids = selectedIdsRef.current;
329
- setLocalTasks((prev) => {
330
- const u = prev.filter((t) => !ids.has(t.id));
331
- onTasksUpdateRef.current?.(u);
332
- return u;
333
- });
334
- setLocalDocuments((prev) => {
335
- const u = prev.filter((d) => !ids.has(d.id));
336
- onDocumentsUpdateRef.current?.(u);
337
- return u;
338
- });
376
+ const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
377
+ const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
378
+ setLocalTasks(updatedTasks);
379
+ setLocalDocuments(updatedDocs);
339
380
  setSelectedIds(new Set());
381
+ onTasksUpdate?.(updatedTasks);
382
+ onDocumentsUpdate?.(updatedDocs);
340
383
  }
341
384
  };
342
385
  window.addEventListener("keydown", handleKeyDown);
343
386
  return () => window.removeEventListener("keydown", handleKeyDown);
344
- }, [localTasks, localDocuments]);
345
- // ─── Render ────────────────────────────────────────────────────────────────
346
- const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
347
- left: 0,
348
- top: 0,
349
- transform: `translate3d(${x * canvasZoom}px, ${y * canvasZoom}px, 0) scale(${canvasZoom})`,
350
- transformOrigin: "top left",
351
- transition: "none",
352
- willChange: "transform",
353
- zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
354
- }, children: children }, id));
355
- return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => {
387
+ }, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
388
+ // ── Render helper ────────────────────────────────────────────────────────────
389
+ const renderItem = (id, x, y, children) => {
390
+ const screenX = x * canvasZoom;
391
+ const screenY = y * canvasZoom;
392
+ // 1. Detect if the user is interacting with the canvas at all
393
+ // 'dragging' is your existing state.
394
+ // You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
395
+ const isDragging = dragging?.itemIds.includes(id);
396
+ return (_jsx("div", { className: "pointer-events-auto absolute", style: {
397
+ left: 0,
398
+ top: 0,
399
+ // 2. Use translate3d for GPU performance
400
+ transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
401
+ transformOrigin: "top left",
402
+ // 3. THE FIX: Remove transition entirely during any viewport change
403
+ // Any 'ease' during zoom causes the "shaking" behavior.
404
+ transition: "none",
405
+ // 4. Optimization
406
+ willChange: "transform",
407
+ zIndex: isDragging ? 1000 : 1,
408
+ }, children: children }, id));
409
+ };
410
+ return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
356
411
  if (e.target === e.currentTarget)
357
412
  setSelectedIds(new Set());
358
413
  }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
359
414
  transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
360
415
  transformOrigin: "top left",
361
- }, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(MemoTaskNode, { ...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(MemoDocumentNode, { ...doc, isSelected: selectedIds.has(doc.id), onSelect: handleSelect, onDragStart: handleDragStart })))] }) }));
416
+ }, 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 })))] }) }));
362
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.35.0",
3
+ "version": "2.37.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",