@bwp-web/canvas 0.5.1 → 0.6.1

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
  }
@@ -2142,19 +2192,81 @@ function serializeCanvas(canvas, options) {
2142
2192
  obj.set({ rx: base.rx, ry: base.ry });
2143
2193
  }
2144
2194
  });
2195
+ const savedOrigins = /* @__PURE__ */ new Map();
2196
+ canvas.forEachObject((obj) => {
2197
+ if (obj.originX === "left" && obj.originY === "top") return;
2198
+ savedOrigins.set(obj, {
2199
+ originX: obj.originX,
2200
+ originY: obj.originY,
2201
+ left: obj.left ?? 0,
2202
+ top: obj.top ?? 0
2203
+ });
2204
+ const leftTop = obj.getPositionByOrigin("left", "top");
2205
+ obj.set({ originX: "left", originY: "top", left: leftTop.x, top: leftTop.y });
2206
+ });
2207
+ const bg = canvas.backgroundImage;
2208
+ let savedBgOrigin = null;
2209
+ if (bg instanceof FabricImage2 && (bg.originX !== "left" || bg.originY !== "top")) {
2210
+ savedBgOrigin = {
2211
+ originX: bg.originX,
2212
+ originY: bg.originY,
2213
+ left: bg.left ?? 0,
2214
+ top: bg.top ?? 0
2215
+ };
2216
+ const leftTop = bg.getPositionByOrigin("left", "top");
2217
+ bg.set({ originX: "left", originY: "top", left: leftTop.x, top: leftTop.y });
2218
+ }
2219
+ const savedData = /* @__PURE__ */ new Map();
2220
+ canvas.forEachObject((obj) => {
2221
+ const base = strokeBaseMap.get(obj) ?? obj.strokeWidth;
2222
+ if (base !== void 0 && base !== 0 && obj.data) {
2223
+ savedData.set(obj, obj.data);
2224
+ obj.data = {
2225
+ ...obj.data,
2226
+ strokeWidthBase: base
2227
+ };
2228
+ }
2229
+ });
2145
2230
  const json = canvas.toObject(properties);
2146
2231
  delete json.backgroundColor;
2232
+ json.backgroundFilters = {
2233
+ opacity: getBackgroundContrast(canvas),
2234
+ inverted: getBackgroundInverted(canvas)
2235
+ };
2147
2236
  scaledWidths.forEach((scaled, obj) => {
2148
2237
  obj.strokeWidth = scaled;
2149
2238
  });
2150
2239
  appliedRadii.forEach((radii, obj) => {
2151
2240
  obj.set({ rx: radii.rx, ry: radii.ry });
2152
2241
  });
2242
+ savedOrigins.forEach((saved, obj) => {
2243
+ obj.set(saved);
2244
+ });
2245
+ if (savedBgOrigin && bg instanceof FabricImage2) {
2246
+ bg.set(savedBgOrigin);
2247
+ }
2248
+ savedData.forEach((originalData, obj) => {
2249
+ obj.data = originalData;
2250
+ });
2153
2251
  return json;
2154
2252
  }
2155
2253
  async function loadCanvas(canvas, json, options) {
2156
2254
  await canvas.loadFromJSON(json);
2157
2255
  canvas.backgroundColor = "";
2256
+ delete canvas.backgroundFilters;
2257
+ const bg = canvas.backgroundImage;
2258
+ if (bg instanceof FabricImage2) {
2259
+ if (bg.originX !== "center" || bg.originY !== "center") {
2260
+ const center = bg.getCenterPoint();
2261
+ bg.set({
2262
+ originX: "center",
2263
+ originY: "center",
2264
+ left: center.x,
2265
+ top: center.y
2266
+ });
2267
+ bg.setCoords();
2268
+ }
2269
+ }
2158
2270
  if (options?.filter) {
2159
2271
  const toRemove = [];
2160
2272
  canvas.forEachObject((obj) => {
@@ -2174,14 +2286,19 @@ async function loadCanvas(canvas, json, options) {
2174
2286
  obj.setCoords();
2175
2287
  });
2176
2288
  canvas.forEachObject((obj) => {
2289
+ const data = obj.data;
2290
+ if (data?.strokeWidthBase !== void 0) {
2291
+ delete data.strokeWidthBase;
2292
+ }
2177
2293
  obj.set(DEFAULT_CONTROL_STYLE);
2178
2294
  if (obj.shapeType === "circle" && obj instanceof Rect5) {
2179
2295
  restoreCircleConstraints(obj);
2180
2296
  }
2181
- if (obj instanceof Rect5 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2297
+ const borderRadius = options?.borderRadius ?? DEFAULT_VIEW_BORDER_RADIUS;
2298
+ if (borderRadius !== false && obj instanceof Rect5 && obj.shapeType !== "circle" && obj.data?.type !== "DEVICE") {
2182
2299
  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);
2300
+ const rx = borderRadius / (obj.scaleX ?? 1);
2301
+ const ry = borderRadius / (obj.scaleY ?? 1);
2185
2302
  obj.set({ rx, ry });
2186
2303
  }
2187
2304
  });
@@ -2189,6 +2306,87 @@ async function loadCanvas(canvas, json, options) {
2189
2306
  return canvas.getObjects();
2190
2307
  }
2191
2308
 
2309
+ // src/history.ts
2310
+ function createHistoryTracker(canvas, options) {
2311
+ const maxSize = options?.maxSize ?? 50;
2312
+ const debounceMs = options?.debounce ?? 300;
2313
+ const snapshots = [];
2314
+ let currentIndex = -1;
2315
+ let isUndoRedo = false;
2316
+ let debounceTimer = null;
2317
+ function captureSnapshot() {
2318
+ if (isUndoRedo) return;
2319
+ const snapshot = serializeCanvas(canvas);
2320
+ if (currentIndex < snapshots.length - 1) {
2321
+ snapshots.length = currentIndex + 1;
2322
+ }
2323
+ snapshots.push(snapshot);
2324
+ if (snapshots.length > maxSize) {
2325
+ snapshots.shift();
2326
+ }
2327
+ currentIndex = snapshots.length - 1;
2328
+ }
2329
+ function debouncedCapture() {
2330
+ if (debounceTimer !== null) {
2331
+ clearTimeout(debounceTimer);
2332
+ }
2333
+ debounceTimer = setTimeout(() => {
2334
+ debounceTimer = null;
2335
+ captureSnapshot();
2336
+ }, debounceMs);
2337
+ }
2338
+ const onChange = () => {
2339
+ if (!isUndoRedo) debouncedCapture();
2340
+ };
2341
+ canvas.on("object:added", onChange);
2342
+ canvas.on("object:modified", onChange);
2343
+ canvas.on("object:removed", onChange);
2344
+ async function loadSnapshot(index) {
2345
+ if (index < 0 || index >= snapshots.length) return;
2346
+ isUndoRedo = true;
2347
+ currentIndex = index;
2348
+ try {
2349
+ await loadCanvas(canvas, snapshots[index]);
2350
+ } finally {
2351
+ isUndoRedo = false;
2352
+ }
2353
+ }
2354
+ return {
2355
+ async undo() {
2356
+ if (currentIndex <= 0) return;
2357
+ await loadSnapshot(currentIndex - 1);
2358
+ },
2359
+ async redo() {
2360
+ if (currentIndex >= snapshots.length - 1) return;
2361
+ await loadSnapshot(currentIndex + 1);
2362
+ },
2363
+ canUndo() {
2364
+ return currentIndex > 0;
2365
+ },
2366
+ canRedo() {
2367
+ return currentIndex < snapshots.length - 1;
2368
+ },
2369
+ pushSnapshot() {
2370
+ if (debounceTimer !== null) {
2371
+ clearTimeout(debounceTimer);
2372
+ debounceTimer = null;
2373
+ }
2374
+ captureSnapshot();
2375
+ },
2376
+ cleanup() {
2377
+ if (debounceTimer !== null) {
2378
+ clearTimeout(debounceTimer);
2379
+ debounceTimer = null;
2380
+ }
2381
+ canvas.off("object:added", onChange);
2382
+ canvas.off("object:modified", onChange);
2383
+ canvas.off("object:removed", onChange);
2384
+ snapshots.length = 0;
2385
+ currentIndex = -1;
2386
+ }
2387
+ };
2388
+ }
2389
+
2192
2390
  // src/hooks/useEditCanvas.ts
2193
2391
  function useEditCanvas(options) {
2194
2392
  const canvasRef = useRef2(null);
@@ -2198,11 +2396,19 @@ function useEditCanvas(options) {
2198
2396
  const modeCleanupRef = useRef2(null);
2199
2397
  const vertexEditCleanupRef = useRef2(null);
2200
2398
  const keyboardCleanupRef = useRef2(null);
2399
+ const historyRef = useRef2(null);
2400
+ const optionsRef = useRef2(options);
2401
+ optionsRef.current = options;
2402
+ const savedSelectabilityRef = useRef2(
2403
+ /* @__PURE__ */ new WeakMap()
2404
+ );
2201
2405
  const [zoom, setZoom] = useState(1);
2202
2406
  const [selected, setSelected] = useState([]);
2203
2407
  const [viewportMode, setViewportModeState] = useState("select");
2204
2408
  const [isEditingVertices, setIsEditingVertices] = useState(false);
2205
2409
  const [isDirty, setIsDirty] = useState(false);
2410
+ const [canUndo, setCanUndo] = useState(false);
2411
+ const [canRedo, setCanRedo] = useState(false);
2206
2412
  const setMode = useCallback((setup) => {
2207
2413
  vertexEditCleanupRef.current?.();
2208
2414
  vertexEditCleanupRef.current = null;
@@ -2214,10 +2420,17 @@ function useEditCanvas(options) {
2214
2420
  if (setup === null) {
2215
2421
  canvas.selection = true;
2216
2422
  canvas.forEachObject((obj) => {
2217
- obj.selectable = true;
2218
- obj.evented = true;
2423
+ const saved = savedSelectabilityRef.current.get(obj);
2424
+ obj.selectable = saved?.selectable ?? true;
2425
+ obj.evented = saved?.evented ?? true;
2219
2426
  });
2220
2427
  } else {
2428
+ canvas.forEachObject((obj) => {
2429
+ savedSelectabilityRef.current.set(obj, {
2430
+ selectable: obj.selectable,
2431
+ evented: obj.evented
2432
+ });
2433
+ });
2221
2434
  canvas.selection = false;
2222
2435
  canvas.forEachObject((obj) => {
2223
2436
  obj.selectable = false;
@@ -2232,34 +2445,38 @@ function useEditCanvas(options) {
2232
2445
  const onReady = useCallback(
2233
2446
  (canvas) => {
2234
2447
  canvasRef.current = canvas;
2235
- if (options?.scaledStrokes !== false) {
2448
+ const opts = optionsRef.current;
2449
+ if (opts?.scaledStrokes !== false) {
2236
2450
  enableScaledStrokes(canvas);
2237
2451
  }
2238
- enableScaledBorderRadius(canvas);
2239
- if (options?.keyboardShortcuts !== false) {
2452
+ if (opts?.borderRadius !== false) {
2453
+ const borderRadiusOpts = typeof opts?.borderRadius === "number" ? { radius: opts.borderRadius } : void 0;
2454
+ enableScaledBorderRadius(canvas, borderRadiusOpts);
2455
+ }
2456
+ if (opts?.keyboardShortcuts !== false) {
2240
2457
  keyboardCleanupRef.current = enableKeyboardShortcuts(canvas);
2241
2458
  }
2242
- setCanvasAlignmentEnabled(canvas, options?.enableAlignment);
2243
- if (options?.panAndZoom !== false) {
2459
+ setCanvasAlignmentEnabled(canvas, opts?.enableAlignment);
2460
+ if (opts?.panAndZoom !== false) {
2244
2461
  viewportRef.current = enablePanAndZoom(
2245
2462
  canvas,
2246
- typeof options?.panAndZoom === "object" ? options.panAndZoom : void 0
2463
+ typeof opts?.panAndZoom === "object" ? opts.panAndZoom : void 0
2247
2464
  );
2248
2465
  }
2249
2466
  const alignmentEnabled = resolveAlignmentEnabled(
2250
- options?.enableAlignment,
2251
- options?.alignment
2467
+ opts?.enableAlignment,
2468
+ opts?.alignment
2252
2469
  );
2253
2470
  if (alignmentEnabled) {
2254
2471
  alignmentCleanupRef.current = enableObjectAlignment(
2255
2472
  canvas,
2256
- typeof options?.alignment === "object" ? options.alignment : void 0
2473
+ typeof opts?.alignment === "object" ? opts.alignment : void 0
2257
2474
  );
2258
2475
  }
2259
- if (options?.rotationSnap !== false) {
2476
+ if (opts?.rotationSnap !== false) {
2260
2477
  rotationSnapCleanupRef.current = enableRotationSnap(
2261
2478
  canvas,
2262
- typeof options?.rotationSnap === "object" ? options.rotationSnap : void 0
2479
+ typeof opts?.rotationSnap === "object" ? opts.rotationSnap : void 0
2263
2480
  );
2264
2481
  }
2265
2482
  canvas.on("mouse:wheel", () => {
@@ -2274,41 +2491,60 @@ function useEditCanvas(options) {
2274
2491
  canvas.on("selection:cleared", () => {
2275
2492
  setSelected([]);
2276
2493
  });
2277
- if (options?.trackChanges) {
2494
+ if (opts?.trackChanges) {
2278
2495
  canvas.on("object:added", () => setIsDirty(true));
2279
2496
  canvas.on("object:removed", () => setIsDirty(true));
2280
2497
  canvas.on("object:modified", () => setIsDirty(true));
2281
2498
  }
2282
- if (options?.vertexEdit !== false) {
2283
- const vertexOpts = typeof options?.vertexEdit === "object" ? options.vertexEdit : void 0;
2499
+ if (opts?.history) {
2500
+ const syncHistoryState = () => {
2501
+ const h = historyRef.current;
2502
+ if (!h) return;
2503
+ setTimeout(() => {
2504
+ setCanUndo(h.canUndo());
2505
+ setCanRedo(h.canRedo());
2506
+ }, 350);
2507
+ };
2508
+ canvas.on("object:added", syncHistoryState);
2509
+ canvas.on("object:removed", syncHistoryState);
2510
+ canvas.on("object:modified", syncHistoryState);
2511
+ }
2512
+ if (opts?.vertexEdit !== false) {
2513
+ const vertexOpts = typeof opts?.vertexEdit === "object" ? opts.vertexEdit : void 0;
2284
2514
  canvas.on("mouse:dblclick", (e) => {
2285
2515
  if (e.target && e.target instanceof Polygon4) {
2286
2516
  vertexEditCleanupRef.current?.();
2287
- vertexEditCleanupRef.current = enableVertexEdit(
2288
- canvas,
2289
- e.target,
2290
- vertexOpts,
2291
- () => {
2517
+ vertexEditCleanupRef.current = enableVertexEdit(canvas, e.target, {
2518
+ ...vertexOpts,
2519
+ onExit: () => {
2292
2520
  vertexEditCleanupRef.current = null;
2293
2521
  setIsEditingVertices(false);
2294
2522
  }
2295
- );
2523
+ });
2296
2524
  setIsEditingVertices(true);
2297
2525
  }
2298
2526
  });
2299
2527
  }
2300
- const onReadyResult = options?.onReady?.(canvas);
2301
- if (options?.autoFitToBackground !== false) {
2528
+ if (opts?.history) {
2529
+ const historyOpts = typeof opts.history === "object" ? opts.history : void 0;
2530
+ historyRef.current = createHistoryTracker(canvas, historyOpts);
2531
+ }
2532
+ const onReadyResult = opts?.onReady?.(canvas);
2533
+ if (opts?.autoFitToBackground !== false) {
2302
2534
  Promise.resolve(onReadyResult).then(() => {
2303
2535
  if (canvas.backgroundImage) {
2304
2536
  fitViewportToBackground(canvas);
2305
2537
  syncZoom(canvasRef, setZoom);
2306
2538
  }
2539
+ historyRef.current?.pushSnapshot();
2540
+ });
2541
+ } else {
2542
+ Promise.resolve(onReadyResult).then(() => {
2543
+ historyRef.current?.pushSnapshot();
2307
2544
  });
2308
2545
  }
2309
2546
  },
2310
2547
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2311
- // eslint-disable-next-line react-hooks/exhaustive-deps
2312
2548
  []
2313
2549
  );
2314
2550
  useEffect2(() => {
@@ -2333,24 +2569,20 @@ function useEditCanvas(options) {
2333
2569
  viewportRef.current?.setMode(mode);
2334
2570
  setViewportModeState(mode);
2335
2571
  }, []);
2336
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2337
- canvasRef,
2338
- viewportRef,
2339
- setZoom
2340
- );
2572
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2341
2573
  const setBackground = useCallback(
2342
2574
  async (url, bgOpts) => {
2343
2575
  const canvas = canvasRef.current;
2344
2576
  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;
2577
+ const opts = optionsRef.current;
2578
+ const resizeOpts = opts?.backgroundResize !== false ? typeof opts?.backgroundResize === "object" ? { ...opts.backgroundResize, ...bgOpts } : { ...bgOpts } : bgOpts?.preserveContrast ? { preserveContrast: true } : void 0;
2346
2579
  const img = await setBackgroundImage(canvas, url, resizeOpts);
2347
- if (options?.autoFitToBackground !== false) {
2580
+ if (opts?.autoFitToBackground !== false) {
2348
2581
  fitViewportToBackground(canvas);
2349
2582
  syncZoom(canvasRef, setZoom);
2350
2583
  }
2351
2584
  return img;
2352
2585
  },
2353
- // eslint-disable-next-line react-hooks/exhaustive-deps
2354
2586
  []
2355
2587
  );
2356
2588
  return {
@@ -2375,7 +2607,9 @@ function useEditCanvas(options) {
2375
2607
  /** Zoom out from the canvas center. Default step: 0.2. */
2376
2608
  zoomOut,
2377
2609
  /** Pan the viewport to center on a specific object. */
2378
- panToObject
2610
+ panToObject,
2611
+ /** Zoom and pan to fit a specific object in the viewport. */
2612
+ zoomToFit
2379
2613
  },
2380
2614
  /** Whether vertex edit mode is currently active (reactive). */
2381
2615
  isEditingVertices,
@@ -2407,7 +2641,27 @@ function useEditCanvas(options) {
2407
2641
  /** Whether the canvas has been modified since the last `resetDirty()` call. Requires `trackChanges: true`. */
2408
2642
  isDirty,
2409
2643
  /** Reset the dirty flag (e.g., after a successful save). */
2410
- resetDirty: useCallback(() => setIsDirty(false), [])
2644
+ resetDirty: useCallback(() => setIsDirty(false), []),
2645
+ /** Undo the last change. Requires `history: true`. */
2646
+ undo: useCallback(async () => {
2647
+ const h = historyRef.current;
2648
+ if (!h) return;
2649
+ await h.undo();
2650
+ setCanUndo(h.canUndo());
2651
+ setCanRedo(h.canRedo());
2652
+ }, []),
2653
+ /** Redo a previously undone change. Requires `history: true`. */
2654
+ redo: useCallback(async () => {
2655
+ const h = historyRef.current;
2656
+ if (!h) return;
2657
+ await h.redo();
2658
+ setCanUndo(h.canUndo());
2659
+ setCanRedo(h.canRedo());
2660
+ }, []),
2661
+ /** Whether an undo operation is available (reactive). Requires `history: true`. */
2662
+ canUndo,
2663
+ /** Whether a redo operation is available (reactive). Requires `history: true`. */
2664
+ canRedo
2411
2665
  };
2412
2666
  }
2413
2667
 
@@ -2422,16 +2676,22 @@ function lockCanvas(canvas) {
2422
2676
  function useViewCanvas(options) {
2423
2677
  const canvasRef = useRef3(null);
2424
2678
  const viewportRef = useRef3(null);
2679
+ const optionsRef = useRef3(options);
2680
+ optionsRef.current = options;
2425
2681
  const [zoom, setZoom] = useState2(1);
2426
2682
  const onReady = useCallback2(
2427
2683
  (canvas) => {
2428
2684
  canvasRef.current = canvas;
2429
- if (options?.scaledStrokes !== false) {
2685
+ const opts = optionsRef.current;
2686
+ if (opts?.scaledStrokes !== false) {
2430
2687
  enableScaledStrokes(canvas);
2431
2688
  }
2432
- enableScaledBorderRadius(canvas);
2433
- if (options?.panAndZoom !== false) {
2434
- const panAndZoomOpts = typeof options?.panAndZoom === "object" ? options.panAndZoom : {};
2689
+ if (opts?.borderRadius !== false) {
2690
+ const borderRadiusOpts = typeof opts?.borderRadius === "number" ? { radius: opts.borderRadius } : void 0;
2691
+ enableScaledBorderRadius(canvas, borderRadiusOpts);
2692
+ }
2693
+ if (opts?.panAndZoom !== false) {
2694
+ const panAndZoomOpts = typeof opts?.panAndZoom === "object" ? opts.panAndZoom : {};
2435
2695
  viewportRef.current = enablePanAndZoom(canvas, {
2436
2696
  ...panAndZoomOpts,
2437
2697
  initialMode: "pan"
@@ -2444,8 +2704,8 @@ function useViewCanvas(options) {
2444
2704
  canvas.on("mouse:wheel", () => {
2445
2705
  setZoom(canvas.getZoom());
2446
2706
  });
2447
- const onReadyResult = options?.onReady?.(canvas);
2448
- if (options?.autoFitToBackground !== false) {
2707
+ const onReadyResult = opts?.onReady?.(canvas);
2708
+ if (opts?.autoFitToBackground !== false) {
2449
2709
  Promise.resolve(onReadyResult).then(() => {
2450
2710
  if (canvas.backgroundImage) {
2451
2711
  fitViewportToBackground(canvas);
@@ -2455,14 +2715,9 @@ function useViewCanvas(options) {
2455
2715
  }
2456
2716
  },
2457
2717
  // onReady and panAndZoom are intentionally excluded — we only initialize once
2458
- // eslint-disable-next-line react-hooks/exhaustive-deps
2459
2718
  []
2460
2719
  );
2461
- const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject } = createViewportActions(
2462
- canvasRef,
2463
- viewportRef,
2464
- setZoom
2465
- );
2720
+ const { resetViewport: resetViewport2, zoomIn, zoomOut, panToObject, zoomToFit } = useViewportActions(canvasRef, viewportRef, setZoom);
2466
2721
  const findObject = (id) => {
2467
2722
  const c = canvasRef.current;
2468
2723
  if (!c) return void 0;
@@ -2526,7 +2781,9 @@ function useViewCanvas(options) {
2526
2781
  /** Zoom out from the canvas center. Default step: 0.2. */
2527
2782
  zoomOut,
2528
2783
  /** Pan the viewport to center on a specific object. */
2529
- panToObject
2784
+ panToObject,
2785
+ /** Zoom and pan to fit a specific object in the viewport. */
2786
+ zoomToFit
2530
2787
  },
2531
2788
  /** Update a single object's visual style by its `data.id`. */
2532
2789
  setObjectStyle,
@@ -2699,36 +2956,41 @@ function useObjectOverlay(canvasRef, object, options) {
2699
2956
  const screenCoords = util4.transformPoint(center, vt);
2700
2957
  const screenWidth = actualWidth * zoom;
2701
2958
  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
2959
  const angle = object.angle ?? 0;
2707
- el.style.rotate = angle !== 0 ? `${angle}deg` : "";
2708
2960
  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;
2961
+ if (opts?.autoScaleContent !== false) {
2962
+ el.style.left = `${screenCoords.x - actualWidth / 2}px`;
2963
+ el.style.top = `${screenCoords.y - actualHeight / 2}px`;
2964
+ el.style.width = `${actualWidth}px`;
2965
+ el.style.height = `${actualHeight}px`;
2966
+ el.style.transformOrigin = "center center";
2967
+ el.style.transform = angle !== 0 ? `scale(${zoom}) rotate(${angle}deg)` : `scale(${zoom})`;
2968
+ el.style.rotate = "";
2969
+ el.style.setProperty("--overlay-scale", String(zoom));
2970
+ if (opts?.textSelector) {
2971
+ const textMinScale = opts?.textMinScale ?? 0.5;
2715
2972
  const textEls = el.querySelectorAll(opts.textSelector);
2716
- const display = clampedScale < textMinScale ? "none" : "";
2973
+ const display = zoom < textMinScale ? "none" : "";
2717
2974
  textEls.forEach((t) => {
2718
2975
  t.style.display = display;
2719
2976
  });
2720
2977
  }
2978
+ } else {
2979
+ el.style.left = `${screenCoords.x - screenWidth / 2}px`;
2980
+ el.style.top = `${screenCoords.y - screenHeight / 2}px`;
2981
+ el.style.width = `${screenWidth}px`;
2982
+ el.style.height = `${screenHeight}px`;
2983
+ el.style.transform = "";
2984
+ el.style.rotate = angle !== 0 ? `${angle}deg` : "";
2721
2985
  }
2722
2986
  }
2723
2987
  update();
2724
2988
  canvas.on("after:render", update);
2725
- canvas.on("mouse:wheel", update);
2726
2989
  object.on("moving", update);
2727
2990
  object.on("scaling", update);
2728
2991
  object.on("rotating", update);
2729
2992
  return () => {
2730
2993
  canvas.off("after:render", update);
2731
- canvas.off("mouse:wheel", update);
2732
2994
  object.off("moving", update);
2733
2995
  object.off("scaling", update);
2734
2996
  object.off("rotating", update);
@@ -2741,7 +3003,7 @@ function useObjectOverlay(canvasRef, object, options) {
2741
3003
  import {
2742
3004
  Canvas as Canvas2,
2743
3005
  FabricObject as FabricObject5,
2744
- FabricImage as FabricImage2,
3006
+ FabricImage as FabricImage3,
2745
3007
  Rect as Rect6,
2746
3008
  Polygon as Polygon5,
2747
3009
  Point as Point9,
@@ -2756,13 +3018,14 @@ export {
2756
3018
  DEFAULT_GUIDELINE_SHAPE_STYLE,
2757
3019
  DEFAULT_SHAPE_STYLE,
2758
3020
  Canvas2 as FabricCanvas,
2759
- FabricImage2 as FabricImage,
3021
+ FabricImage3 as FabricImage,
2760
3022
  FabricObject5 as FabricObject,
2761
3023
  Point9 as Point,
2762
3024
  Polygon5 as Polygon,
2763
3025
  Rect6 as Rect,
2764
3026
  createCircle,
2765
3027
  createCircleAtPoint,
3028
+ createHistoryTracker,
2766
3029
  createPolygon,
2767
3030
  createPolygonAtPoint,
2768
3031
  createPolygonFromDrag,