@bwp-web/canvas 0.5.0 → 0.6.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.
Files changed (40) hide show
  1. package/dist/Canvas/Canvas.d.ts +12 -1
  2. package/dist/Canvas/Canvas.d.ts.map +1 -1
  3. package/dist/background.d.ts.map +1 -1
  4. package/dist/constants.d.ts +2 -2
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/fabricAugmentation.d.ts +3 -1
  7. package/dist/fabricAugmentation.d.ts.map +1 -1
  8. package/dist/history.d.ts +32 -0
  9. package/dist/history.d.ts.map +1 -0
  10. package/dist/hooks/shared.d.ts +6 -4
  11. package/dist/hooks/shared.d.ts.map +1 -1
  12. package/dist/hooks/useEditCanvas.d.ts +22 -0
  13. package/dist/hooks/useEditCanvas.d.ts.map +1 -1
  14. package/dist/hooks/useObjectOverlay.d.ts +4 -3
  15. package/dist/hooks/useObjectOverlay.d.ts.map +1 -1
  16. package/dist/hooks/useViewCanvas.d.ts +7 -0
  17. package/dist/hooks/useViewCanvas.d.ts.map +1 -1
  18. package/dist/index.cjs +416 -159
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +8 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +370 -112
  23. package/dist/index.js.map +1 -1
  24. package/dist/interactions/dragToCreate.d.ts +6 -1
  25. package/dist/interactions/dragToCreate.d.ts.map +1 -1
  26. package/dist/interactions/drawToCreate.d.ts +8 -3
  27. package/dist/interactions/drawToCreate.d.ts.map +1 -1
  28. package/dist/interactions/vertexEdit.d.ts +5 -1
  29. package/dist/interactions/vertexEdit.d.ts.map +1 -1
  30. package/dist/serialization.d.ts +25 -2
  31. package/dist/serialization.d.ts.map +1 -1
  32. package/dist/shapes/polygon.d.ts +2 -2
  33. package/dist/shapes/polygon.d.ts.map +1 -1
  34. package/dist/styles.d.ts +6 -0
  35. package/dist/styles.d.ts.map +1 -1
  36. package/dist/types.d.ts +8 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/viewport.d.ts +12 -2
  39. package/dist/viewport.d.ts.map +1 -1
  40. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -33,7 +33,9 @@ function Canvas({
33
33
  height,
34
34
  className,
35
35
  style,
36
- onReady
36
+ onReady,
37
+ keyboardShortcuts,
38
+ fabricOptions
37
39
  }) {
38
40
  const canvasRef = useRef(null);
39
41
  const wrapperRef = useRef(null);
@@ -45,11 +47,12 @@ function Canvas({
45
47
  const initialWidth = isFixedSize ? width : wrapper.clientWidth || 800;
46
48
  const initialHeight = isFixedSize ? height : wrapper.clientHeight || 600;
47
49
  const fabricCanvas = new FabricCanvas(el, {
50
+ ...fabricOptions,
48
51
  width: initialWidth,
49
52
  height: initialHeight
50
53
  });
51
54
  onReady?.(fabricCanvas);
52
- const cleanupShortcuts = enableKeyboardShortcuts(fabricCanvas);
55
+ const cleanupShortcuts = keyboardShortcuts ? enableKeyboardShortcuts(fabricCanvas) : void 0;
53
56
  let observer;
54
57
  let rafId = 0;
55
58
  if (!isFixedSize) {
@@ -73,7 +76,7 @@ function Canvas({
73
76
  return () => {
74
77
  cancelAnimationFrame(rafId);
75
78
  observer?.disconnect();
76
- cleanupShortcuts();
79
+ cleanupShortcuts?.();
77
80
  fabricCanvas.dispose();
78
81
  };
79
82
  }, []);
@@ -93,7 +96,7 @@ import {
93
96
  // src/constants.ts
94
97
  var DEFAULT_MIN_ZOOM = 0.2;
95
98
  var DEFAULT_MAX_ZOOM = 10;
96
- var DEFAULT_ZOOM_FACTOR = 1.03;
99
+ var DEFAULT_ZOOM_FACTOR = 0.999;
97
100
  var DEFAULT_ZOOM_STEP = 1.2;
98
101
  var DEFAULT_VIEWPORT_PADDING = 0.05;
99
102
  var BASE_CANVAS_SIZE = 1e3;
@@ -136,7 +139,7 @@ function setupWheelZoom(canvas, bounds, zoomFactor, isEnabled) {
136
139
  e.stopPropagation();
137
140
  const delta = e.deltaY;
138
141
  let zoom = canvas.getZoom();
139
- zoom = delta < 0 ? zoom * zoomFactor : zoom / zoomFactor;
142
+ zoom *= zoomFactor ** delta;
140
143
  zoom = Math.min(Math.max(zoom, bounds.minZoom), bounds.maxZoom);
141
144
  canvas.zoomToPoint(new Point(e.offsetX, e.offsetY), zoom);
142
145
  };
@@ -244,8 +247,15 @@ function enablePanAndZoom(canvas, options) {
244
247
  const zoomFactor = options?.zoomFactor ?? DEFAULT_ZOOM_FACTOR;
245
248
  let mode = options?.initialMode ?? "select";
246
249
  let enabled = true;
250
+ let currentAnimRafId = null;
247
251
  const isEnabled = () => enabled;
248
252
  const getMode = () => mode;
253
+ function cancelAnimation() {
254
+ if (currentAnimRafId !== null) {
255
+ cancelAnimationFrame(currentAnimRafId);
256
+ currentAnimRafId = null;
257
+ }
258
+ }
249
259
  const handleWheel = setupWheelZoom(canvas, bounds, zoomFactor, isEnabled);
250
260
  const panHandlers = setupMousePan(canvas, getMode, isEnabled);
251
261
  const cleanupPinch = setupPinchZoom(canvas, bounds, isEnabled);
@@ -291,6 +301,7 @@ function enablePanAndZoom(canvas, options) {
291
301
  );
292
302
  },
293
303
  panToObject(object, panOpts) {
304
+ cancelAnimation();
294
305
  const zoom = canvas.getZoom();
295
306
  const objectCenter = object.getCenterPoint();
296
307
  const canvasCenterX = canvas.getWidth() / 2;
@@ -333,12 +344,37 @@ function enablePanAndZoom(canvas, options) {
333
344
  currentY
334
345
  ]);
335
346
  if (t < 1) {
336
- requestAnimationFrame(step);
347
+ currentAnimRafId = requestAnimationFrame(step);
348
+ } else {
349
+ currentAnimRafId = null;
337
350
  }
338
351
  }
339
- requestAnimationFrame(step);
352
+ currentAnimRafId = requestAnimationFrame(step);
353
+ },
354
+ zoomToFit(object, fitOpts) {
355
+ cancelAnimation();
356
+ const padding = fitOpts?.padding ?? 0.1;
357
+ const objWidth = (object.width ?? 0) * (object.scaleX ?? 1);
358
+ const objHeight = (object.height ?? 0) * (object.scaleY ?? 1);
359
+ if (!objWidth || !objHeight) return;
360
+ const canvasWidth = canvas.getWidth();
361
+ const canvasHeight = canvas.getHeight();
362
+ const availableWidth = canvasWidth * (1 - padding * 2);
363
+ const availableHeight = canvasHeight * (1 - padding * 2);
364
+ const zoom = Math.min(
365
+ Math.max(
366
+ Math.min(availableWidth / objWidth, availableHeight / objHeight),
367
+ bounds.minZoom
368
+ ),
369
+ bounds.maxZoom
370
+ );
371
+ const objectCenter = object.getCenterPoint();
372
+ const offsetX = canvasWidth / 2 - objectCenter.x * zoom;
373
+ const offsetY = canvasHeight / 2 - objectCenter.y * zoom;
374
+ canvas.setViewportTransform([zoom, 0, 0, zoom, offsetX, offsetY]);
340
375
  },
341
376
  cleanup() {
377
+ cancelAnimation();
342
378
  canvas.off("mouse:wheel", handleWheel);
343
379
  canvas.off("mouse:down", panHandlers.handleMouseDown);
344
380
  canvas.off("mouse:move", panHandlers.handleMouseMove);
@@ -351,6 +387,9 @@ function resetViewport(canvas) {
351
387
  canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
352
388
  }
353
389
 
390
+ // src/hooks/shared.ts
391
+ import { useMemo } from "react";
392
+
354
393
  // src/background.ts
355
394
  import { FabricImage, filters } from "fabric";
356
395
  function getBackgroundImage(canvas) {
@@ -481,7 +520,8 @@ function resizeImageUrl(url, options) {
481
520
  async function setBackgroundImage(canvas, url, options) {
482
521
  const prevContrast = options?.preserveContrast ? getBackgroundContrast(canvas) : void 0;
483
522
  let imageUrl = url;
484
- if (options !== void 0) {
523
+ const hasResizeOptions = options?.maxSize !== void 0 || options?.minSize !== void 0;
524
+ if (hasResizeOptions) {
485
525
  const result = await resizeImageUrl(url, options);
486
526
  imageUrl = result.url;
487
527
  }
@@ -499,29 +539,35 @@ function syncZoom(canvasRef, setZoom) {
499
539
  const canvas = canvasRef.current;
500
540
  if (canvas) setZoom(canvas.getZoom());
501
541
  }
502
- function createViewportActions(canvasRef, viewportRef, setZoom) {
503
- const resetViewport2 = () => {
504
- const canvas = canvasRef.current;
505
- if (!canvas) return;
506
- if (canvas.backgroundImage) {
507
- fitViewportToBackground(canvas);
508
- } else {
509
- resetViewport(canvas);
510
- }
511
- setZoom(canvas.getZoom());
512
- };
513
- const zoomIn = (step) => {
514
- viewportRef.current?.zoomIn(step);
515
- syncZoom(canvasRef, setZoom);
516
- };
517
- const zoomOut = (step) => {
518
- viewportRef.current?.zoomOut(step);
519
- syncZoom(canvasRef, setZoom);
520
- };
521
- const panToObject = (object, panOpts) => {
522
- viewportRef.current?.panToObject(object, panOpts);
523
- };
524
- return { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject };
542
+ function useViewportActions(canvasRef, viewportRef, setZoom) {
543
+ return useMemo(() => {
544
+ const resetViewport2 = () => {
545
+ const canvas = canvasRef.current;
546
+ if (!canvas) return;
547
+ if (canvas.backgroundImage) {
548
+ fitViewportToBackground(canvas);
549
+ } else {
550
+ resetViewport(canvas);
551
+ }
552
+ setZoom(canvas.getZoom());
553
+ };
554
+ const zoomIn = (step) => {
555
+ viewportRef.current?.zoomIn(step);
556
+ syncZoom(canvasRef, setZoom);
557
+ };
558
+ const zoomOut = (step) => {
559
+ viewportRef.current?.zoomOut(step);
560
+ syncZoom(canvasRef, setZoom);
561
+ };
562
+ const panToObject = (object, panOpts) => {
563
+ viewportRef.current?.panToObject(object, panOpts);
564
+ };
565
+ const zoomToFit = (object, fitOpts) => {
566
+ viewportRef.current?.zoomToFit(object, fitOpts);
567
+ syncZoom(canvasRef, setZoom);
568
+ };
569
+ return { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit };
570
+ }, []);
525
571
  }
526
572
  function resolveAlignmentEnabled(enableAlignment, alignmentProp) {
527
573
  if (enableAlignment !== void 0) return enableAlignment;
@@ -1410,6 +1456,11 @@ function enableDragToCreate(canvas, factory, options) {
1410
1456
  if (width < MIN_DRAG_SIZE && height < MIN_DRAG_SIZE) {
1411
1457
  canvas.requestRenderAll();
1412
1458
  previewRect = null;
1459
+ if (options?.clickFactory) {
1460
+ const obj2 = options.clickFactory(canvas, { x: startX, y: startY });
1461
+ restoreViewport(options?.viewport);
1462
+ options?.onCreated?.(obj2);
1463
+ }
1413
1464
  return;
1414
1465
  }
1415
1466
  const obj = factory(canvas, { startX, startY, width, height });
@@ -1583,7 +1634,7 @@ function createPolygonAtPoint(canvas, point, options) {
1583
1634
  canvas.requestRenderAll();
1584
1635
  return polygon;
1585
1636
  }
1586
- function createPolygonFromDrag(canvas, start, end, style) {
1637
+ function createPolygonFromDrag(canvas, start, end, options) {
1587
1638
  const width = Math.abs(end.x - start.x);
1588
1639
  const height = Math.abs(end.y - start.y);
1589
1640
  const left = Math.min(start.x, end.x) + width / 2;
@@ -1595,16 +1646,16 @@ function createPolygonFromDrag(canvas, start, end, style) {
1595
1646
  { x: width, y: height },
1596
1647
  { x: 0, y: height }
1597
1648
  ],
1598
- { ...DEFAULT_SHAPE_STYLE, left, top, ...style }
1649
+ { ...DEFAULT_SHAPE_STYLE, left, top, ...options }
1599
1650
  );
1600
1651
  canvas.add(polygon);
1601
1652
  canvas.requestRenderAll();
1602
1653
  return polygon;
1603
1654
  }
1604
- function createPolygonFromVertices(canvas, points, style) {
1655
+ function createPolygonFromVertices(canvas, points, options) {
1605
1656
  const polygon = new Polygon2(
1606
1657
  points.map((p) => ({ x: p.x, y: p.y })),
1607
- { ...DEFAULT_SHAPE_STYLE, ...style }
1658
+ { ...DEFAULT_SHAPE_STYLE, ...options }
1608
1659
  );
1609
1660
  canvas.add(polygon);
1610
1661
  canvas.requestRenderAll();
@@ -1728,14 +1779,14 @@ function enableDrawToCreate(canvas, options) {
1728
1779
  const finalize = () => {
1729
1780
  removePreviewElements();
1730
1781
  snapping.clearSnapResult();
1731
- const polygon = createPolygonFromVertices(canvas, points, options?.style);
1782
+ const obj = options?.factory ? options.factory(canvas, [...points]) : createPolygonFromVertices(canvas, points, options?.style);
1732
1783
  if (options?.data) {
1733
- polygon.data = options.data;
1784
+ obj.data = options.data;
1734
1785
  }
1735
1786
  canvas.selection = previousSelection;
1736
1787
  canvas.requestRenderAll();
1737
1788
  restoreViewport(options?.viewport);
1738
- options?.onCreated?.(polygon);
1789
+ options?.onCreated?.(obj);
1739
1790
  points.length = 0;
1740
1791
  };
1741
1792
  const handleMouseDown = (event) => {
@@ -2047,14 +2098,19 @@ function enableVertexEdit(canvas, polygon, options, onExit) {
2047
2098
  });
2048
2099
  canvas.discardActiveObject();
2049
2100
  canvas.requestRenderAll();
2050
- onExit?.();
2101
+ (options?.onExit ?? onExit)?.();
2051
2102
  }
2052
2103
  return cleanup;
2053
2104
  }
2054
2105
 
2055
2106
  // src/serialization.ts
2056
- import { Rect as Rect5 } from "fabric";
2107
+ import {
2108
+ FabricImage as FabricImage2,
2109
+ Rect as Rect5
2110
+ } from "fabric";
2057
2111
  var strokeBaseMap = /* @__PURE__ */ new WeakMap();
2112
+ var borderRadiusBaseMap = /* @__PURE__ */ new WeakMap();
2113
+ var DEFAULT_VIEW_BORDER_RADIUS = 4;
2058
2114
  function enableScaledStrokes(canvas) {
2059
2115
  function applyScaledStrokes() {
2060
2116
  const zoom = canvas.getZoom();
@@ -2079,6 +2135,29 @@ function enableScaledStrokes(canvas) {
2079
2135
  });
2080
2136
  };
2081
2137
  }
2138
+ function enableScaledBorderRadius(canvas, options) {
2139
+ const radius = options?.radius ?? DEFAULT_VIEW_BORDER_RADIUS;
2140
+ function applyScaledBorderRadius() {
2141
+ canvas.forEachObject((obj) => {
2142
+ if (!(obj instanceof Rect5)) return;
2143
+ if (!borderRadiusBaseMap.has(obj)) return;
2144
+ const rx = radius / (obj.scaleX ?? 1);
2145
+ const ry = radius / (obj.scaleY ?? 1);
2146
+ obj.set({ rx, ry });
2147
+ });
2148
+ }
2149
+ canvas.on("before:render", applyScaledBorderRadius);
2150
+ return () => {
2151
+ canvas.off("before:render", applyScaledBorderRadius);
2152
+ canvas.forEachObject((obj) => {
2153
+ if (!(obj instanceof Rect5)) return;
2154
+ const base = borderRadiusBaseMap.get(obj);
2155
+ if (base !== void 0) {
2156
+ obj.set({ rx: base.rx, ry: base.ry });
2157
+ }
2158
+ });
2159
+ };
2160
+ }
2082
2161
  function getBaseStrokeWidth(obj) {
2083
2162
  return strokeBaseMap.get(obj) ?? obj.strokeWidth ?? 0;
2084
2163
  }
@@ -2104,15 +2183,42 @@ function serializeCanvas(canvas, options) {
2104
2183
  obj.strokeWidth = base;
2105
2184
  }
2106
2185
  });
2186
+ const appliedRadii = /* @__PURE__ */ new Map();
2187
+ canvas.forEachObject((obj) => {
2188
+ if (!(obj instanceof Rect5)) return;
2189
+ const base = borderRadiusBaseMap.get(obj);
2190
+ if (base !== void 0) {
2191
+ appliedRadii.set(obj, { rx: obj.rx ?? 0, ry: obj.ry ?? 0 });
2192
+ obj.set({ rx: base.rx, ry: base.ry });
2193
+ }
2194
+ });
2107
2195
  const json = canvas.toObject(properties);
2108
2196
  delete json.backgroundColor;
2109
2197
  scaledWidths.forEach((scaled, obj) => {
2110
2198
  obj.strokeWidth = scaled;
2111
2199
  });
2200
+ appliedRadii.forEach((radii, obj) => {
2201
+ obj.set({ rx: radii.rx, ry: radii.ry });
2202
+ });
2112
2203
  return json;
2113
2204
  }
2114
2205
  async function loadCanvas(canvas, json, options) {
2115
2206
  await canvas.loadFromJSON(json);
2207
+ canvas.backgroundColor = "";
2208
+ delete canvas.backgroundFilters;
2209
+ const bg = canvas.backgroundImage;
2210
+ if (bg instanceof FabricImage2) {
2211
+ if (bg.originX !== "center" || bg.originY !== "center") {
2212
+ const center = bg.getCenterPoint();
2213
+ bg.set({
2214
+ originX: "center",
2215
+ originY: "center",
2216
+ left: center.x,
2217
+ top: center.y
2218
+ });
2219
+ bg.setCoords();
2220
+ }
2221
+ }
2116
2222
  if (options?.filter) {
2117
2223
  const toRemove = [];
2118
2224
  canvas.forEachObject((obj) => {
@@ -2132,15 +2238,107 @@ async function loadCanvas(canvas, json, options) {
2132
2238
  obj.setCoords();
2133
2239
  });
2134
2240
  canvas.forEachObject((obj) => {
2241
+ const data = obj.data;
2242
+ if (data?.strokeWidthBase !== void 0) {
2243
+ delete data.strokeWidthBase;
2244
+ }
2135
2245
  obj.set(DEFAULT_CONTROL_STYLE);
2136
2246
  if (obj.shapeType === "circle" && obj instanceof Rect5) {
2137
2247
  restoreCircleConstraints(obj);
2138
2248
  }
2249
+ const borderRadius = options?.borderRadius ?? DEFAULT_VIEW_BORDER_RADIUS;
2250
+ if (borderRadius !== false && obj instanceof Rect5 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2251
+ borderRadiusBaseMap.set(obj, { rx: obj.rx ?? 0, ry: obj.ry ?? 0 });
2252
+ const rx = borderRadius / (obj.scaleX ?? 1);
2253
+ const ry = borderRadius / (obj.scaleY ?? 1);
2254
+ obj.set({ rx, ry });
2255
+ }
2139
2256
  });
2140
2257
  canvas.requestRenderAll();
2141
2258
  return canvas.getObjects();
2142
2259
  }
2143
2260
 
2261
+ // src/history.ts
2262
+ function createHistoryTracker(canvas, options) {
2263
+ const maxSize = options?.maxSize ?? 50;
2264
+ const debounceMs = options?.debounce ?? 300;
2265
+ const snapshots = [];
2266
+ let currentIndex = -1;
2267
+ let isUndoRedo = false;
2268
+ let debounceTimer = null;
2269
+ function captureSnapshot() {
2270
+ if (isUndoRedo) return;
2271
+ const snapshot = serializeCanvas(canvas);
2272
+ if (currentIndex < snapshots.length - 1) {
2273
+ snapshots.length = currentIndex + 1;
2274
+ }
2275
+ snapshots.push(snapshot);
2276
+ if (snapshots.length > maxSize) {
2277
+ snapshots.shift();
2278
+ }
2279
+ currentIndex = snapshots.length - 1;
2280
+ }
2281
+ function debouncedCapture() {
2282
+ if (debounceTimer !== null) {
2283
+ clearTimeout(debounceTimer);
2284
+ }
2285
+ debounceTimer = setTimeout(() => {
2286
+ debounceTimer = null;
2287
+ captureSnapshot();
2288
+ }, debounceMs);
2289
+ }
2290
+ const onChange = () => {
2291
+ if (!isUndoRedo) debouncedCapture();
2292
+ };
2293
+ canvas.on("object:added", onChange);
2294
+ canvas.on("object:modified", onChange);
2295
+ canvas.on("object:removed", onChange);
2296
+ async function loadSnapshot(index) {
2297
+ if (index < 0 || index >= snapshots.length) return;
2298
+ isUndoRedo = true;
2299
+ currentIndex = index;
2300
+ try {
2301
+ await loadCanvas(canvas, snapshots[index]);
2302
+ } finally {
2303
+ isUndoRedo = false;
2304
+ }
2305
+ }
2306
+ return {
2307
+ async undo() {
2308
+ if (currentIndex <= 0) return;
2309
+ await loadSnapshot(currentIndex - 1);
2310
+ },
2311
+ async redo() {
2312
+ if (currentIndex >= snapshots.length - 1) return;
2313
+ await loadSnapshot(currentIndex + 1);
2314
+ },
2315
+ canUndo() {
2316
+ return currentIndex > 0;
2317
+ },
2318
+ canRedo() {
2319
+ return currentIndex < snapshots.length - 1;
2320
+ },
2321
+ pushSnapshot() {
2322
+ if (debounceTimer !== null) {
2323
+ clearTimeout(debounceTimer);
2324
+ debounceTimer = null;
2325
+ }
2326
+ captureSnapshot();
2327
+ },
2328
+ cleanup() {
2329
+ if (debounceTimer !== null) {
2330
+ clearTimeout(debounceTimer);
2331
+ debounceTimer = null;
2332
+ }
2333
+ canvas.off("object:added", onChange);
2334
+ canvas.off("object:modified", onChange);
2335
+ canvas.off("object:removed", onChange);
2336
+ snapshots.length = 0;
2337
+ currentIndex = -1;
2338
+ }
2339
+ };
2340
+ }
2341
+
2144
2342
  // src/hooks/useEditCanvas.ts
2145
2343
  function useEditCanvas(options) {
2146
2344
  const canvasRef = useRef2(null);
@@ -2150,11 +2348,19 @@ function useEditCanvas(options) {
2150
2348
  const modeCleanupRef = useRef2(null);
2151
2349
  const vertexEditCleanupRef = useRef2(null);
2152
2350
  const keyboardCleanupRef = useRef2(null);
2351
+ const historyRef = useRef2(null);
2352
+ const optionsRef = useRef2(options);
2353
+ optionsRef.current = options;
2354
+ const savedSelectabilityRef = useRef2(
2355
+ /* @__PURE__ */ new WeakMap()
2356
+ );
2153
2357
  const [zoom, setZoom] = useState(1);
2154
2358
  const [selected, setSelected] = useState([]);
2155
2359
  const [viewportMode, setViewportModeState] = useState("select");
2156
2360
  const [isEditingVertices, setIsEditingVertices] = useState(false);
2157
2361
  const [isDirty, setIsDirty] = useState(false);
2362
+ const [canUndo, setCanUndo] = useState(false);
2363
+ const [canRedo, setCanRedo] = useState(false);
2158
2364
  const setMode = useCallback((setup) => {
2159
2365
  vertexEditCleanupRef.current?.();
2160
2366
  vertexEditCleanupRef.current = null;
@@ -2166,10 +2372,17 @@ function useEditCanvas(options) {
2166
2372
  if (setup === null) {
2167
2373
  canvas.selection = true;
2168
2374
  canvas.forEachObject((obj) => {
2169
- obj.selectable = true;
2170
- obj.evented = true;
2375
+ const saved = savedSelectabilityRef.current.get(obj);
2376
+ obj.selectable = saved?.selectable ?? true;
2377
+ obj.evented = saved?.evented ?? true;
2171
2378
  });
2172
2379
  } else {
2380
+ canvas.forEachObject((obj) => {
2381
+ savedSelectabilityRef.current.set(obj, {
2382
+ selectable: obj.selectable,
2383
+ evented: obj.evented
2384
+ });
2385
+ });
2173
2386
  canvas.selection = false;
2174
2387
  canvas.forEachObject((obj) => {
2175
2388
  obj.selectable = false;
@@ -2184,33 +2397,38 @@ function useEditCanvas(options) {
2184
2397
  const onReady = useCallback(
2185
2398
  (canvas) => {
2186
2399
  canvasRef.current = canvas;
2187
- if (options?.scaledStrokes !== false) {
2400
+ const opts = optionsRef.current;
2401
+ if (opts?.scaledStrokes !== false) {
2188
2402
  enableScaledStrokes(canvas);
2189
2403
  }
2190
- if (options?.keyboardShortcuts !== false) {
2404
+ if (opts?.borderRadius !== false) {
2405
+ const borderRadiusOpts = typeof opts?.borderRadius === "number" ? { radius: opts.borderRadius } : void 0;
2406
+ enableScaledBorderRadius(canvas, borderRadiusOpts);
2407
+ }
2408
+ if (opts?.keyboardShortcuts !== false) {
2191
2409
  keyboardCleanupRef.current = enableKeyboardShortcuts(canvas);
2192
2410
  }
2193
- setCanvasAlignmentEnabled(canvas, options?.enableAlignment);
2194
- if (options?.panAndZoom !== false) {
2411
+ setCanvasAlignmentEnabled(canvas, opts?.enableAlignment);
2412
+ if (opts?.panAndZoom !== false) {
2195
2413
  viewportRef.current = enablePanAndZoom(
2196
2414
  canvas,
2197
- typeof options?.panAndZoom === "object" ? options.panAndZoom : void 0
2415
+ typeof opts?.panAndZoom === "object" ? opts.panAndZoom : void 0
2198
2416
  );
2199
2417
  }
2200
2418
  const alignmentEnabled = resolveAlignmentEnabled(
2201
- options?.enableAlignment,
2202
- options?.alignment
2419
+ opts?.enableAlignment,
2420
+ opts?.alignment
2203
2421
  );
2204
2422
  if (alignmentEnabled) {
2205
2423
  alignmentCleanupRef.current = enableObjectAlignment(
2206
2424
  canvas,
2207
- typeof options?.alignment === "object" ? options.alignment : void 0
2425
+ typeof opts?.alignment === "object" ? opts.alignment : void 0
2208
2426
  );
2209
2427
  }
2210
- if (options?.rotationSnap !== false) {
2428
+ if (opts?.rotationSnap !== false) {
2211
2429
  rotationSnapCleanupRef.current = enableRotationSnap(
2212
2430
  canvas,
2213
- typeof options?.rotationSnap === "object" ? options.rotationSnap : void 0
2431
+ typeof opts?.rotationSnap === "object" ? opts.rotationSnap : void 0
2214
2432
  );
2215
2433
  }
2216
2434
  canvas.on("mouse:wheel", () => {
@@ -2225,41 +2443,60 @@ function useEditCanvas(options) {
2225
2443
  canvas.on("selection:cleared", () => {
2226
2444
  setSelected([]);
2227
2445
  });
2228
- if (options?.trackChanges) {
2446
+ if (opts?.trackChanges) {
2229
2447
  canvas.on("object:added", () => setIsDirty(true));
2230
2448
  canvas.on("object:removed", () => setIsDirty(true));
2231
2449
  canvas.on("object:modified", () => setIsDirty(true));
2232
2450
  }
2233
- if (options?.vertexEdit !== false) {
2234
- const vertexOpts = typeof options?.vertexEdit === "object" ? options.vertexEdit : void 0;
2451
+ if (opts?.history) {
2452
+ const syncHistoryState = () => {
2453
+ const h = historyRef.current;
2454
+ if (!h) return;
2455
+ setTimeout(() => {
2456
+ setCanUndo(h.canUndo());
2457
+ setCanRedo(h.canRedo());
2458
+ }, 350);
2459
+ };
2460
+ canvas.on("object:added", syncHistoryState);
2461
+ canvas.on("object:removed", syncHistoryState);
2462
+ canvas.on("object:modified", syncHistoryState);
2463
+ }
2464
+ if (opts?.vertexEdit !== false) {
2465
+ const vertexOpts = typeof opts?.vertexEdit === "object" ? opts.vertexEdit : void 0;
2235
2466
  canvas.on("mouse:dblclick", (e) => {
2236
2467
  if (e.target && e.target instanceof Polygon4) {
2237
2468
  vertexEditCleanupRef.current?.();
2238
- vertexEditCleanupRef.current = enableVertexEdit(
2239
- canvas,
2240
- e.target,
2241
- vertexOpts,
2242
- () => {
2469
+ vertexEditCleanupRef.current = enableVertexEdit(canvas, e.target, {
2470
+ ...vertexOpts,
2471
+ onExit: () => {
2243
2472
  vertexEditCleanupRef.current = null;
2244
2473
  setIsEditingVertices(false);
2245
2474
  }
2246
- );
2475
+ });
2247
2476
  setIsEditingVertices(true);
2248
2477
  }
2249
2478
  });
2250
2479
  }
2251
- const onReadyResult = options?.onReady?.(canvas);
2252
- if (options?.autoFitToBackground !== false) {
2480
+ if (opts?.history) {
2481
+ const historyOpts = typeof opts.history === "object" ? opts.history : void 0;
2482
+ historyRef.current = createHistoryTracker(canvas, historyOpts);
2483
+ }
2484
+ const onReadyResult = opts?.onReady?.(canvas);
2485
+ if (opts?.autoFitToBackground !== false) {
2253
2486
  Promise.resolve(onReadyResult).then(() => {
2254
2487
  if (canvas.backgroundImage) {
2255
2488
  fitViewportToBackground(canvas);
2256
2489
  syncZoom(canvasRef, setZoom);
2257
2490
  }
2491
+ historyRef.current?.pushSnapshot();
2492
+ });
2493
+ } else {
2494
+ Promise.resolve(onReadyResult).then(() => {
2495
+ historyRef.current?.pushSnapshot();
2258
2496
  });
2259
2497
  }
2260
2498
  },
2261
2499
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2262
- // eslint-disable-next-line react-hooks/exhaustive-deps
2263
2500
  []
2264
2501
  );
2265
2502
  useEffect2(() => {
@@ -2284,24 +2521,20 @@ function useEditCanvas(options) {
2284
2521
  viewportRef.current?.setMode(mode);
2285
2522
  setViewportModeState(mode);
2286
2523
  }, []);
2287
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2288
- canvasRef,
2289
- viewportRef,
2290
- setZoom
2291
- );
2524
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2292
2525
  const setBackground = useCallback(
2293
2526
  async (url, bgOpts) => {
2294
2527
  const canvas = canvasRef.current;
2295
2528
  if (!canvas) throw new Error("Canvas not ready");
2296
- const resizeOpts = options?.backgroundResize !== false ? typeof options?.backgroundResize === "object" ? { ...options.backgroundResize, ...bgOpts } : { ...bgOpts } : bgOpts?.preserveContrast ? { preserveContrast: true } : void 0;
2529
+ const opts = optionsRef.current;
2530
+ const resizeOpts = opts?.backgroundResize !== false ? typeof opts?.backgroundResize === "object" ? { ...opts.backgroundResize, ...bgOpts } : { ...bgOpts } : bgOpts?.preserveContrast ? { preserveContrast: true } : void 0;
2297
2531
  const img = await setBackgroundImage(canvas, url, resizeOpts);
2298
- if (options?.autoFitToBackground !== false) {
2532
+ if (opts?.autoFitToBackground !== false) {
2299
2533
  fitViewportToBackground(canvas);
2300
2534
  syncZoom(canvasRef, setZoom);
2301
2535
  }
2302
2536
  return img;
2303
2537
  },
2304
- // eslint-disable-next-line react-hooks/exhaustive-deps
2305
2538
  []
2306
2539
  );
2307
2540
  return {
@@ -2326,7 +2559,9 @@ function useEditCanvas(options) {
2326
2559
  /** Zoom out from the canvas center. Default step: 0.2. */
2327
2560
  zoomOut,
2328
2561
  /** Pan the viewport to center on a specific object. */
2329
- panToObject
2562
+ panToObject,
2563
+ /** Zoom and pan to fit a specific object in the viewport. */
2564
+ zoomToFit
2330
2565
  },
2331
2566
  /** Whether vertex edit mode is currently active (reactive). */
2332
2567
  isEditingVertices,
@@ -2358,38 +2593,57 @@ function useEditCanvas(options) {
2358
2593
  /** Whether the canvas has been modified since the last `resetDirty()` call. Requires `trackChanges: true`. */
2359
2594
  isDirty,
2360
2595
  /** Reset the dirty flag (e.g., after a successful save). */
2361
- resetDirty: useCallback(() => setIsDirty(false), [])
2596
+ resetDirty: useCallback(() => setIsDirty(false), []),
2597
+ /** Undo the last change. Requires `history: true`. */
2598
+ undo: useCallback(async () => {
2599
+ const h = historyRef.current;
2600
+ if (!h) return;
2601
+ await h.undo();
2602
+ setCanUndo(h.canUndo());
2603
+ setCanRedo(h.canRedo());
2604
+ }, []),
2605
+ /** Redo a previously undone change. Requires `history: true`. */
2606
+ redo: useCallback(async () => {
2607
+ const h = historyRef.current;
2608
+ if (!h) return;
2609
+ await h.redo();
2610
+ setCanUndo(h.canUndo());
2611
+ setCanRedo(h.canRedo());
2612
+ }, []),
2613
+ /** Whether an undo operation is available (reactive). Requires `history: true`. */
2614
+ canUndo,
2615
+ /** Whether a redo operation is available (reactive). Requires `history: true`. */
2616
+ canRedo
2362
2617
  };
2363
2618
  }
2364
2619
 
2365
2620
  // src/hooks/useViewCanvas.ts
2366
2621
  import { useCallback as useCallback2, useRef as useRef3, useState as useState2 } from "react";
2367
- import { Rect as Rect6 } from "fabric";
2368
- var VIEW_BORDER_RADIUS = 4;
2369
2622
  function lockCanvas(canvas) {
2370
2623
  canvas.selection = false;
2371
2624
  canvas.forEachObject((obj) => {
2372
2625
  obj.selectable = false;
2373
- obj.evented = false;
2374
- if (obj instanceof Rect6 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2375
- const rx = VIEW_BORDER_RADIUS / (obj.scaleX ?? 1);
2376
- const ry = VIEW_BORDER_RADIUS / (obj.scaleY ?? 1);
2377
- obj.set({ rx, ry });
2378
- }
2379
2626
  });
2380
2627
  }
2381
2628
  function useViewCanvas(options) {
2382
2629
  const canvasRef = useRef3(null);
2383
2630
  const viewportRef = useRef3(null);
2631
+ const optionsRef = useRef3(options);
2632
+ optionsRef.current = options;
2384
2633
  const [zoom, setZoom] = useState2(1);
2385
2634
  const onReady = useCallback2(
2386
2635
  (canvas) => {
2387
2636
  canvasRef.current = canvas;
2388
- if (options?.scaledStrokes !== false) {
2637
+ const opts = optionsRef.current;
2638
+ if (opts?.scaledStrokes !== false) {
2389
2639
  enableScaledStrokes(canvas);
2390
2640
  }
2391
- if (options?.panAndZoom !== false) {
2392
- const panAndZoomOpts = typeof options?.panAndZoom === "object" ? options.panAndZoom : {};
2641
+ if (opts?.borderRadius !== false) {
2642
+ const borderRadiusOpts = typeof opts?.borderRadius === "number" ? { radius: opts.borderRadius } : void 0;
2643
+ enableScaledBorderRadius(canvas, borderRadiusOpts);
2644
+ }
2645
+ if (opts?.panAndZoom !== false) {
2646
+ const panAndZoomOpts = typeof opts?.panAndZoom === "object" ? opts.panAndZoom : {};
2393
2647
  viewportRef.current = enablePanAndZoom(canvas, {
2394
2648
  ...panAndZoomOpts,
2395
2649
  initialMode: "pan"
@@ -2402,8 +2656,8 @@ function useViewCanvas(options) {
2402
2656
  canvas.on("mouse:wheel", () => {
2403
2657
  setZoom(canvas.getZoom());
2404
2658
  });
2405
- const onReadyResult = options?.onReady?.(canvas);
2406
- if (options?.autoFitToBackground !== false) {
2659
+ const onReadyResult = opts?.onReady?.(canvas);
2660
+ if (opts?.autoFitToBackground !== false) {
2407
2661
  Promise.resolve(onReadyResult).then(() => {
2408
2662
  if (canvas.backgroundImage) {
2409
2663
  fitViewportToBackground(canvas);
@@ -2413,14 +2667,9 @@ function useViewCanvas(options) {
2413
2667
  }
2414
2668
  },
2415
2669
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2416
- // eslint-disable-next-line react-hooks/exhaustive-deps
2417
2670
  []
2418
2671
  );
2419
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2420
- canvasRef,
2421
- viewportRef,
2422
- setZoom
2423
- );
2672
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2424
2673
  const findObject = (id) => {
2425
2674
  const c = canvasRef.current;
2426
2675
  if (!c) return void 0;
@@ -2484,7 +2733,9 @@ function useViewCanvas(options) {
2484
2733
  /** Zoom out from the canvas center. Default step: 0.2. */
2485
2734
  zoomOut,
2486
2735
  /** Pan the viewport to center on a specific object. */
2487
- panToObject
2736
+ panToObject,
2737
+ /** Zoom and pan to fit a specific object in the viewport. */
2738
+ zoomToFit
2488
2739
  },
2489
2740
  /** Update a single object's visual style by its `data.id`. */
2490
2741
  setObjectStyle,
@@ -2657,36 +2908,41 @@ function useObjectOverlay(canvasRef, object, options) {
2657
2908
  const screenCoords = util4.transformPoint(center, vt);
2658
2909
  const screenWidth = actualWidth * zoom;
2659
2910
  const screenHeight = actualHeight * zoom;
2660
- el.style.left = `${screenCoords.x - screenWidth / 2}px`;
2661
- el.style.top = `${screenCoords.y - screenHeight / 2}px`;
2662
- el.style.width = `${screenWidth}px`;
2663
- el.style.height = `${screenHeight}px`;
2664
2911
  const angle = object.angle ?? 0;
2665
- el.style.rotate = angle !== 0 ? `${angle}deg` : "";
2666
2912
  const opts = optionsRef.current;
2667
- if (opts?.autoScaleContent) {
2668
- const contentScale = Math.min(screenWidth, screenHeight) / 100;
2669
- const clampedScale = Math.max(0.1, Math.min(contentScale, 2));
2670
- el.style.setProperty("--overlay-scale", String(clampedScale));
2671
- if (opts.textSelector) {
2672
- const textMinScale = opts.textMinScale ?? 0.5;
2913
+ if (opts?.autoScaleContent !== false) {
2914
+ el.style.left = `${screenCoords.x - actualWidth / 2}px`;
2915
+ el.style.top = `${screenCoords.y - actualHeight / 2}px`;
2916
+ el.style.width = `${actualWidth}px`;
2917
+ el.style.height = `${actualHeight}px`;
2918
+ el.style.transformOrigin = "center center";
2919
+ el.style.transform = angle !== 0 ? `scale(${zoom}) rotate(${angle}deg)` : `scale(${zoom})`;
2920
+ el.style.rotate = "";
2921
+ el.style.setProperty("--overlay-scale", String(zoom));
2922
+ if (opts?.textSelector) {
2923
+ const textMinScale = opts?.textMinScale ?? 0.5;
2673
2924
  const textEls = el.querySelectorAll(opts.textSelector);
2674
- const display = clampedScale < textMinScale ? "none" : "";
2925
+ const display = zoom < textMinScale ? "none" : "";
2675
2926
  textEls.forEach((t) => {
2676
2927
  t.style.display = display;
2677
2928
  });
2678
2929
  }
2930
+ } else {
2931
+ el.style.left = `${screenCoords.x - screenWidth / 2}px`;
2932
+ el.style.top = `${screenCoords.y - screenHeight / 2}px`;
2933
+ el.style.width = `${screenWidth}px`;
2934
+ el.style.height = `${screenHeight}px`;
2935
+ el.style.transform = "";
2936
+ el.style.rotate = angle !== 0 ? `${angle}deg` : "";
2679
2937
  }
2680
2938
  }
2681
2939
  update();
2682
2940
  canvas.on("after:render", update);
2683
- canvas.on("mouse:wheel", update);
2684
2941
  object.on("moving", update);
2685
2942
  object.on("scaling", update);
2686
2943
  object.on("rotating", update);
2687
2944
  return () => {
2688
2945
  canvas.off("after:render", update);
2689
- canvas.off("mouse:wheel", update);
2690
2946
  object.off("moving", update);
2691
2947
  object.off("scaling", update);
2692
2948
  object.off("rotating", update);
@@ -2699,8 +2955,8 @@ function useObjectOverlay(canvasRef, object, options) {
2699
2955
  import {
2700
2956
  Canvas as Canvas2,
2701
2957
  FabricObject as FabricObject5,
2702
- FabricImage as FabricImage2,
2703
- Rect as Rect7,
2958
+ FabricImage as FabricImage3,
2959
+ Rect as Rect6,
2704
2960
  Polygon as Polygon5,
2705
2961
  Point as Point9,
2706
2962
  util as util5
@@ -2714,13 +2970,14 @@ export {
2714
2970
  DEFAULT_GUIDELINE_SHAPE_STYLE,
2715
2971
  DEFAULT_SHAPE_STYLE,
2716
2972
  Canvas2 as FabricCanvas,
2717
- FabricImage2 as FabricImage,
2973
+ FabricImage3 as FabricImage,
2718
2974
  FabricObject5 as FabricObject,
2719
2975
  Point9 as Point,
2720
2976
  Polygon5 as Polygon,
2721
- Rect7 as Rect,
2977
+ Rect6 as Rect,
2722
2978
  createCircle,
2723
2979
  createCircleAtPoint,
2980
+ createHistoryTracker,
2724
2981
  createPolygon,
2725
2982
  createPolygonAtPoint,
2726
2983
  createPolygonFromDrag,
@@ -2738,6 +2995,7 @@ export {
2738
2995
  enableObjectAlignment,
2739
2996
  enablePanAndZoom,
2740
2997
  enableRotationSnap,
2998
+ enableScaledBorderRadius,
2741
2999
  enableScaledStrokes,
2742
3000
  enableVertexEdit,
2743
3001
  fitViewportToBackground,