@mhamz.01/easyflow-whiteboard 2.30.0 → 2.33.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,2CAqZzB"}
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,81 +1,96 @@
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
- // ── PERF: Memoized node wrappers ──────────────────────────────────────────────
7
- // Prevents sibling nodes from re-rendering when only one node's position changes.
8
- // Without memo, every setLocalTasks call during drag re-renders ALL nodes.
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
- // ─── Core refs ────────────────────────────────────────────────────────────
11
+ const [dragging, setDragging] = useState(null);
17
12
  const dragStateRef = useRef({
18
- isDragging: false, itemIds: [],
19
- startPositions: new Map(), canvasObjectsStartPos: new Map(),
20
- offsetX: 0, offsetY: 0,
13
+ isDragging: false,
14
+ itemIds: [],
15
+ startPositions: new Map(),
16
+ canvasObjectsStartPos: new Map(),
17
+ offsetX: 0,
18
+ offsetY: 0,
21
19
  });
22
20
  const rafIdRef = useRef(null);
23
21
  const overlayRef = useRef(null);
24
- // ── PERF: Mutex prevents object:moving and handleMove writing simultaneously ─
25
- const isHtmlDraggingRef = useRef(false);
26
- // ── PERF: Always-fresh refs — rebuilt synchronously every render ──────────
27
- // Eliminates ALL stale closure problems without any useCallback deps.
28
- // nodePositionsRef: ground truth for positions at drag start
29
- const nodePositionsRef = useRef(new Map());
30
- nodePositionsRef.current = new Map([
31
- ...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
32
- ...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
33
- ]);
34
- // selectedIdsRef: used in stable effect closures
35
22
  const selectedIdsRef = useRef(selectedIds);
36
23
  selectedIdsRef.current = selectedIds;
37
- // selectedCanvasObjectsRef: avoids stale prop in handleDragStart
38
- const selectedCanvasObjectsRef = useRef(selectedCanvasObjects);
39
- selectedCanvasObjectsRef.current = selectedCanvasObjects;
40
- // Parent callbacks in refs — handleEnd never captures stale callbacks
41
- const onTasksUpdateRef = useRef(onTasksUpdate);
42
- const onDocumentsUpdateRef = useRef(onDocumentsUpdate);
43
- onTasksUpdateRef.current = onTasksUpdate;
44
- onDocumentsUpdateRef.current = onDocumentsUpdate;
45
- // ─── Sync props → local state ─────────────────────────────────────────────
24
+ // ── Sync props local state ────────────────────────────────────────────────
46
25
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
47
26
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
48
- // ─── Wheel forwarding ─────────────────────────────────────────────────────
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
+ };
49
53
  useEffect(() => {
50
54
  const overlayEl = overlayRef.current;
51
55
  const canvas = fabricCanvas?.current;
52
56
  if (!overlayEl || !canvas)
53
57
  return;
54
58
  const handleGlobalWheel = (e) => {
55
- 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;
56
63
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
64
+ // 1. Prevent Browser Zoom immediately
57
65
  e.preventDefault();
58
66
  e.stopPropagation();
67
+ // 2. Calculate coordinates for Fabric
59
68
  const scenePoint = canvas.getScenePoint(e);
60
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
61
75
  canvas.fire("mouse:wheel", {
62
- e, scenePoint,
63
- viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
76
+ e: e,
77
+ scenePoint,
78
+ viewportPoint,
64
79
  });
65
80
  }
66
81
  };
82
+ // CRITICAL: { passive: false } allows us to cancel the browser's zoom
67
83
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
68
- return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
69
- }, [fabricCanvas]);
70
- // ─── Fabric → Overlay sync ────────────────────────────────────────────────
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) ──────────────────
71
89
  useEffect(() => {
72
90
  const canvas = fabricCanvas?.current;
73
91
  if (!canvas)
74
92
  return;
75
93
  const handleObjectMoving = (e) => {
76
- // MUTEX: HTML drag and Fabric drag must never write positions simultaneously
77
- if (isHtmlDraggingRef.current)
78
- return;
79
94
  const target = e.transform?.target || e.target;
80
95
  if (!target)
81
96
  return;
@@ -85,6 +100,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
85
100
  target._prevTop = target.top;
86
101
  if (deltaX === 0 && deltaY === 0)
87
102
  return;
103
+ // ── Read from ref — always fresh, never stale ──
88
104
  const sel = selectedIdsRef.current;
89
105
  setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
90
106
  setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
@@ -95,6 +111,16 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
95
111
  target._prevLeft = target.left;
96
112
  target._prevTop = target.top;
97
113
  }
114
+ if (!target) {
115
+ setSelectedIds(new Set());
116
+ return;
117
+ }
118
+ // ── Read from ref — not stale closure ──
119
+ const activeObjects = canvas.getActiveObjects();
120
+ const isTargetAlreadySelected = activeObjects.includes(target);
121
+ if (!isTargetAlreadySelected) {
122
+ setSelectedIds(new Set());
123
+ }
98
124
  };
99
125
  canvas.on("object:moving", handleObjectMoving);
100
126
  canvas.on("mouse:down", handleMouseDown);
@@ -102,68 +128,142 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
102
128
  canvas.off("object:moving", handleObjectMoving);
103
129
  canvas.off("mouse:down", handleMouseDown);
104
130
  };
105
- }, [fabricCanvas]);
106
- // ─── Selection box ────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────
107
158
  useEffect(() => {
108
159
  if (!selectionBox)
109
160
  return;
161
+ // ── O(n) single pass — no sort, no join, no extra allocations ──
110
162
  const newSelected = new Set();
111
163
  for (const task of localTasks) {
112
- const x1 = task.x * canvasZoom + canvasViewport.x;
113
- const y1 = task.y * canvasZoom + canvasViewport.y;
114
- const x2 = x1 + 300 * canvasZoom;
115
- const y2 = y1 + 140 * canvasZoom;
116
- const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
117
- const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
118
- const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
119
- const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
120
- if (!(bX2 < x1 || bX1 > x2 || bY2 < y1 || bY1 > y2))
164
+ if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
121
165
  newSelected.add(task.id);
122
166
  }
123
167
  for (const doc of localDocuments) {
124
- const x1 = doc.x * canvasZoom + canvasViewport.x;
125
- const y1 = doc.y * canvasZoom + canvasViewport.y;
126
- const x2 = x1 + 320 * canvasZoom;
127
- const y2 = y1 + 160 * canvasZoom;
128
- const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
129
- const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
130
- const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
131
- const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
132
- if (!(bX2 < x1 || bX1 > x2 || bY2 < y1 || bY1 > y2))
168
+ if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
133
169
  newSelected.add(doc.id);
134
170
  }
135
- // O(n) equality same Set ref if unchanged, blocks unnecessary re-render
171
+ // ── O(n) equality check: size first (fast path), then membership ──
136
172
  setSelectedIds((prev) => {
137
173
  if (prev.size !== newSelected.size)
138
174
  return newSelected;
139
- for (const id of newSelected)
175
+ for (const id of newSelected) {
140
176
  if (!prev.has(id))
141
- return newSelected;
142
- return prev;
177
+ return newSelected; // found a difference, swap
178
+ }
179
+ return prev; // identical — return same reference, no re-render
143
180
  });
144
181
  }, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
145
- // ─── Global drag listeners attached ONCE on mount ───────────────────────
146
- //
147
- // ROOT CAUSE OF THE POSITION JUMP — fixed here:
148
- //
149
- // Old pattern: useEffect(() => { mousemove }, [dragging, localTasks, localDocuments])
150
- // Problem:
151
- // 1. handleDragStart → setDragging() → React re-render
152
- // 2. Re-render tears down mousemove listener, attaches new one
153
- // 3. First real mousemove fires into old (dead) listener → ignored or wrong
154
- // 4. New listener fires with potentially different startPositions → JUMP
155
- //
156
- // Fix: attach listeners ONCE. They read all mutable state via refs.
157
- // Zero re-registration, zero double-fires, zero stale closure issues.
158
- //
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) ───────────────────────────────────────────────
159
256
  useEffect(() => {
257
+ if (!dragging)
258
+ return;
259
+ // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
160
260
  const handleMove = (e) => {
161
261
  if (!dragStateRef.current.isDragging)
162
262
  return;
163
263
  if (e.cancelable)
164
264
  e.preventDefault();
165
- const pointer = "touches" in e && e.touches.length > 0
166
- ? e.touches[0] : e;
265
+ const pointer = getPointerEvent(e);
266
+ // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
167
267
  if (rafIdRef.current !== null)
168
268
  cancelAnimationFrame(rafIdRef.current);
169
269
  rafIdRef.current = requestAnimationFrame(() => {
@@ -171,50 +271,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
171
271
  const canvas = fabricCanvas?.current;
172
272
  if (!canvas)
173
273
  return;
174
- // Live VPT never from React state
274
+ // 2. Read the "Source of Truth" transform from the canvas
175
275
  const vpt = canvas.viewportTransform;
176
- const liveZoom = vpt[0];
177
- const liveVpX = vpt[4];
178
- const liveVpY = vpt[5];
179
- // Screen → World, subtract world-space offset
180
- const newWorldX = (pointer.clientX - liveVpX) / liveZoom - offsetX;
181
- const newWorldY = (pointer.clientY - liveVpY) / liveZoom - offsetY;
182
- 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);
183
290
  if (!firstStart)
184
291
  return;
185
292
  const deltaX = newWorldX - firstStart.x;
186
293
  const deltaY = newWorldY - firstStart.y;
187
- // ── PERF: Only map over items being dragged ────────────────────────
188
- setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
189
- ? { ...t, x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
190
- y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
191
- : t));
192
- setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
193
- ? { ...d, x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
194
- y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
195
- : d));
196
- // Sync Fabric objects imperatively
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),
304
+ } : d));
305
+ // 7. Sync Fabric Objects (Imperative update for performance)
197
306
  canvasObjectsStartPos.forEach((startPos, obj) => {
198
- obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
199
- obj.setCoords();
307
+ obj.set({
308
+ left: startPos.left + deltaX,
309
+ top: startPos.top + deltaY,
310
+ });
311
+ obj.setCoords(); // Required for selection/intersection accuracy
200
312
  });
313
+ // 8. Single render call for all Fabric changes
201
314
  canvas.requestRenderAll();
202
315
  });
203
316
  };
204
317
  const handleEnd = () => {
205
- if (!dragStateRef.current.isDragging)
206
- return;
207
- if (rafIdRef.current !== null) {
318
+ if (rafIdRef.current !== null)
208
319
  cancelAnimationFrame(rafIdRef.current);
209
- rafIdRef.current = null;
210
- }
211
320
  dragStateRef.current.isDragging = false;
212
- isHtmlDraggingRef.current = false;
321
+ setDragging(null);
213
322
  document.body.style.cursor = "";
214
323
  document.body.style.userSelect = "";
215
324
  document.body.style.touchAction = "";
216
- setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
217
- setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
325
+ onTasksUpdate?.(localTasks);
326
+ onDocumentsUpdate?.(localDocuments);
218
327
  };
219
328
  window.addEventListener("mousemove", handleMove, { passive: false });
220
329
  window.addEventListener("mouseup", handleEnd);
@@ -228,62 +337,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
228
337
  window.removeEventListener("touchend", handleEnd);
229
338
  window.removeEventListener("touchcancel", handleEnd);
230
339
  };
231
- // Empty deps — registered once, reads everything via refs
232
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
233
- // ─── Drag start ───────────────────────────────────────────────────────────
234
- const handleDragStart = useCallback((itemId, e) => {
235
- const canvas = fabricCanvas?.current;
236
- if (!canvas)
237
- return;
238
- if (e.cancelable)
239
- e.preventDefault();
240
- const pointer = "touches" in e && e.touches.length > 0
241
- ? e.touches[0] : e;
242
- // Read from ref — not state (avoids async lag from setSelectedIds)
243
- const currentSelected = selectedIdsRef.current;
244
- const itemsToDrag = currentSelected.has(itemId)
245
- ? Array.from(currentSelected) : [itemId];
246
- if (!currentSelected.has(itemId))
247
- setSelectedIds(new Set([itemId]));
248
- // Live VPT
249
- const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
250
- const liveZoom = vpt[0];
251
- const liveVpX = vpt[4];
252
- const liveVpY = vpt[5];
253
- // Pointer → world
254
- const pointerWorldX = (pointer.clientX - liveVpX) / liveZoom;
255
- const pointerWorldY = (pointer.clientY - liveVpY) / liveZoom;
256
- // nodePositionsRef is rebuilt synchronously every render — never stale
257
- const clickedPos = nodePositionsRef.current.get(itemId);
258
- if (!clickedPos)
259
- return;
260
- // Snapshot ALL start positions from ref synchronously
261
- const startPositions = new Map();
262
- for (const id of itemsToDrag) {
263
- const pos = nodePositionsRef.current.get(id);
264
- if (pos)
265
- startPositions.set(id, { x: pos.x, y: pos.y });
266
- }
267
- const canvasObjectsStartPos = new Map();
268
- for (const obj of selectedCanvasObjectsRef.current) {
269
- canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
270
- }
271
- dragStateRef.current = {
272
- isDragging: true,
273
- itemIds: itemsToDrag,
274
- startPositions,
275
- canvasObjectsStartPos,
276
- // World-space offset: pointer distance from node top-left in world units
277
- offsetX: pointerWorldX - clickedPos.x,
278
- offsetY: pointerWorldY - clickedPos.y,
279
- };
280
- isHtmlDraggingRef.current = true;
281
- document.body.style.cursor = "grabbing";
282
- document.body.style.userSelect = "none";
283
- document.body.style.touchAction = "none";
284
- }, [fabricCanvas]);
285
- // ─── Node interaction ─────────────────────────────────────────────────────
286
- const handleSelect = useCallback((id, e) => {
340
+ }, [dragging, localTasks, localDocuments, fabricCanvas]);
341
+ // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
342
+ const handleSelect = (id, e) => {
287
343
  if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
288
344
  setSelectedIds((prev) => {
289
345
  const next = new Set(prev);
@@ -294,48 +350,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
294
350
  else {
295
351
  setSelectedIds(new Set([id]));
296
352
  }
297
- }, []);
298
- const handleStatusChange = useCallback((taskId, newStatus) => {
299
- setLocalTasks((prev) => {
300
- const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
301
- onTasksUpdateRef.current?.(updated);
302
- return updated;
303
- });
304
- }, []);
305
- // ─── Keyboard shortcuts ───────────────────────────────────────────────────
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
+ };
306
359
  useEffect(() => {
307
360
  const handleKeyDown = (e) => {
361
+ // Don't trigger if typing in input
308
362
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
309
363
  return;
364
+ // Select All
310
365
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
311
366
  e.preventDefault();
312
367
  setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
313
368
  }
314
- if (e.key === "Escape")
369
+ // Clear selection
370
+ if (e.key === "Escape") {
315
371
  setSelectedIds(new Set());
316
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
372
+ }
373
+ // ← ADD THIS: Delete selected nodes
374
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
317
375
  e.preventDefault();
318
- const ids = selectedIdsRef.current;
319
- setLocalTasks((prev) => { const u = prev.filter((t) => !ids.has(t.id)); onTasksUpdateRef.current?.(u); return u; });
320
- setLocalDocuments((prev) => { const u = prev.filter((d) => !ids.has(d.id)); onDocumentsUpdateRef.current?.(u); return u; });
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);
321
380
  setSelectedIds(new Set());
381
+ onTasksUpdate?.(updatedTasks);
382
+ onDocumentsUpdate?.(updatedDocs);
322
383
  }
323
384
  };
324
385
  window.addEventListener("keydown", handleKeyDown);
325
386
  return () => window.removeEventListener("keydown", handleKeyDown);
326
- }, [localTasks, localDocuments]);
327
- // ─── Render ───────────────────────────────────────────────────────────────
328
- const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
329
- left: 0, top: 0,
330
- transform: `translate3d(${x * canvasZoom}px, ${y * canvasZoom}px, 0) scale(${canvasZoom})`,
331
- transformOrigin: "top left",
332
- transition: "none", // No CSS transitions causes shaking during zoom
333
- willChange: "transform", // GPU layer hint
334
- zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
335
- }, children: children }, id));
336
- return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onClick: (e) => { if (e.target === e.currentTarget)
337
- setSelectedIds(new Set()); }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
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) => {
411
+ if (e.target === e.currentTarget)
412
+ setSelectedIds(new Set());
413
+ }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
338
414
  transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
339
415
  transformOrigin: "top left",
340
- }, 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 })))] }) }));
341
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.30.0",
3
+ "version": "2.33.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",