@mhamz.01/easyflow-whiteboard 2.36.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;AAwBD,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,2CAmezB"}
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: [],
@@ -24,59 +16,76 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
24
16
  canvasObjectsStartPos: new Map(),
25
17
  offsetX: 0,
26
18
  offsetY: 0,
27
- fabricDragHtmlIds: new Set(),
28
19
  });
29
20
  const rafIdRef = useRef(null);
30
21
  const overlayRef = useRef(null);
31
- // ── Always-fresh refs — assigned synchronously in render body ─────────────
32
- // Read inside stable [] effect closures. Assigned during render (not useEffect)
33
- // so they hold the latest value before any event handler can fire.
34
22
  const selectedIdsRef = useRef(selectedIds);
35
23
  selectedIdsRef.current = selectedIds;
36
- // O(1) world-position lookup replaces O(n) getItemPosition() scans
37
- const nodePositionsRef = useRef(new Map());
38
- nodePositionsRef.current = new Map([
39
- ...localTasks.map((t) => [t.id, { x: t.x, y: t.y }]),
40
- ...localDocuments.map((d) => [d.id, { x: d.x, y: d.y }]),
41
- ]);
42
- // Avoids stale prop capture of selectedCanvasObjects in handleDragStart
43
- const selectedCanvasObjectsRef = useRef(selectedCanvasObjects);
44
- selectedCanvasObjectsRef.current = selectedCanvasObjects;
45
- // Stable callback refs — handleEnd never closes over stale prop functions
46
- const onTasksUpdateRef = useRef(onTasksUpdate);
47
- const onDocumentsUpdateRef = useRef(onDocumentsUpdate);
48
- onTasksUpdateRef.current = onTasksUpdate;
49
- onDocumentsUpdateRef.current = onDocumentsUpdate;
50
- // ─── Sync props → local state ──────────────────────────────────────────────
24
+ // ── Sync props local state ────────────────────────────────────────────────
51
25
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
52
26
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
53
- // ─── Wheel forwarding to Fabric ────────────────────────────────────────────
54
- // `canvasZoom` removed from deps — handler reads canvas state imperatively,
55
- // 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
+ };
56
53
  useEffect(() => {
57
54
  const overlayEl = overlayRef.current;
58
55
  const canvas = fabricCanvas?.current;
59
56
  if (!overlayEl || !canvas)
60
57
  return;
61
58
  const handleGlobalWheel = (e) => {
62
- 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;
63
63
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
64
+ // 1. Prevent Browser Zoom immediately
64
65
  e.preventDefault();
65
66
  e.stopPropagation();
67
+ // 2. Calculate coordinates for Fabric
66
68
  const scenePoint = canvas.getScenePoint(e);
67
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
68
75
  canvas.fire("mouse:wheel", {
69
- e,
76
+ e: e,
70
77
  scenePoint,
71
- viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
78
+ viewportPoint,
72
79
  });
73
80
  }
74
81
  };
82
+ // CRITICAL: { passive: false } allows us to cancel the browser's zoom
75
83
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
76
- return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
77
- }, [fabricCanvas]);
78
- // ─── Fabric → Overlay sync + deselection ───────────────────────────────────
79
- // 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) ──────────────────
80
89
  useEffect(() => {
81
90
  const canvas = fabricCanvas?.current;
82
91
  if (!canvas)
@@ -85,39 +94,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
85
94
  const target = e.transform?.target || e.target;
86
95
  if (!target)
87
96
  return;
88
- // If an HTML-node drag is already in progress (handleDragStart was called),
89
- // object:moving should not also move HTML nodes — the HTML drag loop owns that.
90
- // itemIds.length > 0 means HTML drag is active.
91
- if (dragStateRef.current.isDragging && dragStateRef.current.itemIds.length > 0)
92
- return;
93
- // On the very first object:moving frame of a FABRIC-initiated drag,
94
- // snapshot which HTML nodes were selected at mousedown time — BEFORE
95
- // selection:created/updated had a chance to clear them.
96
- if (!dragStateRef.current.isDragging) {
97
- dragStateRef.current.isDragging = true;
98
- dragStateRef.current.fabricDragHtmlIds = new Set(selectedIdsRef.current);
99
- }
100
97
  const deltaX = target.left - (target._prevLeft ?? target.left);
101
98
  const deltaY = target.top - (target._prevTop ?? target.top);
102
99
  target._prevLeft = target.left;
103
100
  target._prevTop = target.top;
104
101
  if (deltaX === 0 && deltaY === 0)
105
102
  return;
106
- // Use the snapshotted idsimmune to any post-mousedown selection clearing
107
- const sel = dragStateRef.current.fabricDragHtmlIds;
108
- if (sel.size === 0)
109
- return;
103
+ // ── Read from refalways fresh, never stale ──
104
+ const sel = selectedIdsRef.current;
110
105
  setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
111
106
  setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
112
107
  };
113
- // When Fabric drag ends, reset so next gesture gets a fresh snapshot
114
- const handleObjectModified = () => {
115
- // Only reset if this was a Fabric-initiated drag (not HTML drag)
116
- if (dragStateRef.current.isDragging && dragStateRef.current.itemIds.length === 0) {
117
- dragStateRef.current.isDragging = false;
118
- dragStateRef.current.fabricDragHtmlIds = new Set();
119
- }
120
- };
121
108
  const handleMouseDown = (e) => {
122
109
  const target = e.target;
123
110
  if (target) {
@@ -125,84 +112,158 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
125
112
  target._prevTop = target.top;
126
113
  }
127
114
  if (!target) {
128
- // Clicked empty canvas — always deselect HTML nodes
129
115
  setSelectedIds(new Set());
130
116
  return;
131
117
  }
132
- // Clicked a Fabric objectonly deselect HTML nodes if NOT already in
133
- // the active selection (preserves mixed-group drag behaviour).
134
- // Note: getActiveObjects() here reflects the selection BEFORE this click,
135
- // which is exactly what we want — it tells us if this is a continuation
136
- // of an existing selection (drag) vs a fresh click (deselect).
118
+ // ── Read from refnot stale closure ──
137
119
  const activeObjects = canvas.getActiveObjects();
138
- if (!activeObjects.includes(target)) {
139
- setSelectedIds(new Set());
140
- }
141
- // If already selected: selectedIds stays intact.
142
- // handleObjectMoving will snapshot it on the first move frame.
143
- };
144
- // Clear HTML selection on fresh Fabric selection, but never while a
145
- // Fabric drag is active (fabricDragHtmlIds.size > 0 = drag in progress)
146
- const handleFabricSelection = () => {
147
- if (!dragStateRef.current.isDragging) {
120
+ const isTargetAlreadySelected = activeObjects.includes(target);
121
+ if (!isTargetAlreadySelected) {
148
122
  setSelectedIds(new Set());
149
123
  }
150
124
  };
151
125
  canvas.on("object:moving", handleObjectMoving);
152
- canvas.on("object:modified", handleObjectModified);
153
126
  canvas.on("mouse:down", handleMouseDown);
154
- canvas.on("selection:created", handleFabricSelection);
155
- canvas.on("selection:updated", handleFabricSelection);
156
127
  return () => {
157
128
  canvas.off("object:moving", handleObjectMoving);
158
- canvas.off("object:modified", handleObjectModified);
159
129
  canvas.off("mouse:down", handleMouseDown);
160
- canvas.off("selection:created", handleFabricSelection);
161
- canvas.off("selection:updated", handleFabricSelection);
162
130
  };
163
- }, [fabricCanvas]); // selectedIds NOT in deps — read via ref
164
- // ─── 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 ──────────────────────────────────────────────────
165
158
  useEffect(() => {
166
159
  if (!selectionBox)
167
160
  return;
168
- const bX1 = Math.min(selectionBox.x1, selectionBox.x2);
169
- const bY1 = Math.min(selectionBox.y1, selectionBox.y2);
170
- const bX2 = Math.max(selectionBox.x1, selectionBox.x2);
171
- const bY2 = Math.max(selectionBox.y1, selectionBox.y2);
172
- const hits = (x, y, w, h) => {
173
- const x1 = x * canvasZoom + canvasViewport.x;
174
- const y1 = y * canvasZoom + canvasViewport.y;
175
- return !(bX2 < x1 || bX1 > x1 + w * canvasZoom || bY2 < y1 || bY1 > y1 + h * canvasZoom);
176
- };
161
+ // ── O(n) single pass — no sort, no join, no extra allocations ──
177
162
  const newSelected = new Set();
178
- for (const task of localTasks)
179
- if (hits(task.x, task.y, 300, 140))
163
+ for (const task of localTasks) {
164
+ if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
180
165
  newSelected.add(task.id);
181
- for (const doc of localDocuments)
182
- 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))
183
169
  newSelected.add(doc.id);
184
- // O(n) equality — same Set ref if unchanged → no re-render
170
+ }
171
+ // ── O(n) equality check: size first (fast path), then membership ──
185
172
  setSelectedIds((prev) => {
186
173
  if (prev.size !== newSelected.size)
187
174
  return newSelected;
188
- for (const id of newSelected)
175
+ for (const id of newSelected) {
189
176
  if (!prev.has(id))
190
- return newSelected;
191
- return prev;
177
+ return newSelected; // found a difference, swap
178
+ }
179
+ return prev; // identical — return same reference, no re-render
192
180
  });
193
181
  }, [selectionBox, localTasks, localDocuments, canvasZoom, canvasViewport]);
194
- // ─── Global drag listeners registered ONCE on mount ──────────────────────
195
- // Critical: must NOT be in a useEffect with [localTasks] or any state deps.
196
- // Every setLocalTasks during drag would tear down + reattach mousemove → jump.
197
- // 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) ───────────────────────────────────────────────
198
256
  useEffect(() => {
257
+ if (!dragging)
258
+ return;
259
+ // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
199
260
  const handleMove = (e) => {
200
261
  if (!dragStateRef.current.isDragging)
201
262
  return;
202
263
  if (e.cancelable)
203
264
  e.preventDefault();
204
- const pointer = "touches" in e && e.touches.length > 0
205
- ? e.touches[0] : e;
265
+ const pointer = getPointerEvent(e);
266
+ // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
206
267
  if (rafIdRef.current !== null)
207
268
  cancelAnimationFrame(rafIdRef.current);
208
269
  rafIdRef.current = requestAnimationFrame(() => {
@@ -210,48 +271,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
210
271
  const canvas = fabricCanvas?.current;
211
272
  if (!canvas)
212
273
  return;
274
+ // 2. Read the "Source of Truth" transform from the canvas
213
275
  const vpt = canvas.viewportTransform;
214
- const liveZoom = vpt[0];
215
- const liveVpX = vpt[4];
216
- const liveVpY = vpt[5];
217
- const newWorldX = (pointer.clientX - liveVpX) / liveZoom - offsetX;
218
- const newWorldY = (pointer.clientY - liveVpY) / liveZoom - offsetY;
219
- 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);
220
290
  if (!firstStart)
221
291
  return;
222
292
  const deltaX = newWorldX - firstStart.x;
223
293
  const deltaY = newWorldY - firstStart.y;
224
- setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
225
- ? { ...t,
226
- x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
227
- y: (startPositions.get(t.id)?.y ?? t.y) + deltaY }
228
- : t));
229
- setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
230
- ? { ...d,
231
- x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
232
- y: (startPositions.get(d.id)?.y ?? d.y) + deltaY }
233
- : 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)
234
306
  canvasObjectsStartPos.forEach((startPos, obj) => {
235
- obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
236
- obj.setCoords();
307
+ obj.set({
308
+ left: startPos.left + deltaX,
309
+ top: startPos.top + deltaY,
310
+ });
311
+ obj.setCoords(); // Required for selection/intersection accuracy
237
312
  });
313
+ // 8. Single render call for all Fabric changes
238
314
  canvas.requestRenderAll();
239
315
  });
240
316
  };
241
317
  const handleEnd = () => {
242
- if (!dragStateRef.current.isDragging)
243
- return;
244
- if (rafIdRef.current !== null) {
318
+ if (rafIdRef.current !== null)
245
319
  cancelAnimationFrame(rafIdRef.current);
246
- rafIdRef.current = null;
247
- }
248
320
  dragStateRef.current.isDragging = false;
321
+ setDragging(null);
249
322
  document.body.style.cursor = "";
250
323
  document.body.style.userSelect = "";
251
324
  document.body.style.touchAction = "";
252
- // Flush via functional updater (reads latest state) + callback ref (never stale)
253
- setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
254
- setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
325
+ onTasksUpdate?.(localTasks);
326
+ onDocumentsUpdate?.(localDocuments);
255
327
  };
256
328
  window.addEventListener("mousemove", handleMove, { passive: false });
257
329
  window.addEventListener("mouseup", handleEnd);
@@ -265,62 +337,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
265
337
  window.removeEventListener("touchend", handleEnd);
266
338
  window.removeEventListener("touchcancel", handleEnd);
267
339
  };
268
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
269
- // ─── Drag start ────────────────────────────────────────────────────────────
270
- // useCallback: stable ref so MemoTaskNode/MemoDocumentNode don't re-render
271
- // on unrelated parent state changes.
272
- const handleDragStart = useCallback((itemId, e) => {
273
- const canvas = fabricCanvas?.current;
274
- if (!canvas)
275
- return;
276
- if (e.cancelable)
277
- e.preventDefault();
278
- const pointer = "touches" in e && e.touches.length > 0
279
- ? e.touches[0] : e;
280
- // Read from ref — never stale even if setSelectedIds was called this render
281
- const currentSelected = selectedIdsRef.current;
282
- const itemsToDrag = currentSelected.has(itemId)
283
- ? Array.from(currentSelected)
284
- : [itemId];
285
- if (!currentSelected.has(itemId)) {
286
- setSelectedIds(new Set([itemId]));
287
- }
288
- const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
289
- const liveZoom = vpt[0];
290
- const liveVpX = vpt[4];
291
- const liveVpY = vpt[5];
292
- // O(1) ref lookup — no stale state scan
293
- const clickedPos = nodePositionsRef.current.get(itemId);
294
- if (!clickedPos)
295
- return;
296
- const startPositions = new Map();
297
- for (const id of itemsToDrag) {
298
- const pos = nodePositionsRef.current.get(id);
299
- if (pos)
300
- startPositions.set(id, { x: pos.x, y: pos.y });
301
- }
302
- // Read Fabric objects from ref — not stale prop
303
- const canvasObjectsStartPos = new Map();
304
- for (const obj of selectedCanvasObjectsRef.current) {
305
- canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
306
- }
307
- // Write only to ref — zero re-render on drag start (eliminates listener churn)
308
- dragStateRef.current = {
309
- isDragging: true,
310
- itemIds: itemsToDrag,
311
- startPositions,
312
- canvasObjectsStartPos,
313
- offsetX: (pointer.clientX - liveVpX) / liveZoom - clickedPos.x,
314
- offsetY: (pointer.clientY - liveVpY) / liveZoom - clickedPos.y,
315
- fabricDragHtmlIds: new Set(),
316
- };
317
- document.body.style.cursor = "grabbing";
318
- document.body.style.userSelect = "none";
319
- document.body.style.touchAction = "none";
320
- }, [fabricCanvas]);
321
- // ─── Node interaction handlers ─────────────────────────────────────────────
322
- // useCallback: stable refs prevent unnecessary re-renders of memoized nodes
323
- const handleSelect = useCallback((id, e) => {
340
+ }, [dragging, localTasks, localDocuments, fabricCanvas]);
341
+ // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
342
+ const handleSelect = (id, e) => {
324
343
  if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
325
344
  setSelectedIds((prev) => {
326
345
  const next = new Set(prev);
@@ -331,65 +350,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
331
350
  else {
332
351
  setSelectedIds(new Set([id]));
333
352
  }
334
- }, []);
335
- const handleStatusChange = useCallback((taskId, newStatus) => {
336
- setLocalTasks((prev) => {
337
- const updated = prev.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
338
- onTasksUpdateRef.current?.(updated);
339
- return updated;
340
- });
341
- }, []);
342
- // ─── Keyboard shortcuts ────────────────────────────────────────────────────
343
- // selectedIds removed from deps — read via selectedIdsRef (prevents
344
- // 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
+ };
345
359
  useEffect(() => {
346
360
  const handleKeyDown = (e) => {
361
+ // Don't trigger if typing in input
347
362
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
348
363
  return;
364
+ // Select All
349
365
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
350
366
  e.preventDefault();
351
- setSelectedIds(new Set([
352
- ...localTasks.map((t) => t.id),
353
- ...localDocuments.map((d) => d.id),
354
- ]));
367
+ setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
355
368
  }
369
+ // Clear selection
356
370
  if (e.key === "Escape") {
357
371
  setSelectedIds(new Set());
358
372
  }
359
- 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) {
360
375
  e.preventDefault();
361
- const ids = selectedIdsRef.current;
362
- setLocalTasks((prev) => {
363
- const u = prev.filter((t) => !ids.has(t.id));
364
- onTasksUpdateRef.current?.(u);
365
- return u;
366
- });
367
- setLocalDocuments((prev) => {
368
- const u = prev.filter((d) => !ids.has(d.id));
369
- onDocumentsUpdateRef.current?.(u);
370
- return u;
371
- });
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);
372
380
  setSelectedIds(new Set());
381
+ onTasksUpdate?.(updatedTasks);
382
+ onDocumentsUpdate?.(updatedDocs);
373
383
  }
374
384
  };
375
385
  window.addEventListener("keydown", handleKeyDown);
376
386
  return () => window.removeEventListener("keydown", handleKeyDown);
377
- }, [localTasks, localDocuments]);
378
- // ─── Render ────────────────────────────────────────────────────────────────
379
- const renderItem = (id, x, y, children) => (_jsx("div", { className: "pointer-events-auto absolute", style: {
380
- left: 0,
381
- top: 0,
382
- transform: `translate3d(${x * canvasZoom}px, ${y * canvasZoom}px, 0) scale(${canvasZoom})`,
383
- transformOrigin: "top left",
384
- transition: "none",
385
- willChange: "transform",
386
- zIndex: dragStateRef.current.itemIds.includes(id) ? 1000 : 1,
387
- }, children: children }, id));
388
- 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) => {
389
411
  if (e.target === e.currentTarget)
390
412
  setSelectedIds(new Set());
391
413
  }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
392
414
  transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
393
415
  transformOrigin: "top left",
394
- }, 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 })))] }) }));
395
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.36.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",