@mhamz.01/easyflow-whiteboard 2.17.0 → 2.18.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,2CAsgBzB"}
@@ -1,93 +1,108 @@
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
- const selectedIdsRef = useRef(selectedIds);
36
- 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 ─────────────────────────────────────────────
22
+ // ── Sync props local state ────────────────────────────────────────────────
46
23
  useEffect(() => { setLocalTasks(tasks); }, [tasks]);
47
24
  useEffect(() => { setLocalDocuments(documents); }, [documents]);
48
- // ─── Wheel forwarding ─────────────────────────────────────────────────────
25
+ // ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
26
+ const handleOverlayWheel = (e) => {
27
+ if (e.ctrlKey || e.metaKey || e.shiftKey) {
28
+ const canvas = fabricCanvas?.current;
29
+ if (!canvas)
30
+ return;
31
+ const nativeEvent = e.nativeEvent;
32
+ // getScenePoint handles the transformation from screen to canvas space
33
+ const scenePoint = canvas.getScenePoint(nativeEvent);
34
+ // Viewport point is simply the mouse position relative to the canvas element
35
+ const rect = canvas.getElement().getBoundingClientRect();
36
+ const viewportPoint = {
37
+ x: nativeEvent.clientX - rect.left,
38
+ y: nativeEvent.clientY - rect.top,
39
+ };
40
+ // We cast to 'any' here because we are manually triggering an internal
41
+ // event bus, and Fabric's internal types for .fire() can be overly strict.
42
+ canvas.fire("mouse:wheel", {
43
+ e: nativeEvent,
44
+ scenePoint,
45
+ viewportPoint,
46
+ });
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ }
50
+ };
49
51
  useEffect(() => {
50
52
  const overlayEl = overlayRef.current;
51
53
  const canvas = fabricCanvas?.current;
52
54
  if (!overlayEl || !canvas)
53
55
  return;
54
56
  const handleGlobalWheel = (e) => {
55
- const isOverNode = e.target !== overlayEl;
57
+ // Check if the user is hovering over an element that has pointer-events: auto
58
+ // (meaning they are hovering over a Task or Document)
59
+ const target = e.target;
60
+ const isOverNode = target !== overlayEl;
56
61
  if ((e.ctrlKey || e.metaKey) && isOverNode) {
62
+ // 1. Prevent Browser Zoom immediately
57
63
  e.preventDefault();
58
64
  e.stopPropagation();
65
+ // 2. Calculate coordinates for Fabric
59
66
  const scenePoint = canvas.getScenePoint(e);
60
67
  const rect = canvas.getElement().getBoundingClientRect();
68
+ const viewportPoint = {
69
+ x: e.clientX - rect.left,
70
+ y: e.clientY - rect.top,
71
+ };
72
+ // 3. Manually fire the event into Fabric
61
73
  canvas.fire("mouse:wheel", {
62
- e, scenePoint,
63
- viewportPoint: { x: e.clientX - rect.left, y: e.clientY - rect.top },
74
+ e: e,
75
+ scenePoint,
76
+ viewportPoint,
64
77
  });
65
78
  }
66
79
  };
80
+ // CRITICAL: { passive: false } allows us to cancel the browser's zoom
67
81
  overlayEl.addEventListener("wheel", handleGlobalWheel, { passive: false });
68
- return () => overlayEl.removeEventListener("wheel", handleGlobalWheel);
69
- }, [fabricCanvas]);
70
- // ─── Fabric → Overlay sync ────────────────────────────────────────────────
82
+ return () => {
83
+ overlayEl.removeEventListener("wheel", handleGlobalWheel);
84
+ };
85
+ }, [fabricCanvas, canvasZoom]); // Re-bind when zoom changes to keep closure fresh
86
+ // ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
71
87
  useEffect(() => {
72
88
  const canvas = fabricCanvas?.current;
73
89
  if (!canvas)
74
90
  return;
75
91
  const handleObjectMoving = (e) => {
76
- // MUTEX: HTML drag and Fabric drag must never write positions simultaneously
77
- if (isHtmlDraggingRef.current)
78
- return;
79
92
  const target = e.transform?.target || e.target;
80
93
  if (!target)
81
94
  return;
95
+ // 1. Calculate delta in raw Scene Coordinates
96
+ // We do NOT divide by zoom here because target.left/top are world units.
82
97
  const deltaX = target.left - (target._prevLeft ?? target.left);
83
98
  const deltaY = target.top - (target._prevTop ?? target.top);
84
99
  target._prevLeft = target.left;
85
100
  target._prevTop = target.top;
86
101
  if (deltaX === 0 && deltaY === 0)
87
102
  return;
88
- const sel = selectedIdsRef.current;
89
- setLocalTasks((prev) => prev.map((t) => sel.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t));
90
- setLocalDocuments((prev) => prev.map((d) => sel.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d));
103
+ // 2. Apply the raw delta to HTML items
104
+ setLocalTasks((prev) => prev.map((t) => (selectedIds.has(t.id) ? { ...t, x: t.x + deltaX, y: t.y + deltaY } : t)));
105
+ setLocalDocuments((prev) => prev.map((d) => (selectedIds.has(d.id) ? { ...d, x: d.x + deltaX, y: d.y + deltaY } : d)));
91
106
  };
92
107
  const handleMouseDown = (e) => {
93
108
  const target = e.target;
@@ -102,68 +117,138 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
102
117
  canvas.off("object:moving", handleObjectMoving);
103
118
  canvas.off("mouse:down", handleMouseDown);
104
119
  };
105
- }, [fabricCanvas]);
106
- // ─── Selection box ────────────────────────────────────────────────────────
120
+ }, [canvasZoom, selectedIds, fabricCanvas]);
121
+ // ── Helpers ─────────────────────────────────────────────────────────────────
122
+ const getItemPosition = (id) => {
123
+ const task = localTasks.find((t) => t.id === id);
124
+ if (task)
125
+ return { x: task.x, y: task.y };
126
+ const doc = localDocuments.find((d) => d.id === id);
127
+ if (doc)
128
+ return { x: doc.x, y: doc.y };
129
+ return undefined;
130
+ };
131
+ const isItemInSelectionBox = (x, y, width, height, box) => {
132
+ const itemX1 = x * canvasZoom + canvasViewport.x;
133
+ const itemY1 = y * canvasZoom + canvasViewport.y;
134
+ const itemX2 = itemX1 + width * canvasZoom;
135
+ const itemY2 = itemY1 + height * canvasZoom;
136
+ const boxX1 = Math.min(box.x1, box.x2);
137
+ const boxY1 = Math.min(box.y1, box.y2);
138
+ const boxX2 = Math.max(box.x1, box.x2);
139
+ const boxY2 = Math.max(box.y1, box.y2);
140
+ return !(boxX2 < itemX1 || boxX1 > itemX2 || boxY2 < itemY1 || boxY1 > itemY2);
141
+ };
142
+ // ── Selection box detection ──────────────────────────────────────────────────
107
143
  useEffect(() => {
108
144
  if (!selectionBox)
109
145
  return;
146
+ // ── O(n) single pass — no sort, no join, no extra allocations ──
110
147
  const newSelected = new Set();
111
148
  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))
149
+ if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
121
150
  newSelected.add(task.id);
122
151
  }
123
152
  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))
153
+ if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
133
154
  newSelected.add(doc.id);
134
155
  }
135
- // O(n) equality same Set ref if unchanged, blocks unnecessary re-render
156
+ // ── O(n) equality check: size first (fast path), then membership ──
136
157
  setSelectedIds((prev) => {
137
158
  if (prev.size !== newSelected.size)
138
159
  return newSelected;
139
- for (const id of newSelected)
160
+ for (const id of newSelected) {
140
161
  if (!prev.has(id))
141
- return newSelected;
142
- return prev;
162
+ return newSelected; // found a difference, swap
163
+ }
164
+ return prev; // identical — return same reference, no re-render
165
+ });
166
+ }, [selectionBox, localTasks, localDocuments]);
167
+ // ── Drag start (HTML Node side) ──────────────────────────────────────────────
168
+ // Helper to extract coordinates regardless of event type
169
+ const getPointerEvent = (e) => {
170
+ if ('touches' in e && e.touches.length > 0)
171
+ return e.touches[0];
172
+ return e;
173
+ };
174
+ const handleDragStart = (itemId, e) => {
175
+ // 1. Safety check for the Fabric instance
176
+ const canvas = fabricCanvas?.current;
177
+ if (!canvas)
178
+ return;
179
+ // 2. Normalize the event (Touch vs Mouse)
180
+ if (e.cancelable)
181
+ e.preventDefault();
182
+ const pointer = getPointerEvent(e);
183
+ // 3. Determine which items are being dragged
184
+ // selection update DOES NOT trigger before drag snapshot
185
+ let itemsToDrag;
186
+ if (selectedIds.has(itemId)) {
187
+ itemsToDrag = Array.from(selectedIds);
188
+ }
189
+ else {
190
+ itemsToDrag = [itemId];
191
+ }
192
+ // 4. Capture current World Transform (Zoom & Pan)
193
+ // We read directly from the canvas to ensure zero-frame lag
194
+ const vpt = canvas.viewportTransform || [1, 0, 0, 1, 0, 0];
195
+ const liveZoom = vpt[0];
196
+ const liveVpX = vpt[4];
197
+ const liveVpY = vpt[5];
198
+ // 5. Convert the Click Position from Screen Pixels to World Units
199
+ const clickWorldX = (pointer.clientX - liveVpX) / liveZoom;
200
+ const clickWorldY = (pointer.clientY - liveVpY) / liveZoom;
201
+ // 6. Get the clicked item's current World Position
202
+ const clickedPos = getItemPosition(itemId);
203
+ if (!clickedPos)
204
+ return;
205
+ // 7. Calculate the Offset in WORLD UNITS
206
+ // This is the distance from the mouse to the node's top-left in the infinite grid.
207
+ // This value remains constant even if you zoom during the drag.
208
+ const worldOffsetX = clickWorldX - clickedPos.x;
209
+ const worldOffsetY = clickWorldY - clickedPos.y;
210
+ // 8. Snapshot starting positions for all selected HTML nodes
211
+ const startPositions = new Map();
212
+ itemsToDrag.forEach((id) => {
213
+ const pos = getItemPosition(id);
214
+ if (pos)
215
+ startPositions.set(id, pos);
216
+ });
217
+ // 9. Snapshot starting positions for all selected Fabric objects
218
+ const canvasObjectsStartPos = new Map();
219
+ selectedCanvasObjects.forEach((obj) => {
220
+ canvasObjectsStartPos.set(obj, { left: obj.left || 0, top: obj.top || 0 });
143
221
  });
144
- }, [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
- //
222
+ // 10. Commit to the ref for the requestAnimationFrame loop
223
+ dragStateRef.current = {
224
+ isDragging: true,
225
+ itemIds: itemsToDrag,
226
+ startPositions,
227
+ canvasObjectsStartPos,
228
+ offsetX: worldOffsetX, // Now stored as World Units
229
+ offsetY: worldOffsetY, // Now stored as World Units
230
+ };
231
+ if (!selectedIds.has(itemId)) {
232
+ setSelectedIds(new Set([itemId]));
233
+ }
234
+ // 11. Trigger UI states
235
+ setDragging({ itemIds: itemsToDrag });
236
+ document.body.style.cursor = "grabbing";
237
+ document.body.style.userSelect = "none";
238
+ document.body.style.touchAction = "none";
239
+ };
240
+ // ── Drag move (HTML Node side) ───────────────────────────────────────────────
159
241
  useEffect(() => {
242
+ if (!dragging)
243
+ return;
244
+ // Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
160
245
  const handleMove = (e) => {
161
246
  if (!dragStateRef.current.isDragging)
162
247
  return;
163
248
  if (e.cancelable)
164
249
  e.preventDefault();
165
- const pointer = "touches" in e && e.touches.length > 0
166
- ? e.touches[0] : e;
250
+ const pointer = getPointerEvent(e);
251
+ // 1. Throttle updates using requestAnimationFrame for 120Hz/144Hz screen support
167
252
  if (rafIdRef.current !== null)
168
253
  cancelAnimationFrame(rafIdRef.current);
169
254
  rafIdRef.current = requestAnimationFrame(() => {
@@ -171,50 +256,59 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
171
256
  const canvas = fabricCanvas?.current;
172
257
  if (!canvas)
173
258
  return;
174
- // Live VPT never from React state
259
+ // 2. Read the "Source of Truth" transform from the canvas
175
260
  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]);
261
+ const liveZoom = vpt[0]; // Scale
262
+ const liveVpX = vpt[4]; // Pan X
263
+ const liveVpY = vpt[5]; // Pan Y
264
+ // 3. Convert current Mouse Screen Position → World Position
265
+ const currentWorldX = (pointer.clientX - liveVpX) / liveZoom;
266
+ const currentWorldY = (pointer.clientY - liveVpY) / liveZoom;
267
+ // 4. Calculate where the "Anchor" node should be in World Units
268
+ // (Current Mouse World - Initial World Offset from Start)
269
+ const newWorldX = currentWorldX - offsetX;
270
+ const newWorldY = currentWorldY - offsetY;
271
+ // 5. Calculate the Movement Delta in World Units
272
+ // We compare where the first item started vs where it is now.
273
+ const firstId = itemIds[0];
274
+ const firstStart = startPositions.get(firstId);
183
275
  if (!firstStart)
184
276
  return;
185
277
  const deltaX = newWorldX - firstStart.x;
186
278
  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
279
+ // 6. Update HTML Nodes (Batching these into one state update)
280
+ setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id) ? {
281
+ ...t,
282
+ x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
283
+ y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
284
+ } : t));
285
+ setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id) ? {
286
+ ...d,
287
+ x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
288
+ y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
289
+ } : d));
290
+ // 7. Sync Fabric Objects (Imperative update for performance)
197
291
  canvasObjectsStartPos.forEach((startPos, obj) => {
198
- obj.set({ left: startPos.left + deltaX, top: startPos.top + deltaY });
199
- obj.setCoords();
292
+ obj.set({
293
+ left: startPos.left + deltaX,
294
+ top: startPos.top + deltaY,
295
+ });
296
+ obj.setCoords(); // Required for selection/intersection accuracy
200
297
  });
298
+ // 8. Single render call for all Fabric changes
201
299
  canvas.requestRenderAll();
202
300
  });
203
301
  };
204
302
  const handleEnd = () => {
205
- if (!dragStateRef.current.isDragging)
206
- return;
207
- if (rafIdRef.current !== null) {
303
+ if (rafIdRef.current !== null)
208
304
  cancelAnimationFrame(rafIdRef.current);
209
- rafIdRef.current = null;
210
- }
211
305
  dragStateRef.current.isDragging = false;
212
- isHtmlDraggingRef.current = false;
306
+ setDragging(null);
213
307
  document.body.style.cursor = "";
214
308
  document.body.style.userSelect = "";
215
309
  document.body.style.touchAction = "";
216
- setLocalTasks((prev) => { onTasksUpdateRef.current?.(prev); return prev; });
217
- setLocalDocuments((prev) => { onDocumentsUpdateRef.current?.(prev); return prev; });
310
+ onTasksUpdate?.(localTasks);
311
+ onDocumentsUpdate?.(localDocuments);
218
312
  };
219
313
  window.addEventListener("mousemove", handleMove, { passive: false });
220
314
  window.addEventListener("mouseup", handleEnd);
@@ -228,62 +322,9 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
228
322
  window.removeEventListener("touchend", handleEnd);
229
323
  window.removeEventListener("touchcancel", handleEnd);
230
324
  };
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) => {
325
+ }, [dragging, localTasks, localDocuments, fabricCanvas]);
326
+ // ── Selection, Status, Keyboard Logic ────────────────────────────────────────
327
+ const handleSelect = (id, e) => {
287
328
  if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
288
329
  setSelectedIds((prev) => {
289
330
  const next = new Set(prev);
@@ -294,48 +335,68 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
294
335
  else {
295
336
  setSelectedIds(new Set([id]));
296
337
  }
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 ───────────────────────────────────────────────────
338
+ };
339
+ const handleStatusChange = (taskId, newStatus) => {
340
+ const updated = localTasks.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t));
341
+ setLocalTasks(updated);
342
+ onTasksUpdate?.(updated);
343
+ };
306
344
  useEffect(() => {
307
345
  const handleKeyDown = (e) => {
346
+ // Don't trigger if typing in input
308
347
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
309
348
  return;
349
+ // Select All
310
350
  if ((e.ctrlKey || e.metaKey) && e.key === "a") {
311
351
  e.preventDefault();
312
352
  setSelectedIds(new Set([...localTasks.map((t) => t.id), ...localDocuments.map((d) => d.id)]));
313
353
  }
314
- if (e.key === "Escape")
354
+ // Clear selection
355
+ if (e.key === "Escape") {
315
356
  setSelectedIds(new Set());
316
- if ((e.key === "Delete" || e.key === "Backspace") && selectedIdsRef.current.size > 0) {
357
+ }
358
+ // ← ADD THIS: Delete selected nodes
359
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedIds.size > 0) {
317
360
  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; });
361
+ const updatedTasks = localTasks.filter((t) => !selectedIds.has(t.id));
362
+ const updatedDocs = localDocuments.filter((d) => !selectedIds.has(d.id));
363
+ setLocalTasks(updatedTasks);
364
+ setLocalDocuments(updatedDocs);
321
365
  setSelectedIds(new Set());
366
+ onTasksUpdate?.(updatedTasks);
367
+ onDocumentsUpdate?.(updatedDocs);
322
368
  }
323
369
  };
324
370
  window.addEventListener("keydown", handleKeyDown);
325
371
  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: {
372
+ }, [localTasks, localDocuments, selectedIds, onTasksUpdate, onDocumentsUpdate]);
373
+ // ── Render helper ────────────────────────────────────────────────────────────
374
+ const renderItem = (id, x, y, children) => {
375
+ const screenX = x * canvasZoom;
376
+ const screenY = y * canvasZoom;
377
+ // 1. Detect if the user is interacting with the canvas at all
378
+ // 'dragging' is your existing state.
379
+ // You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
380
+ const isDragging = dragging?.itemIds.includes(id);
381
+ return (_jsx("div", { className: "pointer-events-auto absolute", style: {
382
+ left: 0,
383
+ top: 0,
384
+ // 2. Use translate3d for GPU performance
385
+ transform: `translate3d(${screenX}px, ${screenY}px, 0) scale(${canvasZoom})`,
386
+ transformOrigin: "top left",
387
+ // 3. THE FIX: Remove transition entirely during any viewport change
388
+ // Any 'ease' during zoom causes the "shaking" behavior.
389
+ transition: "none",
390
+ // 4. Optimization
391
+ willChange: "transform",
392
+ zIndex: isDragging ? 1000 : 1,
393
+ }, children: children }, id));
394
+ };
395
+ return (_jsx("div", { ref: overlayRef, className: "absolute inset-0 pointer-events-none", style: { zIndex: 50 }, onWheel: handleOverlayWheel, onClick: (e) => {
396
+ if (e.target === e.currentTarget)
397
+ setSelectedIds(new Set());
398
+ }, children: _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: {
338
399
  transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
339
400
  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 })))] }) }));
401
+ }, 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
402
  }
@@ -1 +1 @@
1
- {"version":3,"file":"usePan.d.ts","sourceRoot":"","sources":["../../src/hooks/usePan.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,SAAS,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,UAAU,WAAW;IACnB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACrE,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,MAAM,GAAI,8DAKpB,WAAW,SAkGb,CAAC"}
1
+ {"version":3,"file":"usePan.d.ts","sourceRoot":"","sources":["../../src/hooks/usePan.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,SAAS,EAAE,MAAM,OAAO,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,UAAU,WAAW;IACnB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACrE,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,MAAM,GAAI,8DAKpB,WAAW,SAoIb,CAAC"}
@@ -1,5 +1,15 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useRef } from "react";
2
2
  export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport, }) => {
3
+ // ── PERF FIX 1: All mutable values in refs — effect registers ONCE ────────
4
+ // Old deps: [activeTool, handleZoom, setCanvasViewport, fabricCanvas]
5
+ // handleZoom and setCanvasViewport are new references every render →
6
+ // all 3 listeners re-registered on every render.
7
+ const activeToolRef = useRef(activeTool);
8
+ const handleZoomRef = useRef(handleZoom);
9
+ const setViewportRef = useRef(setCanvasViewport);
10
+ activeToolRef.current = activeTool;
11
+ handleZoomRef.current = handleZoom;
12
+ setViewportRef.current = setCanvasViewport;
3
13
  useEffect(() => {
4
14
  const canvas = fabricCanvas.current;
5
15
  if (!canvas)
@@ -10,17 +20,18 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
10
20
  let lastX = 0;
11
21
  let lastY = 0;
12
22
  let lastTouchDistance = 0;
23
+ let rafId = null;
13
24
  const onDown = (opt) => {
14
- if (activeTool !== "pan")
25
+ if (activeToolRef.current !== "pan")
15
26
  return;
16
27
  const e = opt.e;
17
- // Pinch initialization
18
- if (e.touches && e.touches.length === 2) {
28
+ // Pinch zoom init
29
+ if (e.touches?.length === 2) {
19
30
  isPanning = false;
20
31
  lastTouchDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
21
32
  return;
22
33
  }
23
- // Pan initialization
34
+ // Pan init
24
35
  const pointer = e.touches ? e.touches[0] : e;
25
36
  isPanning = true;
26
37
  lastX = pointer.clientX;
@@ -28,45 +39,63 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
28
39
  canvas.setCursor("grabbing");
29
40
  };
30
41
  const onMove = (opt) => {
31
- if (activeTool !== "pan")
42
+ if (activeToolRef.current !== "pan")
32
43
  return;
33
44
  const e = opt.e;
34
- // Handle pinch zoom (two fingers)
35
- if (e.touches && e.touches.length === 2) {
45
+ // ── PERF FIX 2: rAF throttle — pan fires at raw mouse rate (200+/sec)
46
+ // Without throttling, every mousemove directly mutates VPT and calls
47
+ // requestRenderAll, saturating the main thread.
48
+ if (rafId !== null)
49
+ return; // skip if a frame is already queued
50
+ // Pinch zoom
51
+ if (e.touches?.length === 2) {
36
52
  const currentDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
37
53
  if (lastTouchDistance > 0) {
38
- const zoom = canvas.getZoom();
39
54
  const delta = (currentDistance - lastTouchDistance) * 0.01;
40
- const newZoom = zoom + delta;
41
55
  const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
42
56
  const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
43
57
  const rect = canvasEl.getBoundingClientRect();
44
- handleZoom(newZoom, { x: midX - rect.left, y: midY - rect.top });
58
+ handleZoomRef.current(canvas.getZoom() + delta, { x: midX - rect.left, y: midY - rect.top });
45
59
  }
46
60
  lastTouchDistance = currentDistance;
47
61
  return;
48
62
  }
49
- // Handle panning (one finger or mouse)
50
- if (isPanning) {
51
- const pointer = e.touches ? e.touches[0] : e;
63
+ if (!isPanning)
64
+ return;
65
+ const pointer = e.touches ? e.touches[0] : e;
66
+ const dx = pointer.clientX - lastX;
67
+ const dy = pointer.clientY - lastY;
68
+ lastX = pointer.clientX;
69
+ lastY = pointer.clientY;
70
+ // Schedule the actual VPT mutation on next animation frame
71
+ rafId = requestAnimationFrame(() => {
72
+ rafId = null;
52
73
  const vpt = canvas.viewportTransform;
53
- if (vpt) {
54
- vpt[4] += pointer.clientX - lastX;
55
- vpt[5] += pointer.clientY - lastY;
56
- canvas.requestRenderAll();
57
- }
58
- lastX = pointer.clientX;
59
- lastY = pointer.clientY;
60
- }
74
+ if (!vpt)
75
+ return;
76
+ vpt[4] += dx;
77
+ vpt[5] += dy;
78
+ canvas.requestRenderAll();
79
+ // ── PERF FIX 3: Do NOT call setCanvasViewport here ────────────────
80
+ // Calling setCanvasViewport per frame triggers React re-renders at
81
+ // 60fps, cascading to CanvasOverlayLayer re-rendering all HTML nodes.
82
+ // Viewport state is only flushed ONCE on mouse:up (see onUp below).
83
+ });
61
84
  };
62
85
  const onUp = () => {
63
- const vpt = canvas.viewportTransform;
64
- if (vpt) {
65
- setCanvasViewport({ x: vpt[4], y: vpt[5] });
86
+ if (rafId !== null) {
87
+ cancelAnimationFrame(rafId);
88
+ rafId = null;
66
89
  }
67
90
  isPanning = false;
68
91
  lastTouchDistance = 0;
69
- canvas.setCursor(activeTool === "pan" ? "grab" : "default");
92
+ canvas.setCursor(activeToolRef.current === "pan" ? "grab" : "default");
93
+ // ── Flush React state ONCE per gesture end ────────────────────────────
94
+ // HTML overlay nodes only need to reposition when the pan is complete.
95
+ // One setCanvasViewport call here vs hundreds during the gesture.
96
+ const vpt = canvas.viewportTransform;
97
+ if (vpt)
98
+ setViewportRef.current({ x: vpt[4], y: vpt[5] });
70
99
  };
71
100
  canvas.on("mouse:down", onDown);
72
101
  canvas.on("mouse:move", onMove);
@@ -75,6 +104,9 @@ export const usePan = ({ fabricCanvas, activeTool, handleZoom, setCanvasViewport
75
104
  canvas.off("mouse:down", onDown);
76
105
  canvas.off("mouse:move", onMove);
77
106
  canvas.off("mouse:up", onUp);
107
+ if (rafId !== null)
108
+ cancelAnimationFrame(rafId);
78
109
  };
79
- }, [activeTool, handleZoom, setCanvasViewport, fabricCanvas]);
110
+ // ── PERF FIX 4: Empty deps — registered once, reads everything via refs ───
111
+ }, [fabricCanvas]);
80
112
  };
@@ -15,7 +15,7 @@ interface UseZoomProps {
15
15
  y: number;
16
16
  }) => void;
17
17
  }
18
- export declare const useZoom: ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasViewport, setCanvasZoom, setCanvasViewport, }: UseZoomProps) => {
18
+ export declare const useZoom: ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, setCanvasZoom, setCanvasViewport, }: UseZoomProps) => {
19
19
  handleZoom: (newZoom: number, point?: {
20
20
  x: number;
21
21
  y: number;
@@ -1 +1 @@
1
- {"version":3,"file":"useZoom.d.ts","sourceRoot":"","sources":["../../src/hooks/useZoom.ts"],"names":[],"mappings":"AACA,OAAa,EAA0B,SAAS,EAAE,MAAM,OAAO,CAAC;AAChE,OAAO,EAAE,MAAM,EAAuB,MAAM,QAAQ,CAAC;AAErD,UAAU,YAAY;IACpB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,OAAO,GAAI,qGAQrB,YAAY;0BAED,MAAM,UAAU;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE;CAoJrD,CAAC"}
1
+ {"version":3,"file":"useZoom.d.ts","sourceRoot":"","sources":["../../src/hooks/useZoom.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,SAAS,EAAE,MAAM,OAAO,CAAC;AAClE,OAAO,EAAE,MAAM,EAAuB,MAAM,QAAQ,CAAC;AAErD,UAAU,YAAY;IACpB,YAAY,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,iBAAiB,EAAE,CAAC,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACjE;AAED,eAAO,MAAM,OAAO,GAAI,yEAMrB,YAAY;0BAiB4B,MAAM,UAAU;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE;CA2IlF,CAAC"}
@@ -1,27 +1,57 @@
1
- import { useCallback, useEffect } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
  import { Point } from "fabric";
3
- export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasViewport, setCanvasZoom, setCanvasViewport, }) => {
3
+ export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, setCanvasZoom, setCanvasViewport, }) => {
4
+ // ── PERF FIX 1: Stable refs for setters ──────────────────────────────────
5
+ // setCanvasZoom and setCanvasViewport are new references every render from
6
+ // FabricWhiteboard's useState. Putting them in deps caused handleZoom and
7
+ // all effects to re-register on every render. Refs break the cycle.
8
+ const setCanvasZoomRef = useRef(setCanvasZoom);
9
+ const setCanvasViewportRef = useRef(setCanvasViewport);
10
+ setCanvasZoomRef.current = setCanvasZoom;
11
+ setCanvasViewportRef.current = setCanvasViewport;
12
+ // ── PERF FIX 2: handleZoom reads limits from refs, stable reference ───────
13
+ const minZoomRef = useRef(MIN_ZOOM);
14
+ const maxZoomRef = useRef(MAX_ZOOM);
15
+ minZoomRef.current = MIN_ZOOM;
16
+ maxZoomRef.current = MAX_ZOOM;
4
17
  const handleZoom = useCallback((newZoom, point) => {
5
18
  const canvas = fabricCanvas.current;
6
19
  if (!canvas)
7
20
  return;
8
- const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);
21
+ const clamped = Math.min(Math.max(newZoom, minZoomRef.current), maxZoomRef.current);
9
22
  const pivot = point
10
23
  ? new Point(point.x, point.y)
11
24
  : new Point(canvas.getWidth() / 2, canvas.getHeight() / 2);
12
- canvas.zoomToPoint(pivot, clampedZoom);
25
+ canvas.zoomToPoint(pivot, clamped);
26
+ canvas.requestRenderAll();
27
+ // Flush React state once per zoom action — not per frame
13
28
  const vpt = canvas.viewportTransform;
14
29
  if (vpt) {
15
- setCanvasZoom(clampedZoom);
16
- setCanvasViewport({ x: vpt[4], y: vpt[5] });
30
+ setCanvasZoomRef.current(clamped);
31
+ setCanvasViewportRef.current({ x: vpt[4], y: vpt[5] });
17
32
  }
18
- canvas.requestRenderAll();
19
- }, [MIN_ZOOM, MAX_ZOOM, setCanvasZoom, setCanvasViewport, fabricCanvas]);
20
- // Wheel handler
33
+ // fabricCanvas ref is stable — this function never needs to be recreated
34
+ }, [fabricCanvas]);
35
+ // ── Wheel zoom + pan ───────────────────────────────────────────────────────
21
36
  useEffect(() => {
22
37
  const canvas = fabricCanvas.current;
23
38
  if (!canvas)
24
39
  return;
40
+ // ── PERF FIX 3: rAF-throttled viewport flush ─────────────────────────
41
+ // setCanvasViewport on every wheel tick caused a React re-render per scroll
42
+ // event (60-200/sec). Batch with rAF — flush at most once per paint frame.
43
+ let rafId = null;
44
+ const flushViewport = () => {
45
+ rafId = null;
46
+ const vpt = canvas.viewportTransform;
47
+ if (vpt)
48
+ setCanvasViewportRef.current({ x: vpt[4], y: vpt[5] });
49
+ };
50
+ const scheduleFlush = () => {
51
+ if (rafId !== null)
52
+ cancelAnimationFrame(rafId);
53
+ rafId = requestAnimationFrame(flushViewport);
54
+ };
25
55
  const onWheel = (opt) => {
26
56
  const e = opt.e;
27
57
  e.preventDefault();
@@ -30,36 +60,41 @@ export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasVi
30
60
  if (!vpt)
31
61
  return;
32
62
  if (e.ctrlKey || e.metaKey) {
33
- // Zoom logic
63
+ // Zoom to cursor
34
64
  const rect = canvas.getElement().getBoundingClientRect();
35
- handleZoom(e.deltaY < 0 ? canvasZoom * 1.1 : canvasZoom / 1.1, { x: e.clientX - rect.left, y: e.clientY - rect.top });
65
+ const currentZoom = canvas.getZoom();
66
+ handleZoom(e.deltaY < 0 ? currentZoom * 1.1 : currentZoom / 1.1, { x: e.clientX - rect.left, y: e.clientY - rect.top });
36
67
  }
37
68
  else {
38
- // Pan via wheel
39
- const delta = e.deltaY;
40
- const shiftDelta = e.deltaX || e.deltaY;
41
- vpt[5] -= delta;
69
+ // Pan via scroll — mutate VPT directly, no React state until rAF
70
+ vpt[5] -= e.deltaY;
42
71
  if (e.shiftKey)
43
- vpt[4] -= shiftDelta;
44
- setCanvasViewport({ x: vpt[4], y: vpt[5] });
45
- // Sync selection coordinates
72
+ vpt[4] -= (e.deltaX || e.deltaY);
73
+ // Keep active object coords in sync
46
74
  const activeObj = canvas.getActiveObject();
47
75
  if (activeObj) {
48
76
  activeObj.setCoords();
49
77
  if (activeObj.type === "activeSelection") {
50
- const objects = activeObj.getObjects();
51
- objects.forEach((obj) => obj.setCoords());
52
- activeObj.setCoords();
78
+ activeObj.getObjects().forEach((o) => o.setCoords());
53
79
  }
54
80
  }
55
81
  canvas.calcOffset();
56
82
  canvas.requestRenderAll();
83
+ // Batch React state flush — at most once per animation frame
84
+ scheduleFlush();
57
85
  }
58
86
  };
59
87
  canvas.on("mouse:wheel", onWheel);
60
- return () => canvas.off("mouse:wheel", onWheel);
61
- }, [canvasZoom, canvasViewport, handleZoom, setCanvasViewport, fabricCanvas]);
62
- // Prevent browser zoom
88
+ return () => {
89
+ canvas.off("mouse:wheel", onWheel);
90
+ if (rafId !== null)
91
+ cancelAnimationFrame(rafId);
92
+ };
93
+ // ── PERF FIX 4: Empty deps — reads everything via refs or canvas directly ──
94
+ // Old code had [canvasZoom, canvasViewport, handleZoom] here which caused
95
+ // the listener to re-register on every single zoom/pan action.
96
+ }, [fabricCanvas, handleZoom]);
97
+ // ── Prevent browser zoom ──────────────────────────────────────────────────
63
98
  useEffect(() => {
64
99
  const prevent = (e) => {
65
100
  if (e.ctrlKey || e.metaKey)
@@ -68,66 +103,50 @@ export const useZoom = ({ fabricCanvas, MIN_ZOOM, MAX_ZOOM, canvasZoom, canvasVi
68
103
  window.addEventListener("wheel", prevent, { passive: false });
69
104
  return () => window.removeEventListener("wheel", prevent);
70
105
  }, []);
71
- // Keyboard shortcuts
106
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────
72
107
  useEffect(() => {
73
108
  const onKey = (e) => {
74
- if (e.target instanceof HTMLInputElement ||
75
- e.target instanceof HTMLTextAreaElement)
109
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
76
110
  return;
77
111
  if (!(e.ctrlKey || e.metaKey))
78
112
  return;
113
+ const canvas = fabricCanvas.current;
114
+ if (!canvas)
115
+ return;
79
116
  if (e.key === "=" || e.key === "+") {
80
117
  e.preventDefault();
81
- handleZoom(canvasZoom + 0.1);
118
+ // ── PERF FIX 5: Read zoom from canvas directly, not from stale state ─
119
+ handleZoom(canvas.getZoom() + 0.1);
82
120
  }
83
121
  else if (e.key === "-" || e.key === "_") {
84
122
  e.preventDefault();
85
- handleZoom(canvasZoom - 0.1);
123
+ handleZoom(canvas.getZoom() - 0.1);
86
124
  }
87
125
  else if (e.key === "0") {
88
126
  e.preventDefault();
89
- const canvas = fabricCanvas.current;
90
- if (!canvas)
91
- return;
92
127
  canvas.setZoom(1);
93
128
  const vpt = canvas.viewportTransform;
94
129
  if (vpt) {
95
130
  vpt[4] = 0;
96
131
  vpt[5] = 0;
97
- setCanvasViewport({ x: 0, y: 0 });
98
132
  }
99
- setCanvasZoom(1);
100
- canvas.renderAll();
133
+ canvas.requestRenderAll();
134
+ setCanvasZoomRef.current(1);
135
+ setCanvasViewportRef.current({ x: 0, y: 0 });
101
136
  }
102
137
  };
103
138
  window.addEventListener("keydown", onKey);
104
139
  return () => window.removeEventListener("keydown", onKey);
105
- }, [canvasZoom, handleZoom, setCanvasZoom, setCanvasViewport, fabricCanvas]);
106
- // Sync viewport state
107
- useEffect(() => {
108
- const canvas = fabricCanvas.current;
109
- if (!canvas)
110
- return;
111
- const sync = () => {
112
- const vpt = canvas.viewportTransform;
113
- if (!vpt)
114
- return;
115
- const z = canvas.getZoom();
116
- if (canvasViewport.x !== vpt[4] || canvasViewport.y !== vpt[5]) {
117
- setCanvasViewport({ x: vpt[4], y: vpt[5] });
118
- }
119
- if (canvasZoom !== z) {
120
- setCanvasZoom(z);
121
- }
122
- };
123
- canvas.on("after:render", sync);
124
- canvas.on("object:moving", sync);
125
- canvas.on("mouse:up", sync);
126
- return () => {
127
- canvas.off("after:render", sync);
128
- canvas.off("object:moving", sync);
129
- canvas.off("mouse:up", sync);
130
- };
131
- }, [fabricCanvas, setCanvasViewport, setCanvasZoom]);
140
+ // ── Stable reads zoom from canvas directly, not from canvasZoom state ───
141
+ }, [fabricCanvas, handleZoom]);
142
+ // ── PERF FIX 6: after:render sync REMOVED ────────────────────────────────
143
+ // The old code registered a "after:render" listener that called
144
+ // setCanvasViewport + setCanvasZoom. Since after:render fires after EVERY
145
+ // Fabric paint frame, this created an infinite loop:
146
+ // Fabric renders setCanvasZoom React re-renders → Fabric re-renders → ...
147
+ // React state is now only updated at deliberate points:
148
+ // - handleZoom (once per zoom gesture)
149
+ // - scheduleFlush via rAF (once per animation frame during scroll pan)
150
+ // - usePan's onUp (once per pan gesture end)
132
151
  return { handleZoom };
133
152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhamz.01/easyflow-whiteboard",
3
- "version": "2.17.0",
3
+ "version": "2.18.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",