@mhamz.01/easyflow-whiteboard 2.123.0 → 2.125.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.
- package/dist/components/node/custom-node-overlay-layer.d.ts +3 -2
- package/dist/components/node/custom-node-overlay-layer.d.ts.map +1 -1
- package/dist/components/node/custom-node-overlay-layer.js +102 -78
- package/dist/components/toolbar/tooloptions-panel.d.ts +3 -3
- package/dist/components/toolbar/tooloptions-panel.d.ts.map +1 -1
- package/dist/components/toolbar/tooloptions-panel.js +45 -11
- package/dist/components/toolbar/whiteboard-toolbar.d.ts +3 -3
- package/dist/components/toolbar/whiteboard-toolbar.d.ts.map +1 -1
- package/dist/components/toolbar/whiteboard-toolbar.js +88 -48
- package/dist/components/whiteboard/whiteboard-test.d.ts.map +1 -1
- package/dist/components/whiteboard/whiteboard-test.js +104 -27
- package/dist/styles.css +18 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from "react";
|
|
1
2
|
import { FabricObject, Canvas } from "fabric";
|
|
2
3
|
export interface Task {
|
|
3
4
|
id: string;
|
|
@@ -40,6 +41,6 @@ interface CanvasOverlayLayerProps {
|
|
|
40
41
|
fabricCanvas?: React.RefObject<Canvas | null>;
|
|
41
42
|
canvasReady?: boolean;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
-
export
|
|
44
|
+
declare const _default: React.NamedExoticComponent<CanvasOverlayLayerProps>;
|
|
45
|
+
export default _default;
|
|
45
46
|
//# sourceMappingURL=custom-node-overlay-layer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"custom-node-overlay-layer.d.ts","sourceRoot":"","sources":["../../../src/components/node/custom-node-overlay-layer.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAI9C,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC;IACxC,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,UAAU,uBAAuB;IAC/B,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACxC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACpD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,YAAY,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACzE,qBAAqB,CAAC,EAAE,YAAY,EAAE,CAAC;IACvC,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;;AAaD,wBA6pBG"}
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
4
4
|
import TaskNode from "./custom-node";
|
|
5
5
|
import DocumentNode from "./document-node";
|
|
6
6
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
7
|
-
export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, }) {
|
|
7
|
+
export default React.memo(function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, onDocumentsUpdate, canvasZoom = 1, canvasViewport = { x: 0, y: 0 }, selectionBox = null, selectedCanvasObjects = [], fabricCanvas, canvasReady: canvasReadyProp = false, }) {
|
|
8
8
|
const [localTasks, setLocalTasks] = useState(tasks);
|
|
9
9
|
const [localDocuments, setLocalDocuments] = useState(documents);
|
|
10
10
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
|
11
11
|
const [dragging, setDragging] = useState(null);
|
|
12
|
-
const [canvasReady, setCanvasReady] = useState(false);
|
|
13
12
|
const nodeClipboardRef = useRef({
|
|
14
13
|
tasks: [],
|
|
15
14
|
documents: [],
|
|
16
15
|
});
|
|
16
|
+
// ── Refs for always-current state (Issue 4) ────────────────────────────────
|
|
17
|
+
const localTasksRef = useRef(localTasks);
|
|
18
|
+
const localDocumentsRef = useRef(localDocuments);
|
|
19
|
+
const selectedIdsRef = useRef(selectedIds);
|
|
20
|
+
localTasksRef.current = localTasks;
|
|
21
|
+
localDocumentsRef.current = localDocuments;
|
|
22
|
+
selectedIdsRef.current = selectedIds;
|
|
17
23
|
const dragStateRef = useRef({
|
|
18
24
|
isDragging: false,
|
|
19
25
|
itemIds: [],
|
|
@@ -23,29 +29,18 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
23
29
|
offsetY: 0,
|
|
24
30
|
});
|
|
25
31
|
const rafIdRef = useRef(null);
|
|
32
|
+
const fabricMoveRafRef = useRef(null); // Issue 7
|
|
26
33
|
const overlayRef = useRef(null);
|
|
27
|
-
const selectedIdsRef = useRef(selectedIds);
|
|
28
|
-
selectedIdsRef.current = selectedIds;
|
|
29
34
|
// ── Sync props → local state ────────────────────────────────────────────────
|
|
30
|
-
useEffect(() => { setLocalTasks(tasks); }, [tasks]);
|
|
31
|
-
useEffect(() => { setLocalDocuments(documents); }, [documents]);
|
|
32
|
-
// effect — polls until fabricCanvas.current is available:
|
|
33
35
|
useEffect(() => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (fabricCanvas?.current) {
|
|
43
|
-
setCanvasReady(true);
|
|
44
|
-
clearInterval(interval);
|
|
45
|
-
}
|
|
46
|
-
}, 50);
|
|
47
|
-
return () => clearInterval(interval);
|
|
48
|
-
}, [fabricCanvas, canvasReady]);
|
|
36
|
+
setLocalTasks(tasks);
|
|
37
|
+
}, [tasks]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setLocalDocuments(documents);
|
|
40
|
+
}, [documents]);
|
|
41
|
+
// Issue 2: Remove internal canvasReady state and polling — use prop directly
|
|
42
|
+
// No longer needed: const [canvasReady, setCanvasReady] = useState(false);
|
|
43
|
+
// No longer needed: polling useEffect with setInterval
|
|
49
44
|
// ── Event Forwarding (Fixes Zooming on Nodes) ───────────────────────────────
|
|
50
45
|
const handleOverlayWheel = (e) => {
|
|
51
46
|
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
@@ -61,7 +56,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
61
56
|
x: nativeEvent.clientX - rect.left,
|
|
62
57
|
y: nativeEvent.clientY - rect.top,
|
|
63
58
|
};
|
|
64
|
-
// We cast to 'any' here because we are manually triggering an internal
|
|
59
|
+
// We cast to 'any' here because we are manually triggering an internal
|
|
65
60
|
// event bus, and Fabric's internal types for .fire() can be overly strict.
|
|
66
61
|
canvas.fire("mouse:wheel", {
|
|
67
62
|
e: nativeEvent,
|
|
@@ -72,6 +67,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
72
67
|
e.stopPropagation();
|
|
73
68
|
}
|
|
74
69
|
};
|
|
70
|
+
// Issue 6: Remove canvasZoom from deps — not used in handler
|
|
75
71
|
useEffect(() => {
|
|
76
72
|
const overlayEl = overlayRef.current;
|
|
77
73
|
const canvas = fabricCanvas?.current;
|
|
@@ -106,12 +102,14 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
106
102
|
return () => {
|
|
107
103
|
overlayEl.removeEventListener("wheel", handleGlobalWheel);
|
|
108
104
|
};
|
|
109
|
-
}, [fabricCanvas,
|
|
105
|
+
}, [fabricCanvas, canvasReadyProp]); // Issue 6: removed canvasZoom
|
|
110
106
|
// ── Fabric → Overlay Sync (Fixes Dragging from Fabric area) ──────────────────
|
|
107
|
+
// Issue 5: Remove canvasZoom from deps — not used in handlers
|
|
111
108
|
useEffect(() => {
|
|
112
109
|
const canvas = fabricCanvas?.current;
|
|
113
110
|
if (!canvas)
|
|
114
111
|
return;
|
|
112
|
+
// Issue 7: Throttle handleObjectMoving with rAF gate
|
|
115
113
|
const handleObjectMoving = (e) => {
|
|
116
114
|
const target = e.transform?.target || e.target;
|
|
117
115
|
if (!target)
|
|
@@ -122,10 +120,19 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
122
120
|
target._prevTop = target.top;
|
|
123
121
|
if (deltaX === 0 && deltaY === 0)
|
|
124
122
|
return;
|
|
125
|
-
// ── Read from ref — always fresh, never stale ──
|
|
126
123
|
const sel = selectedIdsRef.current;
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
// Skip if frame already queued
|
|
125
|
+
if (fabricMoveRafRef.current !== null)
|
|
126
|
+
return;
|
|
127
|
+
fabricMoveRafRef.current = requestAnimationFrame(() => {
|
|
128
|
+
fabricMoveRafRef.current = null;
|
|
129
|
+
setLocalTasks((prev) => prev.map((t) => sel.has(t.id)
|
|
130
|
+
? { ...t, x: t.x + deltaX, y: t.y + deltaY }
|
|
131
|
+
: t));
|
|
132
|
+
setLocalDocuments((prev) => prev.map((d) => sel.has(d.id)
|
|
133
|
+
? { ...d, x: d.x + deltaX, y: d.y + deltaY }
|
|
134
|
+
: d));
|
|
135
|
+
});
|
|
129
136
|
};
|
|
130
137
|
const handleMouseDown = (e) => {
|
|
131
138
|
const target = e.target;
|
|
@@ -160,18 +167,17 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
160
167
|
canvas.off("object:moving", handleObjectMoving);
|
|
161
168
|
canvas.off("mouse:down", handleMouseDown);
|
|
162
169
|
canvas.off("selection:cleared", handleSelectionCleared);
|
|
170
|
+
if (fabricMoveRafRef.current !== null) {
|
|
171
|
+
cancelAnimationFrame(fabricMoveRafRef.current);
|
|
172
|
+
}
|
|
163
173
|
};
|
|
164
|
-
|
|
165
|
-
// Having selectedIds here caused the effect to re-register on every selection
|
|
166
|
-
// change, creating a new closure each time. The second drag captured a stale
|
|
167
|
-
// or empty selectedIds from the closure at re-registration time.
|
|
168
|
-
}, [canvasZoom, fabricCanvas, canvasReady]);
|
|
174
|
+
}, [fabricCanvas, canvasReadyProp]); // Issue 5: removed canvasZoom
|
|
169
175
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
170
176
|
const getItemPosition = (id) => {
|
|
171
|
-
const task =
|
|
177
|
+
const task = localTasksRef.current.find((t) => t.id === id);
|
|
172
178
|
if (task)
|
|
173
179
|
return { x: task.x, y: task.y };
|
|
174
|
-
const doc =
|
|
180
|
+
const doc = localDocumentsRef.current.find((d) => d.id === id);
|
|
175
181
|
if (doc)
|
|
176
182
|
return { x: doc.x, y: doc.y };
|
|
177
183
|
return undefined;
|
|
@@ -193,11 +199,11 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
193
199
|
return;
|
|
194
200
|
// ── O(n) single pass — no sort, no join, no extra allocations ──
|
|
195
201
|
const newSelected = new Set();
|
|
196
|
-
for (const task of
|
|
202
|
+
for (const task of localTasksRef.current) {
|
|
197
203
|
if (isItemInSelectionBox(task.x, task.y, 300, 140, selectionBox))
|
|
198
204
|
newSelected.add(task.id);
|
|
199
205
|
}
|
|
200
|
-
for (const doc of
|
|
206
|
+
for (const doc of localDocumentsRef.current) {
|
|
201
207
|
if (isItemInSelectionBox(doc.x, doc.y, 320, 160, selectionBox))
|
|
202
208
|
newSelected.add(doc.id);
|
|
203
209
|
}
|
|
@@ -215,7 +221,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
215
221
|
// ── Drag start (HTML Node side) ──────────────────────────────────────────────
|
|
216
222
|
// Helper to extract coordinates regardless of event type
|
|
217
223
|
const getPointerEvent = (e) => {
|
|
218
|
-
if (
|
|
224
|
+
if ("touches" in e && e.touches.length > 0)
|
|
219
225
|
return e.touches[0];
|
|
220
226
|
return e;
|
|
221
227
|
};
|
|
@@ -231,8 +237,8 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
231
237
|
// 3. Determine which items are being dragged
|
|
232
238
|
// selection update DOES NOT trigger before drag snapshot
|
|
233
239
|
let itemsToDrag;
|
|
234
|
-
if (
|
|
235
|
-
itemsToDrag = Array.from(
|
|
240
|
+
if (selectedIdsRef.current.has(itemId)) {
|
|
241
|
+
itemsToDrag = Array.from(selectedIdsRef.current);
|
|
236
242
|
}
|
|
237
243
|
else {
|
|
238
244
|
itemsToDrag = [itemId];
|
|
@@ -265,7 +271,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
265
271
|
// 9. Snapshot starting positions for all selected Fabric objects
|
|
266
272
|
const canvasObjectsStartPos = new Map();
|
|
267
273
|
selectedCanvasObjects.forEach((obj) => {
|
|
268
|
-
canvasObjectsStartPos.set(obj, {
|
|
274
|
+
canvasObjectsStartPos.set(obj, {
|
|
275
|
+
left: obj.left || 0,
|
|
276
|
+
top: obj.top || 0,
|
|
277
|
+
});
|
|
269
278
|
});
|
|
270
279
|
// 10. Commit to the ref for the requestAnimationFrame loop
|
|
271
280
|
dragStateRef.current = {
|
|
@@ -276,7 +285,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
276
285
|
offsetX: clickWorldX, // Now stored as World Units
|
|
277
286
|
offsetY: clickWorldY, // Now stored as World Units
|
|
278
287
|
};
|
|
279
|
-
if (!
|
|
288
|
+
if (!selectedIdsRef.current.has(itemId) && dragStateRef.current.itemIds.length === 0) {
|
|
280
289
|
setSelectedIds(new Set([itemId]));
|
|
281
290
|
}
|
|
282
291
|
// 11. Trigger UI states
|
|
@@ -286,10 +295,10 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
286
295
|
document.body.style.touchAction = "none";
|
|
287
296
|
};
|
|
288
297
|
// ── Drag move (HTML Node side) ───────────────────────────────────────────────
|
|
298
|
+
// Issue 3: Remove localTasks/localDocuments from deps — use refs instead
|
|
289
299
|
useEffect(() => {
|
|
290
300
|
if (!dragging)
|
|
291
301
|
return;
|
|
292
|
-
// Inside the useEffect that watches [dragging, localTasks, localDocuments, fabricCanvas]
|
|
293
302
|
const handleMove = (e) => {
|
|
294
303
|
if (!dragStateRef.current.isDragging)
|
|
295
304
|
return;
|
|
@@ -300,7 +309,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
300
309
|
if (rafIdRef.current !== null)
|
|
301
310
|
cancelAnimationFrame(rafIdRef.current);
|
|
302
311
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
303
|
-
const { itemIds, startPositions, canvasObjectsStartPos, offsetX, offsetY } = dragStateRef.current;
|
|
312
|
+
const { itemIds, startPositions, canvasObjectsStartPos, offsetX, offsetY, } = dragStateRef.current;
|
|
304
313
|
const canvas = fabricCanvas?.current;
|
|
305
314
|
if (!canvas)
|
|
306
315
|
return;
|
|
@@ -316,33 +325,27 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
316
325
|
// (Current Mouse World - Initial World Offset from Start)
|
|
317
326
|
const deltaX = currentWorldX - offsetX;
|
|
318
327
|
const deltaY = currentWorldY - offsetY;
|
|
319
|
-
// if (itemIds.length > 0) {
|
|
320
|
-
// console.groupCollapsed(`Dragging Node: ${itemIds[0]}`);
|
|
321
|
-
// console.log("Screen Mouse:", { x: pointer.clientX, y: pointer.clientY });
|
|
322
|
-
// console.log("World Mouse:", { x: currentWorldX.toFixed(2), y: currentWorldY.toFixed(2) });
|
|
323
|
-
// console.log("Canvas Zoom:", liveZoom.toFixed(2));
|
|
324
|
-
// console.log("New Node Pos:", { x: deltaX.toFixed(2), y: deltaY.toFixed(2) });
|
|
325
|
-
// console.groupEnd();
|
|
326
|
-
// }
|
|
327
328
|
// 5. Calculate the Movement Delta in World Units
|
|
328
329
|
// We compare where the first item started vs where it is now.
|
|
329
330
|
const firstId = itemIds[0];
|
|
330
331
|
const firstStart = startPositions.get(firstId);
|
|
331
332
|
if (!firstStart)
|
|
332
333
|
return;
|
|
333
|
-
// The real problem of task jumps
|
|
334
|
-
// const deltaX = deltaX - firstStart.x;
|
|
335
334
|
// 6. Update HTML Nodes (Batching these into one state update)
|
|
336
|
-
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
335
|
+
setLocalTasks((prev) => prev.map((t) => itemIds.includes(t.id)
|
|
336
|
+
? {
|
|
337
|
+
...t,
|
|
338
|
+
x: (startPositions.get(t.id)?.x ?? t.x) + deltaX,
|
|
339
|
+
y: (startPositions.get(t.id)?.y ?? t.y) + deltaY,
|
|
340
|
+
}
|
|
341
|
+
: t));
|
|
342
|
+
setLocalDocuments((prev) => prev.map((d) => itemIds.includes(d.id)
|
|
343
|
+
? {
|
|
344
|
+
...d,
|
|
345
|
+
x: (startPositions.get(d.id)?.x ?? d.x) + deltaX,
|
|
346
|
+
y: (startPositions.get(d.id)?.y ?? d.y) + deltaY,
|
|
347
|
+
}
|
|
348
|
+
: d));
|
|
346
349
|
// 7. Sync Fabric Objects (Imperative update for performance)
|
|
347
350
|
canvasObjectsStartPos.forEach((startPos, obj) => {
|
|
348
351
|
obj.set({
|
|
@@ -355,6 +358,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
355
358
|
canvas.requestRenderAll();
|
|
356
359
|
});
|
|
357
360
|
};
|
|
361
|
+
// Issue 4: Use refs for always-current values
|
|
358
362
|
const handleEnd = () => {
|
|
359
363
|
if (rafIdRef.current !== null)
|
|
360
364
|
cancelAnimationFrame(rafIdRef.current);
|
|
@@ -363,8 +367,8 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
363
367
|
document.body.style.cursor = "";
|
|
364
368
|
document.body.style.userSelect = "";
|
|
365
369
|
document.body.style.touchAction = "";
|
|
366
|
-
onTasksUpdate?.(
|
|
367
|
-
onDocumentsUpdate?.(
|
|
370
|
+
onTasksUpdate?.(localTasksRef.current);
|
|
371
|
+
onDocumentsUpdate?.(localDocumentsRef.current);
|
|
368
372
|
};
|
|
369
373
|
window.addEventListener("mousemove", handleMove, { passive: false });
|
|
370
374
|
window.addEventListener("mouseup", handleEnd);
|
|
@@ -378,7 +382,7 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
378
382
|
window.removeEventListener("touchend", handleEnd);
|
|
379
383
|
window.removeEventListener("touchcancel", handleEnd);
|
|
380
384
|
};
|
|
381
|
-
}, [dragging,
|
|
385
|
+
}, [dragging, fabricCanvas]); // Issue 3: removed localTasks/localDocuments
|
|
382
386
|
// ── Selection, Status, Keyboard Logic ────────────────────────────────────────
|
|
383
387
|
const handleSelect = (id, e) => {
|
|
384
388
|
if (e?.shiftKey || e?.ctrlKey || e?.metaKey) {
|
|
@@ -393,29 +397,36 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
393
397
|
}
|
|
394
398
|
};
|
|
395
399
|
const handleStatusChange = (taskId, newStatus) => {
|
|
396
|
-
const updated =
|
|
400
|
+
const updated = localTasksRef.current.map((t) => t.id === taskId ? { ...t, status: newStatus } : t);
|
|
397
401
|
setLocalTasks(updated);
|
|
398
402
|
onTasksUpdate?.(updated);
|
|
399
403
|
};
|
|
404
|
+
// Issue 9: Use refs and only depend on stable callbacks
|
|
400
405
|
useEffect(() => {
|
|
401
406
|
const handleKeyDown = (e) => {
|
|
402
407
|
// Don't trigger if typing in input
|
|
403
|
-
if (e.target instanceof HTMLInputElement ||
|
|
408
|
+
if (e.target instanceof HTMLInputElement ||
|
|
409
|
+
e.target instanceof HTMLTextAreaElement)
|
|
404
410
|
return;
|
|
405
411
|
// Select All
|
|
406
412
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
|
407
413
|
e.preventDefault();
|
|
408
|
-
setSelectedIds(new Set([
|
|
414
|
+
setSelectedIds(new Set([
|
|
415
|
+
...localTasksRef.current.map((t) => t.id),
|
|
416
|
+
...localDocumentsRef.current.map((d) => d.id),
|
|
417
|
+
]));
|
|
409
418
|
}
|
|
410
419
|
// Clear selection
|
|
411
420
|
if (e.key === "Escape") {
|
|
412
421
|
setSelectedIds(new Set());
|
|
413
422
|
}
|
|
414
|
-
//
|
|
415
|
-
if ((e.key === "Delete" || e.key === "Backspace") &&
|
|
423
|
+
// Delete selected nodes
|
|
424
|
+
if ((e.key === "Delete" || e.key === "Backspace") &&
|
|
425
|
+
selectedIdsRef.current.size > 0) {
|
|
416
426
|
e.preventDefault();
|
|
417
|
-
const
|
|
418
|
-
const
|
|
427
|
+
const sel = selectedIdsRef.current;
|
|
428
|
+
const updatedTasks = localTasksRef.current.filter((t) => !sel.has(t.id));
|
|
429
|
+
const updatedDocs = localDocumentsRef.current.filter((d) => !sel.has(d.id));
|
|
419
430
|
setLocalTasks(updatedTasks);
|
|
420
431
|
setLocalDocuments(updatedDocs);
|
|
421
432
|
setSelectedIds(new Set());
|
|
@@ -425,14 +436,12 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
425
436
|
};
|
|
426
437
|
window.addEventListener("keydown", handleKeyDown);
|
|
427
438
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
428
|
-
}, [
|
|
439
|
+
}, [onTasksUpdate, onDocumentsUpdate]); // Issue 9: only stable callbacks
|
|
429
440
|
// ── Render helper ────────────────────────────────────────────────────────────
|
|
430
441
|
const renderItem = (id, x, y, children) => {
|
|
431
442
|
const screenX = x * canvasZoom;
|
|
432
443
|
const screenY = y * canvasZoom;
|
|
433
444
|
// 1. Detect if the user is interacting with the canvas at all
|
|
434
|
-
// 'dragging' is your existing state.
|
|
435
|
-
// You might want to pass 'isZooming' or 'isPanning' from your main canvas component here.
|
|
436
445
|
const isDragging = dragging?.itemIds.includes(id);
|
|
437
446
|
return (_jsx("div", { className: "pointer-events-auto absolute", style: {
|
|
438
447
|
left: 0,
|
|
@@ -455,4 +464,19 @@ export default function CanvasOverlayLayer({ tasks, documents, onTasksUpdate, on
|
|
|
455
464
|
transform: `translate(${canvasViewport.x}px, ${canvasViewport.y}px)`,
|
|
456
465
|
transformOrigin: "top left",
|
|
457
466
|
}, children: [localTasks.map((task) => renderItem(task.id, task.x, task.y, _jsx(TaskNode, { ...task, isSelected: selectedIds.has(task.id), onSelect: handleSelect, onDragStart: handleDragStart, onStatusChange: handleStatusChange, zoom: 1 }))), localDocuments.map((doc) => renderItem(doc.id, doc.x, doc.y, _jsx(DocumentNode, { ...doc, isSelected: selectedIds.has(doc.id), onSelect: handleSelect, onDragStart: handleDragStart })))] }) }));
|
|
458
|
-
}
|
|
467
|
+
}, (prev, next) => {
|
|
468
|
+
// Custom comparator — skip re-render if props are equal
|
|
469
|
+
// Return true to skip re-render, false to re-render
|
|
470
|
+
return (prev.tasks === next.tasks &&
|
|
471
|
+
prev.documents === next.documents &&
|
|
472
|
+
prev.canvasZoom === next.canvasZoom &&
|
|
473
|
+
prev.canvasViewport?.x === next.canvasViewport?.x &&
|
|
474
|
+
prev.canvasViewport?.y === next.canvasViewport?.y &&
|
|
475
|
+
prev.selectionBox === next.selectionBox &&
|
|
476
|
+
prev.selectedCanvasObjects === next.selectedCanvasObjects &&
|
|
477
|
+
prev.onTasksUpdate === next.onTasksUpdate &&
|
|
478
|
+
prev.onDocumentsUpdate === next.onDocumentsUpdate &&
|
|
479
|
+
prev.canvasReady === next.canvasReady
|
|
480
|
+
// fabricCanvas ref intentionally omitted — it's stable and doesn't need comparison
|
|
481
|
+
);
|
|
482
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { RefObject } from "react";
|
|
1
|
+
import React, { RefObject } from "react";
|
|
2
2
|
import fabric from "fabric";
|
|
3
3
|
interface ToolOptionsPanelProps {
|
|
4
4
|
fabricCanvas: RefObject<fabric.Canvas | null>;
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
export
|
|
6
|
+
declare const _default: React.NamedExoticComponent<ToolOptionsPanelProps>;
|
|
7
|
+
export default _default;
|
|
8
8
|
//# sourceMappingURL=tooloptions-panel.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tooloptions-panel.d.ts","sourceRoot":"","sources":["../../../src/components/toolbar/tooloptions-panel.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"tooloptions-panel.d.ts","sourceRoot":"","sources":["../../../src/components/toolbar/tooloptions-panel.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAAE,SAAS,EAA6C,MAAM,OAAO,CAAC;AAUpF,OAAO,MAA4C,MAAM,QAAQ,CAAC;AAIlE,UAAU,qBAAqB;IAC7B,YAAY,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;;AAWD,wBA2PG"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
|
4
4
|
import { cn } from "../../lib/utils";
|
|
5
5
|
import { useWhiteboardStore } from "../../store/whiteboard-store";
|
|
6
6
|
import PenOptions from "./options/pen-option";
|
|
@@ -12,13 +12,21 @@ import ArrowOptions from "./options/arrow-options";
|
|
|
12
12
|
import { Rect, Circle, IText } from "fabric";
|
|
13
13
|
import { Settings2, ChevronDown } from "lucide-react";
|
|
14
14
|
import LayerControls from "./layers-control";
|
|
15
|
-
|
|
15
|
+
// ── Memoized child components ─────────────────────────────────────────────────
|
|
16
|
+
const MemoizedPenOptions = React.memo(PenOptions);
|
|
17
|
+
const MemoizedShapeOptions = React.memo(ShapeOptions);
|
|
18
|
+
const MemoizedTextOptions = React.memo(TextOptions);
|
|
19
|
+
const MemoizedImageOptions = React.memo(ImageOptions);
|
|
20
|
+
const MemoizedLineOptions = React.memo(LineOptions);
|
|
21
|
+
const MemoizedArrowOptions = React.memo(ArrowOptions);
|
|
22
|
+
const MemoizedLayerControls = React.memo(LayerControls);
|
|
23
|
+
export default React.memo(function ToolOptionsPanel({ fabricCanvas, }) {
|
|
16
24
|
const activeTool = useWhiteboardStore((state) => state.activeTool);
|
|
17
25
|
const selectedObjectType = useWhiteboardStore((state) => state.selectedObjectType);
|
|
18
26
|
const toolOptions = useWhiteboardStore((state) => state.toolOptions);
|
|
19
27
|
const [isMobileExpanded, setIsMobileExpanded] = useState(false);
|
|
20
28
|
const displayTool = selectedObjectType || activeTool;
|
|
21
|
-
// Real-time fabric updates
|
|
29
|
+
// ── Real-time fabric updates ──────────────────────────────────────────────────
|
|
22
30
|
useEffect(() => {
|
|
23
31
|
const canvas = fabricCanvas.current;
|
|
24
32
|
if (!canvas)
|
|
@@ -57,21 +65,43 @@ export default function ToolOptionsPanel({ fabricCanvas, }) {
|
|
|
57
65
|
}
|
|
58
66
|
canvas.renderAll();
|
|
59
67
|
}, [toolOptions, selectedObjectType, fabricCanvas]);
|
|
60
|
-
|
|
68
|
+
// ── Memoized constants ────────────────────────────────────────────────────────
|
|
69
|
+
const toolsWithoutOptions = useMemo(() => ["select", "pan", "undo", "redo", "eraser"], []);
|
|
61
70
|
const isOpen = useMemo(() => {
|
|
62
71
|
const target = selectedObjectType || activeTool;
|
|
63
72
|
return target && !toolsWithoutOptions.includes(target);
|
|
64
|
-
}, [activeTool, selectedObjectType]);
|
|
73
|
+
}, [activeTool, selectedObjectType, toolsWithoutOptions]);
|
|
74
|
+
// ── Auto-collapse mobile panel when tool changes ───────────────────────────────
|
|
65
75
|
useEffect(() => {
|
|
66
76
|
if (!isOpen) {
|
|
67
77
|
setIsMobileExpanded(false);
|
|
68
78
|
}
|
|
69
79
|
}, [isOpen]);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
// ── Memoized handlers ─────────────────────────────────────────────────────────
|
|
81
|
+
const handleMobileToggle = useCallback(() => {
|
|
82
|
+
setIsMobileExpanded((prev) => !prev);
|
|
83
|
+
}, []);
|
|
84
|
+
// ── Memoized desktop sidebar content ──────────────────────────────────────────
|
|
85
|
+
const desktopContent = useMemo(() => {
|
|
86
|
+
return (_jsxs("div", { className: "space-y-6 text-neutral-200", children: [displayTool === "pen" && _jsx(MemoizedPenOptions, {}), (displayTool === "rectangle" || displayTool === "circle" || displayTool === "frame") && (_jsx(MemoizedShapeOptions, { shapeType: displayTool })), displayTool === "text" && _jsx(MemoizedTextOptions, {}), displayTool === "image" && _jsx(MemoizedImageOptions, {}), displayTool === "line" && _jsx(MemoizedLineOptions, {}), displayTool === "arrow" && _jsx(MemoizedArrowOptions, { fabricCanvas: fabricCanvas }), selectedObjectType && _jsx(MemoizedLayerControls, { fabricCanvas: fabricCanvas })] }));
|
|
87
|
+
}, [displayTool, selectedObjectType, fabricCanvas]);
|
|
88
|
+
// ── Memoized mobile content ───────────────────────────────────────────────────
|
|
89
|
+
const mobileContent = useMemo(() => {
|
|
90
|
+
return (_jsxs("div", { className: "px-4 pb-5 pt-2 space-y-4 overflow-y-auto max-h-[60vh] custom-scrollbar", children: [_jsx("div", { className: "h-[1px] w-full bg-white/5 mb-4" }), displayTool === "pen" && _jsx(MemoizedPenOptions, {}), (displayTool === "rectangle" || displayTool === "circle" || displayTool === "frame") && (_jsx(MemoizedShapeOptions, { shapeType: displayTool })), displayTool === "text" && _jsx(MemoizedTextOptions, {}), displayTool === "image" && _jsx(MemoizedImageOptions, {}), displayTool === "line" && _jsx(MemoizedLineOptions, {}), displayTool === "arrow" && _jsx(MemoizedArrowOptions, { fabricCanvas: fabricCanvas })] }));
|
|
91
|
+
}, [displayTool, fabricCanvas]);
|
|
92
|
+
// ── Memoized desktop header ───────────────────────────────────────────────────
|
|
93
|
+
const desktopHeader = useMemo(() => (_jsxs("div", { className: "flex items-center justify-between px-5 py-4 border-b border-white/5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Settings2, { className: "w-4 h-4 text-[#029AFF]" }), _jsx("h3", { className: "text-[11px] font-bold uppercase tracking-[0.1em] text-neutral-400", children: "Properties" })] }), _jsx("div", { className: "text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-white/40 border border-white/5", children: displayTool })] })), [displayTool]);
|
|
94
|
+
// ── Memoized mobile header ────────────────────────────────────────────────────
|
|
95
|
+
const mobileHeader = useMemo(() => (_jsxs("div", { className: "flex items-center justify-between px-4 h-[48px] cursor-pointer", onClick: handleMobileToggle, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Settings2, { className: "w-4 h-4 text-[#029AFF]" }), _jsxs("span", { className: "text-[10px] font-bold uppercase tracking-wider text-neutral-300", children: [displayTool, " Settings"] })] }), _jsx("div", { className: "flex items-center gap-2", children: _jsx("div", { className: cn("transition-transform duration-300", isMobileExpanded ? "rotate-180" : "rotate-0"), children: _jsx(ChevronDown, { className: "w-4 h-4 text-white/50" }) }) })] })), [displayTool, isMobileExpanded, handleMobileToggle]);
|
|
96
|
+
// ── Memoized desktop sidebar classes ──────────────────────────────────────────
|
|
97
|
+
const desktopSidebarClasses = useMemo(() => cn("hidden md:flex", "absolute left-4 top-1/2 -translate-y-1/2 z-40", "flex-col", "w-64 max-h-[85vh]", "bg-[#1A1A1E]/90 backdrop-blur-xl", "border border-white/10", "shadow-[0_20px_50px_rgba(0,0,0,0.5)]", "rounded-[24px]", "transition-all duration-500 cubic-bezier(0.16, 1, 0.3, 1)", isOpen
|
|
98
|
+
? "translate-x-0 opacity-100 visible scale-100"
|
|
99
|
+
: "-translate-x-12 opacity-0 invisible scale-95 pointer-events-none"), [isOpen]);
|
|
100
|
+
// ── Memoized mobile panel classes ─────────────────────────────────────────────
|
|
101
|
+
const mobilePanelClasses = useMemo(() => cn("md:hidden", "fixed left-3 top-4 z-40", "w-[calc(100%-80px)] max-w-[180px]", "bg-[#1A1A1E]/95 backdrop-blur-xl", "border border-white/10", "rounded-2xl shadow-2xl", "transition-all duration-300 ease-in-out", isMobileExpanded ? "max-h-[70vh]" : "h-[48px]"), [isMobileExpanded]);
|
|
102
|
+
// ── Memoized mobile content container classes ─────────────────────────────────
|
|
103
|
+
const mobileContentClasses = useMemo(() => cn("overflow-hidden transition-all duration-300", isMobileExpanded ? "opacity-100 h-auto" : "opacity-0 h-0"), [isMobileExpanded]);
|
|
104
|
+
return (_jsxs(_Fragment, { children: [_jsxs("aside", { className: desktopSidebarClasses, children: [_jsx("div", { className: "absolute -left-[1px] top-1/2 -translate-y-1/2 w-[3px] h-12 bg-[#029AFF] rounded-r-full shadow-[0_0_15px_#029AFF]" }), desktopHeader, _jsx("div", { className: "flex-1 overflow-y-auto p-5 custom-scrollbar scroll-smooth", children: desktopContent }), _jsx("div", { className: "p-3 flex justify-center", children: _jsx("div", { className: "w-8 h-1 rounded-full bg-white/10" }) })] }), isOpen && (_jsxs("div", { className: mobilePanelClasses, children: [mobileHeader, _jsx("div", { className: mobileContentClasses, children: mobileContent })] })), _jsx("style", { children: `
|
|
75
105
|
.custom-scrollbar::-webkit-scrollbar {
|
|
76
106
|
width: 4px;
|
|
77
107
|
}
|
|
@@ -86,4 +116,8 @@ export default function ToolOptionsPanel({ fabricCanvas, }) {
|
|
|
86
116
|
background: #029AFF;
|
|
87
117
|
}
|
|
88
118
|
` })] }));
|
|
89
|
-
}
|
|
119
|
+
}, (prev, next) => {
|
|
120
|
+
// Custom comparator for React.memo
|
|
121
|
+
// Return true to skip re-render, false to re-render
|
|
122
|
+
return prev.fabricCanvas === next.fabricCanvas;
|
|
123
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MutableRefObject } from "react";
|
|
1
|
+
import React, { MutableRefObject } from "react";
|
|
2
2
|
import { Canvas } from "fabric";
|
|
3
3
|
interface TaskTemplate {
|
|
4
4
|
id: string;
|
|
@@ -26,6 +26,6 @@ interface WhiteboardToolbarProps {
|
|
|
26
26
|
availableDocuments?: DocumentTemplate[];
|
|
27
27
|
isLoadingData?: boolean;
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
export
|
|
29
|
+
declare const _default: React.NamedExoticComponent<WhiteboardToolbarProps>;
|
|
30
|
+
export default _default;
|
|
31
31
|
//# sourceMappingURL=whiteboard-toolbar.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"whiteboard-toolbar.d.ts","sourceRoot":"","sources":["../../../src/components/toolbar/whiteboard-toolbar.tsx"],"names":[],"mappings":"AAEA,
|
|
1
|
+
{"version":3,"file":"whiteboard-toolbar.d.ts","sourceRoot":"","sources":["../../../src/components/toolbar/whiteboard-toolbar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAAqB,gBAAgB,EAAkC,MAAM,OAAO,CAAC;AAKnG,OAAO,EAAE,MAAM,EAAQ,MAAM,QAAQ,CAAC;AAQtC,UAAU,YAAY;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAAC;IACxC,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,UAAU,gBAAgB;IACxB,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;CACpB;AAkBD,UAAU,sBAAsB;IAC9B,YAAY,EAAE,gBAAgB,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,cAAc,EAAE,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC1C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;IACzC,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAChD,cAAc,CAAC,EAAE,YAAY,EAAE,CAAC;IAChC,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;;AAUD,wBA2SG"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
|
4
4
|
import { MousePointer2, Pencil, Square, Circle, Minus, MoveRight, Type, Eraser, Hand, Frame, Undo2, Redo2, Image as ImageIcon, } from "lucide-react";
|
|
5
5
|
import { util } from "fabric";
|
|
6
6
|
import { useWhiteboardStore } from "../../store/whiteboard-store";
|
|
@@ -24,7 +24,13 @@ const TOOLS = [
|
|
|
24
24
|
{ id: "undo", icon: Undo2, label: "Undo", shortcut: "⌘Z", category: "history" },
|
|
25
25
|
{ id: "redo", icon: Redo2, label: "Redo", shortcut: "⌘⇧Z", category: "history" },
|
|
26
26
|
];
|
|
27
|
-
|
|
27
|
+
// ── Memoized ToolButton wrapper ───────────────────────────────────────────────
|
|
28
|
+
const MemoizedToolButton = React.memo(ToolButton);
|
|
29
|
+
const MemoizedToolbarSeparator = React.memo(ToolbarSeparator);
|
|
30
|
+
const MemoizedTaskDropdown = React.memo(TaskDropdown);
|
|
31
|
+
const MemoizedDocumentDropdown = React.memo(DocumentDropdown);
|
|
32
|
+
// ── Main Component ────────────────────────────────────────────────────────────
|
|
33
|
+
export default React.memo(function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddTask, onAddDocument, availableTasks = [], availableDocuments = [], isLoadingData = false, }) {
|
|
28
34
|
const activeTool = useWhiteboardStore((s) => s.activeTool);
|
|
29
35
|
const setActiveTool = useWhiteboardStore((s) => s.setActiveTool);
|
|
30
36
|
const canUndo = useWhiteboardStore((s) => s.canUndo);
|
|
@@ -32,53 +38,33 @@ export default function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddT
|
|
|
32
38
|
const undo = useWhiteboardStore((s) => s.undo);
|
|
33
39
|
const redo = useWhiteboardStore((s) => s.redo);
|
|
34
40
|
const scrollRef = useRef(null);
|
|
35
|
-
const [scrollPosition, setScrollPosition] = useState(0);
|
|
36
41
|
const fileInputRef = useRef(null);
|
|
37
|
-
const
|
|
42
|
+
const [scrollPosition, setScrollPosition] = useState(0);
|
|
43
|
+
// ── Memoized callbacks ────────────────────────────────────────────────────────
|
|
44
|
+
const handleScroll = useCallback(() => {
|
|
38
45
|
if (scrollRef.current) {
|
|
39
46
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
|
40
47
|
const position = scrollLeft / (scrollWidth - clientWidth);
|
|
41
48
|
setScrollPosition(position);
|
|
42
49
|
}
|
|
43
|
-
};
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// If the user selects something other than the default 'select' tool
|
|
48
|
-
if (!canvas || activeTool === "select")
|
|
49
|
-
return;
|
|
50
|
-
const hero = canvas.getObjects().find((obj) => obj.id === "welcome-hero");
|
|
51
|
-
if (hero) {
|
|
52
|
-
hero.animate({
|
|
53
|
-
opacity: 0,
|
|
54
|
-
top: (hero.top || 0) - 20 // Subtle "lift" animation on exit
|
|
55
|
-
}, {
|
|
56
|
-
duration: 500,
|
|
57
|
-
easing: util.ease.easeInQuad,
|
|
58
|
-
onChange: () => canvas.requestRenderAll(),
|
|
59
|
-
onComplete: () => {
|
|
60
|
-
canvas.remove(hero);
|
|
61
|
-
canvas.requestRenderAll();
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}, [activeTool]);
|
|
66
|
-
// ── Restore helper ───────────────────────────────────────────────────────────
|
|
67
|
-
// Sets isRestoringRef=true BEFORE loadFromJSON so that the object:added events
|
|
68
|
-
// fired during restore do NOT push new entries onto the history stack.
|
|
69
|
-
// Without this, undo corrupts the stack and redo breaks permanently.
|
|
70
|
-
const restoreCanvas = (canvas, json) => {
|
|
71
|
-
isRestoringRef.current = true; // ← block saveState during restore
|
|
50
|
+
}, []);
|
|
51
|
+
// ── Restore helper (memoized) ─────────────────────────────────────────────────
|
|
52
|
+
const restoreCanvas = useCallback((canvas, json) => {
|
|
53
|
+
isRestoringRef.current = true;
|
|
72
54
|
canvas.loadFromJSON(JSON.parse(json)).then(() => {
|
|
73
55
|
canvas.isDrawingMode = false;
|
|
74
56
|
canvas.selection = true;
|
|
75
|
-
canvas.forEachObject((obj) => {
|
|
57
|
+
canvas.forEachObject((obj) => {
|
|
58
|
+
obj.selectable = true;
|
|
59
|
+
obj.evented = true;
|
|
60
|
+
});
|
|
76
61
|
canvas.renderAll();
|
|
77
62
|
setActiveTool("select");
|
|
78
|
-
isRestoringRef.current = false;
|
|
63
|
+
isRestoringRef.current = false;
|
|
79
64
|
});
|
|
80
|
-
};
|
|
81
|
-
|
|
65
|
+
}, [setActiveTool, isRestoringRef]);
|
|
66
|
+
// ── Handle tool click (memoized) ──────────────────────────────────────────────
|
|
67
|
+
const handleToolClick = useCallback((toolId) => {
|
|
82
68
|
const canvas = fabricCanvas.current;
|
|
83
69
|
if (!canvas)
|
|
84
70
|
return;
|
|
@@ -101,8 +87,9 @@ export default function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddT
|
|
|
101
87
|
return;
|
|
102
88
|
}
|
|
103
89
|
setActiveTool(toolId);
|
|
104
|
-
};
|
|
105
|
-
|
|
90
|
+
}, [fabricCanvas, undo, redo, restoreCanvas, setActiveTool]);
|
|
91
|
+
// ── Handle image upload (memoized) ────────────────────────────────────────────
|
|
92
|
+
const handleImageUpload = useCallback((e) => {
|
|
106
93
|
const file = e.target.files?.[0];
|
|
107
94
|
if (!file || !file.type.startsWith("image/"))
|
|
108
95
|
return;
|
|
@@ -110,19 +97,56 @@ export default function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddT
|
|
|
110
97
|
if (!canvas)
|
|
111
98
|
return;
|
|
112
99
|
const reader = new FileReader();
|
|
113
|
-
reader.onload = (ev) => {
|
|
114
|
-
|
|
100
|
+
reader.onload = (ev) => {
|
|
101
|
+
const url = ev.target?.result;
|
|
102
|
+
if (url)
|
|
103
|
+
addImageToCanvas(canvas, url);
|
|
104
|
+
};
|
|
115
105
|
reader.readAsDataURL(file);
|
|
116
106
|
e.target.value = "";
|
|
117
|
-
};
|
|
118
|
-
//
|
|
107
|
+
}, [fabricCanvas]);
|
|
108
|
+
// ── Hero animation effect ─────────────────────────────────────────────────────
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const canvas = fabricCanvas.current;
|
|
111
|
+
if (!canvas || activeTool === "select")
|
|
112
|
+
return;
|
|
113
|
+
const hero = canvas.getObjects().find((obj) => obj.id === "welcome-hero");
|
|
114
|
+
if (hero) {
|
|
115
|
+
hero.animate({
|
|
116
|
+
opacity: 0,
|
|
117
|
+
top: (hero.top || 0) - 20,
|
|
118
|
+
}, {
|
|
119
|
+
duration: 500,
|
|
120
|
+
easing: util.ease.easeInQuad,
|
|
121
|
+
onChange: () => canvas.requestRenderAll(),
|
|
122
|
+
onComplete: () => {
|
|
123
|
+
canvas.remove(hero);
|
|
124
|
+
canvas.requestRenderAll();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}, [activeTool, fabricCanvas]);
|
|
129
|
+
// ── Keyboard shortcuts effect ─────────────────────────────────────────────────
|
|
119
130
|
useEffect(() => {
|
|
120
131
|
const onKey = (e) => {
|
|
121
|
-
if (e.target instanceof HTMLInputElement ||
|
|
132
|
+
if (e.target instanceof HTMLInputElement ||
|
|
133
|
+
e.target instanceof HTMLTextAreaElement)
|
|
122
134
|
return;
|
|
123
135
|
const key = e.key.toLowerCase();
|
|
124
136
|
const ctrl = e.ctrlKey || e.metaKey;
|
|
125
|
-
const shortcuts = {
|
|
137
|
+
const shortcuts = {
|
|
138
|
+
v: "select",
|
|
139
|
+
h: "pan",
|
|
140
|
+
p: "pen",
|
|
141
|
+
r: "rectangle",
|
|
142
|
+
c: "circle",
|
|
143
|
+
f: "frame",
|
|
144
|
+
l: "line",
|
|
145
|
+
a: "arrow",
|
|
146
|
+
t: "text",
|
|
147
|
+
i: "image",
|
|
148
|
+
e: "eraser",
|
|
149
|
+
};
|
|
126
150
|
if (shortcuts[key] && !ctrl) {
|
|
127
151
|
e.preventDefault();
|
|
128
152
|
handleToolClick(shortcuts[key]);
|
|
@@ -148,8 +172,14 @@ export default function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddT
|
|
|
148
172
|
};
|
|
149
173
|
window.addEventListener("keydown", onKey);
|
|
150
174
|
return () => window.removeEventListener("keydown", onKey);
|
|
151
|
-
}, [
|
|
152
|
-
|
|
175
|
+
}, [fabricCanvas, activeTool, handleToolClick]);
|
|
176
|
+
// ── Memoized tool buttons ─────────────────────────────────────────────────────
|
|
177
|
+
const toolButtons = useMemo(() => TOOLS.map((tool, index) => (_jsxs("div", { className: "flex items-center snap-center", children: [_jsx(MemoizedToolButton, { icon: tool.icon, label: tool.label, isActive: activeTool === tool.id, onClick: () => handleToolClick(tool.id), disabled: (tool.id === "undo" && !canUndo) || (tool.id === "redo" && !canRedo), shortcut: tool.shortcut }), (index === 1 || index === 9) && (_jsx("div", { className: "hidden md:block mx-1", children: _jsx(MemoizedToolbarSeparator, {}) }))] }, tool.id))), [activeTool, canUndo, canRedo, handleToolClick]);
|
|
178
|
+
// ── Memoized dropdown section ─────────────────────────────────────────────────
|
|
179
|
+
const dropdownSection = useMemo(() => (onAddTask || onAddDocument) && (_jsxs("div", { className: "flex items-center gap-1 snap-center pr-2", children: [_jsx("div", { className: "mx-1 flex-shrink-0", children: _jsx(MemoizedToolbarSeparator, {}) }), onAddTask && (_jsx(MemoizedTaskDropdown, { onAddTask: onAddTask, availableTasks: availableTasks })), onAddDocument && (_jsx(MemoizedDocumentDropdown, { onAddDocument: onAddDocument, availableDocuments: availableDocuments }))] })), [onAddTask, onAddDocument, availableTasks, availableDocuments]);
|
|
180
|
+
// ── Scroll indicator visibility ───────────────────────────────────────────────
|
|
181
|
+
const scrollIndicators = useMemo(() => (_jsxs("div", { className: "flex gap-1.5 md:hidden", children: [_jsx("div", { className: `w-1.5 h-1.5 rounded-full transition-all duration-300 ${scrollPosition < 0.5 ? "bg-white w-6" : "bg-white/30"}` }), _jsx("div", { className: `w-1.5 h-1.5 rounded-full transition-all duration-300 ${scrollPosition >= 0.5 ? "bg-white w-6" : "bg-white/30"}` })] })), [scrollPosition]);
|
|
182
|
+
return (_jsxs(_Fragment, { children: [_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleImageUpload, className: "hidden" }), _jsxs("div", { className: "fixed bottom-4 md:bottom-6 left-0 right-0 z-50 flex flex-col items-center gap-2 px-4 pointer-events-none", children: [_jsxs("div", { className: "relative w-full md:w-auto max-w-full pointer-events-auto", children: [_jsx("div", { className: "absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#0b0b0b] to-transparent pointer-events-none z-10 md:hidden rounded-l-2xl" }), _jsx("div", { className: "absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#0b0b0b] to-transparent pointer-events-none z-10 md:hidden rounded-r-2xl" }), _jsxs("div", { ref: scrollRef, onScroll: handleScroll, className: "flex items-center gap-1 px-2 py-2 bg-black/95 backdrop-blur-md border border-[#A1A1A1] rounded-2xl shadow-2xl overflow-x-auto md:overflow-x-visible snap-x snap-mandatory md:snap-none scrollbar-hide scroll-smooth", children: [toolButtons, dropdownSection] })] }), scrollIndicators] }), _jsx("style", { children: `
|
|
153
183
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
|
154
184
|
.scrollbar-hide {
|
|
155
185
|
-ms-overflow-style: none;
|
|
@@ -157,4 +187,14 @@ export default function WhiteboardToolbar({ fabricCanvas, isRestoringRef, onAddT
|
|
|
157
187
|
-webkit-overflow-scrolling: touch;
|
|
158
188
|
}
|
|
159
189
|
` })] }));
|
|
160
|
-
}
|
|
190
|
+
}, (prev, next) => {
|
|
191
|
+
// Custom comparator for React.memo
|
|
192
|
+
// Return true to skip re-render, false to re-render
|
|
193
|
+
return (prev.fabricCanvas === next.fabricCanvas &&
|
|
194
|
+
prev.isRestoringRef === next.isRestoringRef &&
|
|
195
|
+
prev.onAddTask === next.onAddTask &&
|
|
196
|
+
prev.onAddDocument === next.onAddDocument &&
|
|
197
|
+
prev.availableTasks === next.availableTasks &&
|
|
198
|
+
prev.availableDocuments === next.availableDocuments &&
|
|
199
|
+
prev.isLoadingData === next.isLoadingData);
|
|
200
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"whiteboard-test.d.ts","sourceRoot":"","sources":["../../../src/components/whiteboard/whiteboard-test.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"whiteboard-test.d.ts","sourceRoot":"","sources":["../../../src/components/whiteboard/whiteboard-test.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAkB5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAGxD,YAAY,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AACvE,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAIxE,UAAU,IAAI;IACZ,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,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,qBAAqB;IAC7B,WAAW,CAAC,EAAE;QACZ,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;QACf,SAAS,CAAC,EAAE,QAAQ,EAAE,CAAC;KACxB,CAAC;IACF,cAAc,CAAC,EAAE,YAAY,EAAE,CAAC;IAChC,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,GAAG,EAAE,CAAC;QACb,SAAS,EAAE,GAAG,EAAE,CAAC;KAClB,KAAK,IAAI,CAAC;IACX,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AA2HD,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACvC,WAAW,EACX,MAAM,EACN,cAAc,EACd,cAAmB,EACnB,kBAAuB,EACvB,aAAqB,GACtB,EAAE,qBAAqB,2CAiRvB"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import React, { useCallback, useRef, useState } from "react";
|
|
4
4
|
import { classRegistry } from "fabric";
|
|
5
5
|
import { useWhiteboardStore } from "../../store/whiteboard-store";
|
|
6
6
|
import WhiteboardToolbar from "../toolbar/whiteboard-toolbar";
|
|
@@ -23,6 +23,54 @@ import { usePersistence } from "../../hooks/usePersistance";
|
|
|
23
23
|
import { ToolbarSkeleton } from "../toolbar/toolbar-skeleton/toolbar-skeleton";
|
|
24
24
|
import { useCopyPaste } from "../../hooks/useCopyPaste";
|
|
25
25
|
classRegistry.setClass(Frame, "frame");
|
|
26
|
+
// ── Memoized Toolbar wrapper (Issue 10) ───────────────────────────────────────
|
|
27
|
+
const MemoizedToolbarWrapper = React.memo(function ToolbarWrapper({ canvasReady, fabricCanvasRef, isRestoringRef, onAddTask, onAddDocument, availableDocuments, availableTasks, isLoadingData, }) {
|
|
28
|
+
return canvasReady ? (_jsx(WhiteboardToolbar, { fabricCanvas: fabricCanvasRef, isRestoringRef: isRestoringRef, onAddTask: onAddTask, onAddDocument: onAddDocument, availableDocuments: availableDocuments, availableTasks: availableTasks, isLoadingData: isLoadingData })) : (_jsx(ToolbarSkeleton, {}));
|
|
29
|
+
});
|
|
30
|
+
// ── Memoized Overlay wrapper (Issue 11) ───────────────────────────────────────
|
|
31
|
+
// This wrapper only updates when tasks/documents/canvasReady change
|
|
32
|
+
// Not on every pan/zoom frame
|
|
33
|
+
const MemoizedOverlayWrapper = React.memo(function OverlayWrapper({ tasks, documents, canvasReady, fabricCanvasRef, onTasksUpdate, onDocumentsUpdate, }) {
|
|
34
|
+
// Issue 11: Read zoom/viewport directly from fabricCanvas instead of prop drilling
|
|
35
|
+
// This prevents re-renders on every pan/zoom frame
|
|
36
|
+
const [canvasZoom, setCanvasZoom] = useState(1);
|
|
37
|
+
const [canvasViewport, setCanvasViewport] = useState({ x: 0, y: 0 });
|
|
38
|
+
const [selectionBox, setSelectionBox] = useState(null);
|
|
39
|
+
const [selectedCanvasObjects, setSelectedCanvasObjects] = useState([]);
|
|
40
|
+
// Effect to sync zoom/viewport from fabric canvas
|
|
41
|
+
// This keeps the overlay in sync without prop drilling
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
const canvas = fabricCanvasRef.current;
|
|
44
|
+
if (!canvas)
|
|
45
|
+
return;
|
|
46
|
+
const updateViewport = () => {
|
|
47
|
+
const vpt = canvas.viewportTransform;
|
|
48
|
+
if (!vpt)
|
|
49
|
+
return;
|
|
50
|
+
// Extract zoom (scale) from viewport transform
|
|
51
|
+
const zoom = vpt[0];
|
|
52
|
+
const vpX = vpt[4];
|
|
53
|
+
const vpY = vpt[5];
|
|
54
|
+
setCanvasZoom(zoom);
|
|
55
|
+
setCanvasViewport({ x: vpX, y: vpY });
|
|
56
|
+
};
|
|
57
|
+
// Update on viewport change
|
|
58
|
+
canvas.on("viewport:changed", updateViewport);
|
|
59
|
+
updateViewport(); // Initial sync
|
|
60
|
+
return () => {
|
|
61
|
+
canvas.off("viewport:changed", updateViewport);
|
|
62
|
+
};
|
|
63
|
+
}, [fabricCanvasRef, canvasReady]);
|
|
64
|
+
return (_jsx(CanvasOverlayLayer, { tasks: tasks, documents: documents, onTasksUpdate: onTasksUpdate, onDocumentsUpdate: onDocumentsUpdate, canvasZoom: canvasZoom, canvasViewport: canvasViewport, selectionBox: selectionBox, selectedCanvasObjects: selectedCanvasObjects, fabricCanvas: fabricCanvasRef, canvasReady: canvasReady }));
|
|
65
|
+
}, (prev, next) => {
|
|
66
|
+
// Custom comparator — only re-render if these props change
|
|
67
|
+
return (prev.tasks === next.tasks &&
|
|
68
|
+
prev.documents === next.documents &&
|
|
69
|
+
prev.canvasReady === next.canvasReady &&
|
|
70
|
+
prev.fabricCanvasRef === next.fabricCanvasRef &&
|
|
71
|
+
prev.onTasksUpdate === next.onTasksUpdate &&
|
|
72
|
+
prev.onDocumentsUpdate === next.onDocumentsUpdate);
|
|
73
|
+
});
|
|
26
74
|
export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs, availableTasks = [], availableDocuments = [], isLoadingData = false, }) {
|
|
27
75
|
// Refs
|
|
28
76
|
const canvasRef = useRef(null);
|
|
@@ -48,14 +96,10 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
48
96
|
const [tasks, setTasks] = useState([]);
|
|
49
97
|
const [canvasReady, setCanvasReady] = useState(false);
|
|
50
98
|
const [documents, setDocuments] = useState([]);
|
|
51
|
-
const [canvasZoom, setCanvasZoom] = useState(1);
|
|
52
|
-
const [canvasViewport, setCanvasViewport] = useState({ x: 0, y: 0 });
|
|
53
|
-
const [selectionBox, setSelectionBox] = useState(null);
|
|
54
|
-
const [selectedCanvasObjects, setSelectedCanvasObjects] = useState([]);
|
|
55
99
|
const MIN_ZOOM = 0.1;
|
|
56
100
|
const MAX_ZOOM = 5;
|
|
57
101
|
const ZOOM_STEP = 0.1;
|
|
58
|
-
//
|
|
102
|
+
// Initialize canvas
|
|
59
103
|
useCanvasInit({
|
|
60
104
|
canvasRef,
|
|
61
105
|
fabricCanvasRef,
|
|
@@ -68,7 +112,7 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
68
112
|
initialData,
|
|
69
113
|
onReady: () => setCanvasReady(true),
|
|
70
114
|
});
|
|
71
|
-
//
|
|
115
|
+
// Persistence
|
|
72
116
|
usePersistence({
|
|
73
117
|
fabricCanvas: fabricCanvasRef,
|
|
74
118
|
tasks,
|
|
@@ -121,26 +165,26 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
121
165
|
fabricCanvas: fabricCanvasRef,
|
|
122
166
|
MIN_ZOOM,
|
|
123
167
|
MAX_ZOOM,
|
|
124
|
-
canvasZoom,
|
|
125
|
-
canvasViewport,
|
|
126
|
-
setCanvasZoom,
|
|
127
|
-
setCanvasViewport,
|
|
168
|
+
canvasZoom: 1, // Not used for state — just reference
|
|
169
|
+
canvasViewport: { x: 0, y: 0 },
|
|
170
|
+
setCanvasZoom: () => { }, // Not used anymore
|
|
171
|
+
setCanvasViewport: () => { }, // Not used anymore
|
|
128
172
|
});
|
|
129
173
|
// Pan
|
|
130
174
|
usePan({
|
|
131
175
|
fabricCanvas: fabricCanvasRef,
|
|
132
176
|
activeTool,
|
|
133
177
|
handleZoom,
|
|
134
|
-
setCanvasViewport,
|
|
178
|
+
setCanvasViewport: () => { }, // Not used anymore
|
|
135
179
|
});
|
|
136
180
|
// Selection
|
|
137
181
|
useSelection({
|
|
138
182
|
fabricCanvas: fabricCanvasRef,
|
|
139
183
|
activeTool,
|
|
140
|
-
canvasZoom,
|
|
141
|
-
canvasViewport,
|
|
142
|
-
setSelectionBox,
|
|
143
|
-
setSelectedCanvasObjects,
|
|
184
|
+
canvasZoom: 1, // Read from canvas instead
|
|
185
|
+
canvasViewport: { x: 0, y: 0 },
|
|
186
|
+
setSelectionBox: () => { }, // Not used anymore
|
|
187
|
+
setSelectedCanvasObjects: () => { }, // Not used anymore
|
|
144
188
|
isDrawingRef,
|
|
145
189
|
});
|
|
146
190
|
// Text style updates
|
|
@@ -161,7 +205,7 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
161
205
|
drawingHandlers,
|
|
162
206
|
eraserHandlers,
|
|
163
207
|
});
|
|
164
|
-
//
|
|
208
|
+
// ── Memoized static handlers ──────────────────────────────────────────────────
|
|
165
209
|
const handleAddTaskFromDropdown = useCallback((taskTemplate) => {
|
|
166
210
|
const canvas = fabricCanvasRef.current;
|
|
167
211
|
if (!canvas)
|
|
@@ -172,7 +216,15 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
172
216
|
const liveZoom = vpt[0];
|
|
173
217
|
const cx = (canvas.getWidth() / 2 - vpt[4]) / liveZoom;
|
|
174
218
|
const cy = (canvas.getHeight() / 2 - vpt[5]) / liveZoom;
|
|
175
|
-
setTasks((prev) => [
|
|
219
|
+
setTasks((prev) => [
|
|
220
|
+
...prev,
|
|
221
|
+
{
|
|
222
|
+
...taskTemplate,
|
|
223
|
+
id: `${taskTemplate.id}-${Date.now()}`,
|
|
224
|
+
x: cx - 150,
|
|
225
|
+
y: cy - 60,
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
176
228
|
}, []);
|
|
177
229
|
const handleAddDocumentFromDropdown = useCallback((docTemplate) => {
|
|
178
230
|
const canvas = fabricCanvasRef.current;
|
|
@@ -184,18 +236,43 @@ export default function FabricWhiteboard({ initialData, onSave, saveDebounceMs,
|
|
|
184
236
|
const liveZoom = vpt[0];
|
|
185
237
|
const cx = (canvas.getWidth() / 2 - vpt[4]) / liveZoom;
|
|
186
238
|
const cy = (canvas.getHeight() / 2 - vpt[5]) / liveZoom;
|
|
187
|
-
setDocuments((prev) => [
|
|
239
|
+
setDocuments((prev) => [
|
|
240
|
+
...prev,
|
|
241
|
+
{
|
|
242
|
+
...docTemplate,
|
|
243
|
+
id: `${docTemplate.id}-${Date.now()}`,
|
|
244
|
+
x: cx - 160,
|
|
245
|
+
y: cy - 80,
|
|
246
|
+
},
|
|
247
|
+
]);
|
|
188
248
|
}, []);
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
249
|
+
// ── Zoom handlers (read from canvas directly) ────────────────────────────────
|
|
250
|
+
const handleZoomIn = useCallback(() => {
|
|
251
|
+
const canvas = fabricCanvasRef.current;
|
|
252
|
+
if (!canvas)
|
|
253
|
+
return;
|
|
254
|
+
const vpt = canvas.viewportTransform;
|
|
255
|
+
if (!vpt)
|
|
256
|
+
return;
|
|
257
|
+
const currentZoom = vpt[0];
|
|
258
|
+
handleZoom(currentZoom + ZOOM_STEP);
|
|
259
|
+
}, [handleZoom]);
|
|
260
|
+
const handleZoomOut = useCallback(() => {
|
|
261
|
+
const canvas = fabricCanvasRef.current;
|
|
262
|
+
if (!canvas)
|
|
263
|
+
return;
|
|
264
|
+
const vpt = canvas.viewportTransform;
|
|
265
|
+
if (!vpt)
|
|
266
|
+
return;
|
|
267
|
+
const currentZoom = vpt[0];
|
|
268
|
+
handleZoom(currentZoom - ZOOM_STEP);
|
|
269
|
+
}, [handleZoom]);
|
|
270
|
+
const handleResetZoom = useCallback(() => {
|
|
271
|
+
handleZoom(1);
|
|
272
|
+
}, [handleZoom]);
|
|
196
273
|
return (_jsx("div", { className: "easyflow-whiteboard w-full h-full", children: _jsxs("div", { ref: containerRef, className: "relative w-full h-full overflow-hidden bg-[#0b0b0b]", style: { touchAction: "none", overscrollBehavior: "none" }, children: [_jsx("div", { className: "absolute inset-0 pointer-events-none", style: {
|
|
197
274
|
backgroundImage: `radial-gradient(circle, rgba(255,255,255,0.2) 1.2px, transparent 1.2px)`,
|
|
198
275
|
backgroundSize: "40px 40px",
|
|
199
276
|
zIndex: 0,
|
|
200
|
-
} }), _jsx("canvas", { ref: canvasRef, className: "absolute inset-0", style: { zIndex: 1 } }),
|
|
277
|
+
} }), _jsx("canvas", { ref: canvasRef, className: "absolute inset-0", style: { zIndex: 1 } }), _jsx(MemoizedOverlayWrapper, { tasks: tasks, documents: documents, canvasReady: canvasReady, fabricCanvasRef: fabricCanvasRef, onTasksUpdate: setTasks, onDocumentsUpdate: setDocuments }), _jsxs("div", { className: "absolute inset-0 pointer-events-none", style: { zIndex: 100 }, children: [_jsx("div", { className: "pointer-events-auto", children: _jsx(MemoizedToolbarWrapper, { canvasReady: canvasReady, fabricCanvasRef: fabricCanvasRef, isRestoringRef: isRestoringRef, onAddTask: handleAddTaskFromDropdown, onAddDocument: handleAddDocumentFromDropdown, availableDocuments: availableDocuments, availableTasks: availableTasks, isLoadingData: isLoadingData }) }), _jsx("div", { className: "pointer-events-auto", children: _jsx(ToolOptionsPanel, { fabricCanvas: fabricCanvasRef }) }), _jsx("div", { className: "pointer-events-auto", children: _jsx(ZoomControls, { zoom: 1, onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, onResetZoom: handleResetZoom }) })] })] }) }));
|
|
201
278
|
}
|
package/dist/styles.css
CHANGED
|
@@ -333,6 +333,24 @@
|
|
|
333
333
|
.z-\[100\] {
|
|
334
334
|
z-index: 100;
|
|
335
335
|
}
|
|
336
|
+
.container {
|
|
337
|
+
width: 100%;
|
|
338
|
+
@media (width >= 40rem) {
|
|
339
|
+
max-width: 40rem;
|
|
340
|
+
}
|
|
341
|
+
@media (width >= 48rem) {
|
|
342
|
+
max-width: 48rem;
|
|
343
|
+
}
|
|
344
|
+
@media (width >= 64rem) {
|
|
345
|
+
max-width: 64rem;
|
|
346
|
+
}
|
|
347
|
+
@media (width >= 80rem) {
|
|
348
|
+
max-width: 80rem;
|
|
349
|
+
}
|
|
350
|
+
@media (width >= 96rem) {
|
|
351
|
+
max-width: 96rem;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
336
354
|
.-mx-1 {
|
|
337
355
|
margin-inline: calc(var(--spacing) * -1);
|
|
338
356
|
}
|