@bwp-web/canvas 0.5.1 → 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.
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;
@@ -1588,7 +1634,7 @@ function createPolygonAtPoint(canvas, point, options) {
1588
1634
  canvas.requestRenderAll();
1589
1635
  return polygon;
1590
1636
  }
1591
- function createPolygonFromDrag(canvas, start, end, style) {
1637
+ function createPolygonFromDrag(canvas, start, end, options) {
1592
1638
  const width = Math.abs(end.x - start.x);
1593
1639
  const height = Math.abs(end.y - start.y);
1594
1640
  const left = Math.min(start.x, end.x) + width / 2;
@@ -1600,16 +1646,16 @@ function createPolygonFromDrag(canvas, start, end, style) {
1600
1646
  { x: width, y: height },
1601
1647
  { x: 0, y: height }
1602
1648
  ],
1603
- { ...DEFAULT_SHAPE_STYLE, left, top, ...style }
1649
+ { ...DEFAULT_SHAPE_STYLE, left, top, ...options }
1604
1650
  );
1605
1651
  canvas.add(polygon);
1606
1652
  canvas.requestRenderAll();
1607
1653
  return polygon;
1608
1654
  }
1609
- function createPolygonFromVertices(canvas, points, style) {
1655
+ function createPolygonFromVertices(canvas, points, options) {
1610
1656
  const polygon = new Polygon2(
1611
1657
  points.map((p) => ({ x: p.x, y: p.y })),
1612
- { ...DEFAULT_SHAPE_STYLE, ...style }
1658
+ { ...DEFAULT_SHAPE_STYLE, ...options }
1613
1659
  );
1614
1660
  canvas.add(polygon);
1615
1661
  canvas.requestRenderAll();
@@ -1733,14 +1779,14 @@ function enableDrawToCreate(canvas, options) {
1733
1779
  const finalize = () => {
1734
1780
  removePreviewElements();
1735
1781
  snapping.clearSnapResult();
1736
- const polygon = createPolygonFromVertices(canvas, points, options?.style);
1782
+ const obj = options?.factory ? options.factory(canvas, [...points]) : createPolygonFromVertices(canvas, points, options?.style);
1737
1783
  if (options?.data) {
1738
- polygon.data = options.data;
1784
+ obj.data = options.data;
1739
1785
  }
1740
1786
  canvas.selection = previousSelection;
1741
1787
  canvas.requestRenderAll();
1742
1788
  restoreViewport(options?.viewport);
1743
- options?.onCreated?.(polygon);
1789
+ options?.onCreated?.(obj);
1744
1790
  points.length = 0;
1745
1791
  };
1746
1792
  const handleMouseDown = (event) => {
@@ -2052,16 +2098,19 @@ function enableVertexEdit(canvas, polygon, options, onExit) {
2052
2098
  });
2053
2099
  canvas.discardActiveObject();
2054
2100
  canvas.requestRenderAll();
2055
- onExit?.();
2101
+ (options?.onExit ?? onExit)?.();
2056
2102
  }
2057
2103
  return cleanup;
2058
2104
  }
2059
2105
 
2060
2106
  // src/serialization.ts
2061
- import { Rect as Rect5 } from "fabric";
2107
+ import {
2108
+ FabricImage as FabricImage2,
2109
+ Rect as Rect5
2110
+ } from "fabric";
2062
2111
  var strokeBaseMap = /* @__PURE__ */ new WeakMap();
2063
2112
  var borderRadiusBaseMap = /* @__PURE__ */ new WeakMap();
2064
- var VIEW_BORDER_RADIUS = 4;
2113
+ var DEFAULT_VIEW_BORDER_RADIUS = 4;
2065
2114
  function enableScaledStrokes(canvas) {
2066
2115
  function applyScaledStrokes() {
2067
2116
  const zoom = canvas.getZoom();
@@ -2086,13 +2135,14 @@ function enableScaledStrokes(canvas) {
2086
2135
  });
2087
2136
  };
2088
2137
  }
2089
- function enableScaledBorderRadius(canvas) {
2138
+ function enableScaledBorderRadius(canvas, options) {
2139
+ const radius = options?.radius ?? DEFAULT_VIEW_BORDER_RADIUS;
2090
2140
  function applyScaledBorderRadius() {
2091
2141
  canvas.forEachObject((obj) => {
2092
2142
  if (!(obj instanceof Rect5)) return;
2093
2143
  if (!borderRadiusBaseMap.has(obj)) return;
2094
- const rx = VIEW_BORDER_RADIUS / (obj.scaleX ?? 1);
2095
- const ry = VIEW_BORDER_RADIUS / (obj.scaleY ?? 1);
2144
+ const rx = radius / (obj.scaleX ?? 1);
2145
+ const ry = radius / (obj.scaleY ?? 1);
2096
2146
  obj.set({ rx, ry });
2097
2147
  });
2098
2148
  }
@@ -2155,6 +2205,20 @@ function serializeCanvas(canvas, options) {
2155
2205
  async function loadCanvas(canvas, json, options) {
2156
2206
  await canvas.loadFromJSON(json);
2157
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
+ }
2158
2222
  if (options?.filter) {
2159
2223
  const toRemove = [];
2160
2224
  canvas.forEachObject((obj) => {
@@ -2174,14 +2238,19 @@ async function loadCanvas(canvas, json, options) {
2174
2238
  obj.setCoords();
2175
2239
  });
2176
2240
  canvas.forEachObject((obj) => {
2241
+ const data = obj.data;
2242
+ if (data?.strokeWidthBase !== void 0) {
2243
+ delete data.strokeWidthBase;
2244
+ }
2177
2245
  obj.set(DEFAULT_CONTROL_STYLE);
2178
2246
  if (obj.shapeType === "circle" && obj instanceof Rect5) {
2179
2247
  restoreCircleConstraints(obj);
2180
2248
  }
2181
- if (obj instanceof Rect5 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2249
+ const borderRadius = options?.borderRadius ?? DEFAULT_VIEW_BORDER_RADIUS;
2250
+ if (borderRadius !== false && obj instanceof Rect5 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2182
2251
  borderRadiusBaseMap.set(obj, { rx: obj.rx ?? 0, ry: obj.ry ?? 0 });
2183
- const rx = VIEW_BORDER_RADIUS / (obj.scaleX ?? 1);
2184
- const ry = VIEW_BORDER_RADIUS / (obj.scaleY ?? 1);
2252
+ const rx = borderRadius / (obj.scaleX ?? 1);
2253
+ const ry = borderRadius / (obj.scaleY ?? 1);
2185
2254
  obj.set({ rx, ry });
2186
2255
  }
2187
2256
  });
@@ -2189,6 +2258,87 @@ async function loadCanvas(canvas, json, options) {
2189
2258
  return canvas.getObjects();
2190
2259
  }
2191
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
+
2192
2342
  // src/hooks/useEditCanvas.ts
2193
2343
  function useEditCanvas(options) {
2194
2344
  const canvasRef = useRef2(null);
@@ -2198,11 +2348,19 @@ function useEditCanvas(options) {
2198
2348
  const modeCleanupRef = useRef2(null);
2199
2349
  const vertexEditCleanupRef = useRef2(null);
2200
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
+ );
2201
2357
  const [zoom, setZoom] = useState(1);
2202
2358
  const [selected, setSelected] = useState([]);
2203
2359
  const [viewportMode, setViewportModeState] = useState("select");
2204
2360
  const [isEditingVertices, setIsEditingVertices] = useState(false);
2205
2361
  const [isDirty, setIsDirty] = useState(false);
2362
+ const [canUndo, setCanUndo] = useState(false);
2363
+ const [canRedo, setCanRedo] = useState(false);
2206
2364
  const setMode = useCallback((setup) => {
2207
2365
  vertexEditCleanupRef.current?.();
2208
2366
  vertexEditCleanupRef.current = null;
@@ -2214,10 +2372,17 @@ function useEditCanvas(options) {
2214
2372
  if (setup === null) {
2215
2373
  canvas.selection = true;
2216
2374
  canvas.forEachObject((obj) => {
2217
- obj.selectable = true;
2218
- obj.evented = true;
2375
+ const saved = savedSelectabilityRef.current.get(obj);
2376
+ obj.selectable = saved?.selectable ?? true;
2377
+ obj.evented = saved?.evented ?? true;
2219
2378
  });
2220
2379
  } else {
2380
+ canvas.forEachObject((obj) => {
2381
+ savedSelectabilityRef.current.set(obj, {
2382
+ selectable: obj.selectable,
2383
+ evented: obj.evented
2384
+ });
2385
+ });
2221
2386
  canvas.selection = false;
2222
2387
  canvas.forEachObject((obj) => {
2223
2388
  obj.selectable = false;
@@ -2232,34 +2397,38 @@ function useEditCanvas(options) {
2232
2397
  const onReady = useCallback(
2233
2398
  (canvas) => {
2234
2399
  canvasRef.current = canvas;
2235
- if (options?.scaledStrokes !== false) {
2400
+ const opts = optionsRef.current;
2401
+ if (opts?.scaledStrokes !== false) {
2236
2402
  enableScaledStrokes(canvas);
2237
2403
  }
2238
- enableScaledBorderRadius(canvas);
2239
- 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) {
2240
2409
  keyboardCleanupRef.current = enableKeyboardShortcuts(canvas);
2241
2410
  }
2242
- setCanvasAlignmentEnabled(canvas, options?.enableAlignment);
2243
- if (options?.panAndZoom !== false) {
2411
+ setCanvasAlignmentEnabled(canvas, opts?.enableAlignment);
2412
+ if (opts?.panAndZoom !== false) {
2244
2413
  viewportRef.current = enablePanAndZoom(
2245
2414
  canvas,
2246
- typeof options?.panAndZoom === "object" ? options.panAndZoom : void 0
2415
+ typeof opts?.panAndZoom === "object" ? opts.panAndZoom : void 0
2247
2416
  );
2248
2417
  }
2249
2418
  const alignmentEnabled = resolveAlignmentEnabled(
2250
- options?.enableAlignment,
2251
- options?.alignment
2419
+ opts?.enableAlignment,
2420
+ opts?.alignment
2252
2421
  );
2253
2422
  if (alignmentEnabled) {
2254
2423
  alignmentCleanupRef.current = enableObjectAlignment(
2255
2424
  canvas,
2256
- typeof options?.alignment === "object" ? options.alignment : void 0
2425
+ typeof opts?.alignment === "object" ? opts.alignment : void 0
2257
2426
  );
2258
2427
  }
2259
- if (options?.rotationSnap !== false) {
2428
+ if (opts?.rotationSnap !== false) {
2260
2429
  rotationSnapCleanupRef.current = enableRotationSnap(
2261
2430
  canvas,
2262
- typeof options?.rotationSnap === "object" ? options.rotationSnap : void 0
2431
+ typeof opts?.rotationSnap === "object" ? opts.rotationSnap : void 0
2263
2432
  );
2264
2433
  }
2265
2434
  canvas.on("mouse:wheel", () => {
@@ -2274,41 +2443,60 @@ function useEditCanvas(options) {
2274
2443
  canvas.on("selection:cleared", () => {
2275
2444
  setSelected([]);
2276
2445
  });
2277
- if (options?.trackChanges) {
2446
+ if (opts?.trackChanges) {
2278
2447
  canvas.on("object:added", () => setIsDirty(true));
2279
2448
  canvas.on("object:removed", () => setIsDirty(true));
2280
2449
  canvas.on("object:modified", () => setIsDirty(true));
2281
2450
  }
2282
- if (options?.vertexEdit !== false) {
2283
- 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;
2284
2466
  canvas.on("mouse:dblclick", (e) => {
2285
2467
  if (e.target && e.target instanceof Polygon4) {
2286
2468
  vertexEditCleanupRef.current?.();
2287
- vertexEditCleanupRef.current = enableVertexEdit(
2288
- canvas,
2289
- e.target,
2290
- vertexOpts,
2291
- () => {
2469
+ vertexEditCleanupRef.current = enableVertexEdit(canvas, e.target, {
2470
+ ...vertexOpts,
2471
+ onExit: () => {
2292
2472
  vertexEditCleanupRef.current = null;
2293
2473
  setIsEditingVertices(false);
2294
2474
  }
2295
- );
2475
+ });
2296
2476
  setIsEditingVertices(true);
2297
2477
  }
2298
2478
  });
2299
2479
  }
2300
- const onReadyResult = options?.onReady?.(canvas);
2301
- 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) {
2302
2486
  Promise.resolve(onReadyResult).then(() => {
2303
2487
  if (canvas.backgroundImage) {
2304
2488
  fitViewportToBackground(canvas);
2305
2489
  syncZoom(canvasRef, setZoom);
2306
2490
  }
2491
+ historyRef.current?.pushSnapshot();
2492
+ });
2493
+ } else {
2494
+ Promise.resolve(onReadyResult).then(() => {
2495
+ historyRef.current?.pushSnapshot();
2307
2496
  });
2308
2497
  }
2309
2498
  },
2310
2499
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2311
- // eslint-disable-next-line react-hooks/exhaustive-deps
2312
2500
  []
2313
2501
  );
2314
2502
  useEffect2(() => {
@@ -2333,24 +2521,20 @@ function useEditCanvas(options) {
2333
2521
  viewportRef.current?.setMode(mode);
2334
2522
  setViewportModeState(mode);
2335
2523
  }, []);
2336
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2337
- canvasRef,
2338
- viewportRef,
2339
- setZoom
2340
- );
2524
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2341
2525
  const setBackground = useCallback(
2342
2526
  async (url, bgOpts) => {
2343
2527
  const canvas = canvasRef.current;
2344
2528
  if (!canvas) throw new Error("Canvas not ready");
2345
- 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;
2346
2531
  const img = await setBackgroundImage(canvas, url, resizeOpts);
2347
- if (options?.autoFitToBackground !== false) {
2532
+ if (opts?.autoFitToBackground !== false) {
2348
2533
  fitViewportToBackground(canvas);
2349
2534
  syncZoom(canvasRef, setZoom);
2350
2535
  }
2351
2536
  return img;
2352
2537
  },
2353
- // eslint-disable-next-line react-hooks/exhaustive-deps
2354
2538
  []
2355
2539
  );
2356
2540
  return {
@@ -2375,7 +2559,9 @@ function useEditCanvas(options) {
2375
2559
  /** Zoom out from the canvas center. Default step: 0.2. */
2376
2560
  zoomOut,
2377
2561
  /** Pan the viewport to center on a specific object. */
2378
- panToObject
2562
+ panToObject,
2563
+ /** Zoom and pan to fit a specific object in the viewport. */
2564
+ zoomToFit
2379
2565
  },
2380
2566
  /** Whether vertex edit mode is currently active (reactive). */
2381
2567
  isEditingVertices,
@@ -2407,7 +2593,27 @@ function useEditCanvas(options) {
2407
2593
  /** Whether the canvas has been modified since the last `resetDirty()` call. Requires `trackChanges: true`. */
2408
2594
  isDirty,
2409
2595
  /** Reset the dirty flag (e.g., after a successful save). */
2410
- 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
2411
2617
  };
2412
2618
  }
2413
2619
 
@@ -2422,16 +2628,22 @@ function lockCanvas(canvas) {
2422
2628
  function useViewCanvas(options) {
2423
2629
  const canvasRef = useRef3(null);
2424
2630
  const viewportRef = useRef3(null);
2631
+ const optionsRef = useRef3(options);
2632
+ optionsRef.current = options;
2425
2633
  const [zoom, setZoom] = useState2(1);
2426
2634
  const onReady = useCallback2(
2427
2635
  (canvas) => {
2428
2636
  canvasRef.current = canvas;
2429
- if (options?.scaledStrokes !== false) {
2637
+ const opts = optionsRef.current;
2638
+ if (opts?.scaledStrokes !== false) {
2430
2639
  enableScaledStrokes(canvas);
2431
2640
  }
2432
- enableScaledBorderRadius(canvas);
2433
- if (options?.panAndZoom !== false) {
2434
- 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 : {};
2435
2647
  viewportRef.current = enablePanAndZoom(canvas, {
2436
2648
  ...panAndZoomOpts,
2437
2649
  initialMode: "pan"
@@ -2444,8 +2656,8 @@ function useViewCanvas(options) {
2444
2656
  canvas.on("mouse:wheel", () => {
2445
2657
  setZoom(canvas.getZoom());
2446
2658
  });
2447
- const onReadyResult = options?.onReady?.(canvas);
2448
- if (options?.autoFitToBackground !== false) {
2659
+ const onReadyResult = opts?.onReady?.(canvas);
2660
+ if (opts?.autoFitToBackground !== false) {
2449
2661
  Promise.resolve(onReadyResult).then(() => {
2450
2662
  if (canvas.backgroundImage) {
2451
2663
  fitViewportToBackground(canvas);
@@ -2455,14 +2667,9 @@ function useViewCanvas(options) {
2455
2667
  }
2456
2668
  },
2457
2669
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2458
- // eslint-disable-next-line react-hooks/exhaustive-deps
2459
2670
  []
2460
2671
  );
2461
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2462
- canvasRef,
2463
- viewportRef,
2464
- setZoom
2465
- );
2672
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2466
2673
  const findObject = (id) => {
2467
2674
  const c = canvasRef.current;
2468
2675
  if (!c) return void 0;
@@ -2526,7 +2733,9 @@ function useViewCanvas(options) {
2526
2733
  /** Zoom out from the canvas center. Default step: 0.2. */
2527
2734
  zoomOut,
2528
2735
  /** Pan the viewport to center on a specific object. */
2529
- panToObject
2736
+ panToObject,
2737
+ /** Zoom and pan to fit a specific object in the viewport. */
2738
+ zoomToFit
2530
2739
  },
2531
2740
  /** Update a single object's visual style by its `data.id`. */
2532
2741
  setObjectStyle,
@@ -2699,36 +2908,41 @@ function useObjectOverlay(canvasRef, object, options) {
2699
2908
  const screenCoords = util4.transformPoint(center, vt);
2700
2909
  const screenWidth = actualWidth * zoom;
2701
2910
  const screenHeight = actualHeight * zoom;
2702
- el.style.left = `${screenCoords.x - screenWidth / 2}px`;
2703
- el.style.top = `${screenCoords.y - screenHeight / 2}px`;
2704
- el.style.width = `${screenWidth}px`;
2705
- el.style.height = `${screenHeight}px`;
2706
2911
  const angle = object.angle ?? 0;
2707
- el.style.rotate = angle !== 0 ? `${angle}deg` : "";
2708
2912
  const opts = optionsRef.current;
2709
- if (opts?.autoScaleContent) {
2710
- const contentScale = Math.min(screenWidth, screenHeight) / 100;
2711
- const clampedScale = Math.max(0.1, Math.min(contentScale, 2));
2712
- el.style.setProperty("--overlay-scale", String(clampedScale));
2713
- if (opts.textSelector) {
2714
- 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;
2715
2924
  const textEls = el.querySelectorAll(opts.textSelector);
2716
- const display = clampedScale < textMinScale ? "none" : "";
2925
+ const display = zoom < textMinScale ? "none" : "";
2717
2926
  textEls.forEach((t) => {
2718
2927
  t.style.display = display;
2719
2928
  });
2720
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` : "";
2721
2937
  }
2722
2938
  }
2723
2939
  update();
2724
2940
  canvas.on("after:render", update);
2725
- canvas.on("mouse:wheel", update);
2726
2941
  object.on("moving", update);
2727
2942
  object.on("scaling", update);
2728
2943
  object.on("rotating", update);
2729
2944
  return () => {
2730
2945
  canvas.off("after:render", update);
2731
- canvas.off("mouse:wheel", update);
2732
2946
  object.off("moving", update);
2733
2947
  object.off("scaling", update);
2734
2948
  object.off("rotating", update);
@@ -2741,7 +2955,7 @@ function useObjectOverlay(canvasRef, object, options) {
2741
2955
  import {
2742
2956
  Canvas as Canvas2,
2743
2957
  FabricObject as FabricObject5,
2744
- FabricImage as FabricImage2,
2958
+ FabricImage as FabricImage3,
2745
2959
  Rect as Rect6,
2746
2960
  Polygon as Polygon5,
2747
2961
  Point as Point9,
@@ -2756,13 +2970,14 @@ export {
2756
2970
  DEFAULT_GUIDELINE_SHAPE_STYLE,
2757
2971
  DEFAULT_SHAPE_STYLE,
2758
2972
  Canvas2 as FabricCanvas,
2759
- FabricImage2 as FabricImage,
2973
+ FabricImage3 as FabricImage,
2760
2974
  FabricObject5 as FabricObject,
2761
2975
  Point9 as Point,
2762
2976
  Polygon5 as Polygon,
2763
2977
  Rect6 as Rect,
2764
2978
  createCircle,
2765
2979
  createCircleAtPoint,
2980
+ createHistoryTracker,
2766
2981
  createPolygon,
2767
2982
  createPolygonAtPoint,
2768
2983
  createPolygonFromDrag,