@overtone-art/canvas-editor-core 0.2.7 → 0.2.8

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.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  computeCoverPlacement,
3
3
  computePrintAreaClip,
4
+ displaceRgba,
4
5
  exportDataURL,
6
+ exportIsolatedPNG,
5
7
  exportMockup,
6
8
  exportPNG,
7
9
  exportSVG
8
- } from "./chunk-NINRTPOJ.mjs";
10
+ } from "./chunk-ORZZ6MGQ.mjs";
9
11
 
10
12
  // src/editor.ts
11
- import { Canvas, FabricImage as FabricImage2, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
13
+ import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
12
14
 
13
15
  // src/events.ts
14
16
  var EventEmitter = class {
@@ -246,9 +248,13 @@ var LayerManager = class {
246
248
  };
247
249
 
248
250
  // src/history.ts
249
- var HistoryManager = class {
251
+ var HistoryManager = class _HistoryManager {
252
+ static ASSET_KEY = "__canvasEditorHistoryAsset";
250
253
  undoStack = [];
251
254
  redoStack = [];
255
+ assets = /* @__PURE__ */ new Map();
256
+ assetIds = /* @__PURE__ */ new Map();
257
+ nextAssetId = 1;
252
258
  maxSize;
253
259
  maxBytes;
254
260
  paused = false;
@@ -291,7 +297,8 @@ var HistoryManager = class {
291
297
  return;
292
298
  }
293
299
  this.cancelPending();
294
- const state = this.getState();
300
+ const rawState = this.getState();
301
+ const state = this.compactState(rawState);
295
302
  if (this.undoStack.at(-1) === state) {
296
303
  this.emitChanged();
297
304
  return;
@@ -303,7 +310,7 @@ var HistoryManager = class {
303
310
  this.redoStack = [];
304
311
  this.trimToBudget();
305
312
  this.events.emit("history:snapshot", {
306
- bytes: state.length * 2,
313
+ bytes: rawState.length * 2,
307
314
  totalBytes: this.snapshotBytes(),
308
315
  entries: this.undoStack.length
309
316
  });
@@ -331,15 +338,15 @@ var HistoryManager = class {
331
338
  }
332
339
  async undo() {
333
340
  this.cancelPending();
334
- const current = this.getState();
335
341
  const committed = this.undoStack.at(-1);
336
342
  if (!committed) return;
343
+ const current = this.compactState(this.getState());
337
344
  const currentIsCommitted = current === committed;
338
345
  if (currentIsCommitted && this.undoStack.length < 2) return;
339
346
  const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
340
347
  this.paused = true;
341
348
  try {
342
- await this.restoreState(target);
349
+ await this.restoreState(this.expandState(target));
343
350
  if (currentIsCommitted) this.undoStack.pop();
344
351
  this.redoStack.push(current);
345
352
  this.trimToBudget();
@@ -357,7 +364,7 @@ var HistoryManager = class {
357
364
  if (!state) return;
358
365
  this.paused = true;
359
366
  try {
360
- await this.restoreState(state);
367
+ await this.restoreState(this.expandState(state));
361
368
  this.redoStack.pop();
362
369
  if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
363
370
  this.trimToBudget();
@@ -390,6 +397,8 @@ var HistoryManager = class {
390
397
  this.cancelPending();
391
398
  this.undoStack = [];
392
399
  this.redoStack = [];
400
+ this.assets.clear();
401
+ this.assetIds.clear();
393
402
  this.emitChanged();
394
403
  }
395
404
  getSnapshotBytes() {
@@ -405,6 +414,10 @@ var HistoryManager = class {
405
414
  this.cancelPending();
406
415
  this.transactionDepth = 0;
407
416
  this.transactionDirty = false;
417
+ this.undoStack = [];
418
+ this.redoStack = [];
419
+ this.assets.clear();
420
+ this.assetIds.clear();
408
421
  }
409
422
  emitChanged() {
410
423
  this.events.emit("history:changed", {
@@ -413,17 +426,91 @@ var HistoryManager = class {
413
426
  });
414
427
  }
415
428
  snapshotBytes() {
416
- return [...this.undoStack, ...this.redoStack].reduce(
429
+ const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
417
430
  (total, state) => total + state.length * 2,
418
431
  0
419
432
  );
433
+ const assetBytes = [...this.assets.values()].reduce(
434
+ (total, asset) => total + asset.length * 2,
435
+ 0
436
+ );
437
+ return stackBytes + assetBytes;
420
438
  }
421
439
  trimToBudget() {
440
+ this.pruneAssets();
422
441
  while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
423
442
  this.undoStack.shift();
443
+ this.pruneAssets();
424
444
  }
425
445
  while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
426
446
  this.redoStack.shift();
447
+ this.pruneAssets();
448
+ }
449
+ }
450
+ /**
451
+ * History is internal and can content-address large raster strings without
452
+ * changing the public EditorState wire format. Unchanged images are retained
453
+ * once even when dozens of snapshots reference them.
454
+ */
455
+ compactState(state) {
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(state);
459
+ } catch {
460
+ return state;
461
+ }
462
+ const visit = (value) => {
463
+ if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
464
+ let id = this.assetIds.get(value);
465
+ if (!id) {
466
+ id = `a${this.nextAssetId++}`;
467
+ this.assetIds.set(value, id);
468
+ this.assets.set(id, value);
469
+ }
470
+ return { [_HistoryManager.ASSET_KEY]: id };
471
+ }
472
+ if (Array.isArray(value)) return value.map(visit);
473
+ if (!value || typeof value !== "object") return value;
474
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
475
+ };
476
+ return JSON.stringify(visit(parsed));
477
+ }
478
+ expandState(state) {
479
+ let parsed;
480
+ try {
481
+ parsed = JSON.parse(state);
482
+ } catch {
483
+ return state;
484
+ }
485
+ const visit = (value) => {
486
+ if (Array.isArray(value)) return value.map(visit);
487
+ if (!value || typeof value !== "object") return value;
488
+ const record = value;
489
+ const id = record[_HistoryManager.ASSET_KEY];
490
+ if (typeof id === "string" && Object.keys(record).length === 1) {
491
+ const asset = this.assets.get(id);
492
+ if (!asset) throw new Error(`Missing history raster asset: ${id}`);
493
+ return asset;
494
+ }
495
+ return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
496
+ };
497
+ return JSON.stringify(visit(parsed));
498
+ }
499
+ /**
500
+ * Runs on every commit, so it scans for the serialized reference marker
501
+ * instead of re-parsing every snapshot — parsing multi-megabyte raster states
502
+ * per save would cost more than the retention it reclaims. `JSON.stringify`
503
+ * emits the marker verbatim; the same text inside user data is escaped and
504
+ * therefore cannot match.
505
+ */
506
+ pruneAssets() {
507
+ if (this.assets.size === 0) return;
508
+ const states = [...this.undoStack, ...this.redoStack];
509
+ for (const [id, asset] of this.assets) {
510
+ const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
511
+ if (states.some((state) => state.includes(marker))) continue;
512
+ this.assets.delete(id);
513
+ this.assetIds.delete(asset);
427
514
  }
428
515
  }
429
516
  };
@@ -1076,6 +1163,7 @@ function serializeEditor(editor) {
1076
1163
  // forced transparent while a mockup preview is active).
1077
1164
  background: editor.getDesignBackground(),
1078
1165
  backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
1166
+ backgroundImageOptions: editor.getBackgroundImageOptions(),
1079
1167
  mockup: editor.getMockup()
1080
1168
  };
1081
1169
  }
@@ -1094,13 +1182,28 @@ async function deserializeEditor(editor, state) {
1094
1182
  throw new Error("Invalid editor background color");
1095
1183
  }
1096
1184
  const staged = await Promise.all(
1097
- state.layers.map(async (serialized) => ({
1098
- serialized,
1099
- fabricObject: (await util2.enlivenObjects([serialized.fabricObject]))[0]
1100
- }))
1185
+ state.layers.map(async (serialized) => {
1186
+ const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
1187
+ if (!fabricObject) {
1188
+ const source = serialized.fabricObject.src;
1189
+ if (typeof source === "string" && source.startsWith("blob:")) {
1190
+ throw new Error(`Failed to restore expired object URL: ${source}`);
1191
+ }
1192
+ throw new Error(`Failed to restore layer: ${serialized.id}`);
1193
+ }
1194
+ return { serialized, fabricObject };
1195
+ })
1101
1196
  );
1102
1197
  const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
1198
+ if (state.backgroundImage && !stagedBackground) {
1199
+ const source = state.backgroundImage.src;
1200
+ if (typeof source === "string" && source.startsWith("blob:")) {
1201
+ throw new Error(`Failed to restore expired background object URL: ${source}`);
1202
+ }
1203
+ throw new Error("Failed to restore background image");
1204
+ }
1103
1205
  editor.crop.cancel();
1206
+ editor.masks.detach();
1104
1207
  editor.layers.clear();
1105
1208
  if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
1106
1209
  if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
@@ -1108,7 +1211,11 @@ async function deserializeEditor(editor, state) {
1108
1211
  if (state.background !== void 0) {
1109
1212
  editor.setBackground(state.background);
1110
1213
  }
1111
- editor.setBackgroundImageObject(stagedBackground, false);
1214
+ editor.setBackgroundImageObject(
1215
+ stagedBackground ?? null,
1216
+ false,
1217
+ state.backgroundImageOptions ?? null
1218
+ );
1112
1219
  editor.setMockup(state.mockup ?? null);
1113
1220
  for (const item of staged) {
1114
1221
  restoreLayer(editor, item.serialized, item.fabricObject);
@@ -1445,9 +1552,270 @@ var ProjectManager = class {
1445
1552
  }
1446
1553
  };
1447
1554
 
1555
+ // src/mask.ts
1556
+ import { FabricImage as FabricImage2 } from "fabric";
1557
+ var MaskRefinementError = class extends Error {
1558
+ constructor(code, message, cause) {
1559
+ super(message);
1560
+ this.code = code;
1561
+ this.cause = cause;
1562
+ this.name = "MaskRefinementError";
1563
+ }
1564
+ code;
1565
+ cause;
1566
+ };
1567
+ var MaskController = class {
1568
+ constructor(editor) {
1569
+ this.editor = editor;
1570
+ }
1571
+ editor;
1572
+ backing = null;
1573
+ context = null;
1574
+ layerId = null;
1575
+ brush = null;
1576
+ previousPoint = null;
1577
+ strokeBackup = null;
1578
+ strokeStartedAt = null;
1579
+ lastInteractionLatencyMs = 0;
1580
+ refinement = null;
1581
+ disposed = false;
1582
+ async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
1583
+ this.assertActive();
1584
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
1585
+ throw new Error("Mask dimensions must be positive integers");
1586
+ }
1587
+ const backing = this.makeCanvas(width, height);
1588
+ const image = new FabricImage2(backing, {
1589
+ left: 0,
1590
+ top: 0,
1591
+ originX: "left",
1592
+ originY: "top",
1593
+ selectable: false
1594
+ });
1595
+ const layer = this.editor.layers.add("mask", image, "Mask");
1596
+ layer.meta.mask = { width, height, revision: 0 };
1597
+ this.editor.history.saveImmediate();
1598
+ this.attachBacking(layer.id, backing);
1599
+ return layer;
1600
+ }
1601
+ attach(layerId) {
1602
+ this.assertActive();
1603
+ this.cancelStroke();
1604
+ const layer = this.requireMask(layerId);
1605
+ const image = layer.fabricObject;
1606
+ const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
1607
+ const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
1608
+ const backing = this.makeCanvas(width, height);
1609
+ const context = backing.getContext("2d");
1610
+ if (!context) throw new Error("2D mask context is unavailable");
1611
+ const element = image.getElement();
1612
+ if (element) context.drawImage(element, 0, 0, width, height);
1613
+ this.attachBacking(layerId, backing);
1614
+ }
1615
+ beginStroke(options) {
1616
+ this.assertActive();
1617
+ if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
1618
+ if (this.brush) throw new Error("A mask stroke is already active");
1619
+ if (!Number.isFinite(options.size) || options.size <= 0) {
1620
+ throw new Error("Mask brush size must be positive");
1621
+ }
1622
+ this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
1623
+ this.previousPoint = null;
1624
+ this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
1625
+ this.strokeStartedAt = performance.now();
1626
+ }
1627
+ addPoint(point) {
1628
+ if (!this.brush || !this.context || !this.backing || !this.layerId) {
1629
+ throw new Error("No active mask stroke");
1630
+ }
1631
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
1632
+ const previous = this.previousPoint ?? point;
1633
+ const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
1634
+ const step = Math.max(1, this.brush.size / 4);
1635
+ const samples = Math.max(1, Math.ceil(distance / step));
1636
+ for (let index = 0; index <= samples; index += 1) {
1637
+ const ratio = index / samples;
1638
+ this.drawDot({
1639
+ x: previous.x + (point.x - previous.x) * ratio,
1640
+ y: previous.y + (point.y - previous.y) * ratio
1641
+ });
1642
+ }
1643
+ this.previousPoint = point;
1644
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
1645
+ this.editor.canvas.requestRenderAll();
1646
+ }
1647
+ async endStroke() {
1648
+ if (!this.brush || !this.backing || !this.layerId) return;
1649
+ const layerId = this.layerId;
1650
+ this.brush = null;
1651
+ this.previousPoint = null;
1652
+ this.strokeBackup = null;
1653
+ const dataUrl = this.backing.toDataURL("image/png");
1654
+ await this.editor.history.transaction(async () => {
1655
+ await this.editor.replaceImageSource(layerId, dataUrl);
1656
+ const layer = this.requireMask(layerId);
1657
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
1658
+ });
1659
+ this.attach(layerId);
1660
+ if (this.strokeStartedAt !== null) {
1661
+ this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
1662
+ this.strokeStartedAt = null;
1663
+ }
1664
+ }
1665
+ cancelStroke() {
1666
+ if (this.strokeBackup && this.context && this.backing && this.layerId) {
1667
+ this.context.putImageData(this.strokeBackup, 0, 0);
1668
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
1669
+ this.editor.canvas.requestRenderAll();
1670
+ }
1671
+ this.brush = null;
1672
+ this.previousPoint = null;
1673
+ this.strokeBackup = null;
1674
+ this.strokeStartedAt = null;
1675
+ }
1676
+ isStrokeActive() {
1677
+ return this.brush !== null;
1678
+ }
1679
+ activeLayerId() {
1680
+ return this.layerId;
1681
+ }
1682
+ detach(layerId) {
1683
+ if (layerId && this.layerId !== layerId) return;
1684
+ this.cancelStroke();
1685
+ this.cancelRefinement();
1686
+ this.backing = null;
1687
+ this.context = null;
1688
+ this.layerId = null;
1689
+ }
1690
+ async refine(layerId, provider, prompts, options = {}) {
1691
+ this.assertActive();
1692
+ const layer = this.editor.layers.get(layerId);
1693
+ if (!layer || layer.type !== "mask") {
1694
+ throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
1695
+ }
1696
+ this.cancelRefinement();
1697
+ const controller = new AbortController();
1698
+ this.refinement = controller;
1699
+ const abort = () => controller.abort(options.signal?.reason);
1700
+ options.signal?.addEventListener("abort", abort, { once: true });
1701
+ if (options.signal?.aborted) abort();
1702
+ try {
1703
+ const image = layer.fabricObject;
1704
+ const result = await provider.refine(
1705
+ {
1706
+ mask: image.getSrc(),
1707
+ width: layer.meta.mask?.width ?? image.width ?? 1,
1708
+ height: layer.meta.mask?.height ?? image.height ?? 1,
1709
+ prompts: structuredClone(prompts)
1710
+ },
1711
+ { signal: controller.signal, onProgress: options.onProgress }
1712
+ );
1713
+ if (controller.signal.aborted)
1714
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
1715
+ if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
1716
+ throw new MaskRefinementError(
1717
+ "invalid-result",
1718
+ "Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
1719
+ );
1720
+ }
1721
+ await this.editor.history.transaction(async () => {
1722
+ await this.editor.replaceImageSource(layerId, result.dataUrl);
1723
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
1724
+ });
1725
+ this.attach(layerId);
1726
+ return result;
1727
+ } catch (error) {
1728
+ if (error instanceof MaskRefinementError) throw error;
1729
+ if (controller.signal.aborted) {
1730
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
1731
+ }
1732
+ throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
1733
+ } finally {
1734
+ options.signal?.removeEventListener("abort", abort);
1735
+ if (this.refinement === controller) this.refinement = null;
1736
+ }
1737
+ }
1738
+ cancelRefinement() {
1739
+ this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
1740
+ this.refinement = null;
1741
+ }
1742
+ measure() {
1743
+ if (!this.backing) return null;
1744
+ const started = performance.now();
1745
+ this.context?.getImageData(0, 0, 1, 1);
1746
+ const backingBytes = this.backing.width * this.backing.height * 4;
1747
+ const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
1748
+ const memory = performance;
1749
+ return {
1750
+ width: this.backing.width,
1751
+ height: this.backing.height,
1752
+ backingBytes,
1753
+ strokeBackupBytes,
1754
+ // The backing store and rollback ImageData dominate interactive mask
1755
+ // memory. Encoded historical rasters are reported separately below.
1756
+ estimatedPeakBytes: backingBytes + strokeBackupBytes,
1757
+ historyBytes: this.editor.history.getSnapshotBytes(),
1758
+ interactionLatencyMs: this.lastInteractionLatencyMs,
1759
+ ...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
1760
+ elapsedMs: performance.now() - started
1761
+ };
1762
+ }
1763
+ dispose() {
1764
+ this.detach();
1765
+ this.disposed = true;
1766
+ }
1767
+ drawDot(point) {
1768
+ const context = this.context;
1769
+ const brush = this.brush;
1770
+ const radius = brush.size / 2;
1771
+ context.save();
1772
+ context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
1773
+ const gradient = context.createRadialGradient(
1774
+ point.x,
1775
+ point.y,
1776
+ radius * brush.hardness,
1777
+ point.x,
1778
+ point.y,
1779
+ radius
1780
+ );
1781
+ const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
1782
+ gradient.addColorStop(0, color);
1783
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1784
+ context.fillStyle = gradient;
1785
+ context.beginPath();
1786
+ context.arc(point.x, point.y, radius, 0, Math.PI * 2);
1787
+ context.fill();
1788
+ context.restore();
1789
+ }
1790
+ makeCanvas(width, height) {
1791
+ const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
1792
+ canvas.width = width;
1793
+ canvas.height = height;
1794
+ return canvas;
1795
+ }
1796
+ attachBacking(layerId, backing) {
1797
+ const context = backing.getContext("2d");
1798
+ if (!context) throw new Error("2D mask context is unavailable");
1799
+ this.layerId = layerId;
1800
+ this.backing = backing;
1801
+ this.context = context;
1802
+ }
1803
+ requireMask(layerId) {
1804
+ const layer = this.editor.layers.get(layerId);
1805
+ if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
1806
+ return layer;
1807
+ }
1808
+ assertActive() {
1809
+ if (this.disposed) throw new Error("Mask controller has been disposed");
1810
+ }
1811
+ };
1812
+
1448
1813
  // src/editor.ts
1449
1814
  var MIN_ZOOM = 0.1;
1450
1815
  var MAX_ZOOM = 8;
1816
+ function isTaintedCanvasError(error) {
1817
+ return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
1818
+ }
1451
1819
  var CanvasEditor = class {
1452
1820
  canvas;
1453
1821
  layers;
@@ -1460,6 +1828,7 @@ var CanvasEditor = class {
1460
1828
  fonts;
1461
1829
  licensing;
1462
1830
  pages;
1831
+ masks;
1463
1832
  fileAdapter;
1464
1833
  imageProvider;
1465
1834
  zoomLevel = 1;
@@ -1469,6 +1838,7 @@ var CanvasEditor = class {
1469
1838
  // for serialization and export — not the (possibly transient) canvas value.
1470
1839
  designBackground;
1471
1840
  designBackgroundImage = null;
1841
+ backgroundImageOptions = null;
1472
1842
  constructor(canvasElement, config) {
1473
1843
  this.events = new EventEmitter();
1474
1844
  this.fonts = new FontRegistry();
@@ -1509,11 +1879,12 @@ var CanvasEditor = class {
1509
1879
  this.setupCanvasEvents();
1510
1880
  this.history.saveImmediate();
1511
1881
  this.pages = new ProjectManager(this);
1882
+ this.masks = new MaskController(this);
1512
1883
  }
1513
1884
  // ─── Layer Operations ────────────────────────────────
1514
1885
  async addImage(url, options) {
1515
1886
  try {
1516
- const img = await FabricImage2.fromURL(
1887
+ const img = await FabricImage3.fromURL(
1517
1888
  url,
1518
1889
  {},
1519
1890
  { originX: "left", originY: "top", ...options }
@@ -1529,14 +1900,16 @@ var CanvasEditor = class {
1529
1900
  /** Replace an image source without changing its layer identity or visual transform. */
1530
1901
  async replaceImageSource(layerId, url) {
1531
1902
  const layer = this.layers.get(layerId);
1532
- if (!layer || layer.type !== "image") throw new Error(`Image layer not found: ${layerId}`);
1903
+ if (!layer || layer.type !== "image" && layer.type !== "mask") {
1904
+ throw new Error(`Image or mask layer not found: ${layerId}`);
1905
+ }
1533
1906
  if (layer.meta.pattern) {
1534
1907
  throw new Error("Clear the pattern before replacing the image source");
1535
1908
  }
1536
1909
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1537
1910
  const previous = layer.fabricObject;
1538
1911
  try {
1539
- const replacement = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
1912
+ const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
1540
1913
  replacement.set({
1541
1914
  left: previous.left,
1542
1915
  top: previous.top,
@@ -1620,6 +1993,9 @@ var CanvasEditor = class {
1620
1993
  }
1621
1994
  removeLayer(id) {
1622
1995
  if (this.crop.activeLayerId() === id) this.crop.cancel();
1996
+ if (this.masks.activeLayerId() === id) {
1997
+ this.masks.detach(id);
1998
+ }
1623
1999
  if (this.layers.remove(id)) this.history.save();
1624
2000
  }
1625
2001
  selectLayer(id) {
@@ -1767,6 +2143,50 @@ var CanvasEditor = class {
1767
2143
  async toWebP(options) {
1768
2144
  return this.toRaster("webp", options);
1769
2145
  }
2146
+ /** Export one layer in document coordinates or at its native image resolution. */
2147
+ async exportLayer(id, options = {}) {
2148
+ const layer = this.layers.get(id);
2149
+ if (!layer) throw new Error(`Layer not found: ${id}`);
2150
+ try {
2151
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
2152
+ const image = await layer.fabricObject.clone();
2153
+ image.set({
2154
+ left: 0,
2155
+ top: 0,
2156
+ originX: "left",
2157
+ originY: "top",
2158
+ scaleX: 1,
2159
+ scaleY: 1,
2160
+ angle: 0,
2161
+ flipX: false,
2162
+ flipY: false
2163
+ });
2164
+ return await exportIsolatedPNG(this.canvas, [image], {
2165
+ ...options,
2166
+ width: image.width || 1,
2167
+ height: image.height || 1,
2168
+ cloneObjects: false
2169
+ });
2170
+ }
2171
+ return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
2172
+ } catch (error) {
2173
+ this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
2174
+ throw error;
2175
+ }
2176
+ }
2177
+ /** Export only the configured document background, excluding design layers. */
2178
+ async exportBackground(options = {}) {
2179
+ try {
2180
+ return await exportIsolatedPNG(this.canvas, [], {
2181
+ ...options,
2182
+ backgroundColor: this.designBackground,
2183
+ backgroundImage: this.designBackgroundImage
2184
+ });
2185
+ } catch (error) {
2186
+ this.events.emit("error", { message: "Failed to export background", error });
2187
+ throw error;
2188
+ }
2189
+ }
1770
2190
  async toRaster(format, options) {
1771
2191
  this.events.emit("export:start", { format });
1772
2192
  try {
@@ -1778,7 +2198,10 @@ var CanvasEditor = class {
1778
2198
  this.licensing.track(`export:${format}`);
1779
2199
  return blob;
1780
2200
  } catch (error) {
1781
- this.events.emit("error", { message: `Failed to export ${format.toUpperCase()}`, error });
2201
+ this.events.emit("error", {
2202
+ message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
2203
+ error
2204
+ });
1782
2205
  throw error;
1783
2206
  }
1784
2207
  }
@@ -1971,13 +2394,20 @@ var CanvasEditor = class {
1971
2394
  getDesignBackgroundImage() {
1972
2395
  return this.designBackgroundImage;
1973
2396
  }
2397
+ getBackgroundImageOptions() {
2398
+ return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
2399
+ }
1974
2400
  async setBackgroundImage(url, options = {}) {
1975
2401
  if (url === null) {
1976
2402
  this.setBackgroundImageObject(null);
1977
2403
  return;
1978
2404
  }
1979
2405
  try {
1980
- const image = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
2406
+ const image = await FabricImage3.fromURL(
2407
+ url,
2408
+ { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
2409
+ { originX: "left", originY: "top" }
2410
+ );
1981
2411
  const width = image.width || 1;
1982
2412
  const height = image.height || 1;
1983
2413
  const canvasWidth = this.canvas.getWidth();
@@ -1996,15 +2426,18 @@ var CanvasEditor = class {
1996
2426
  selectable: false,
1997
2427
  evented: false
1998
2428
  });
1999
- this.setBackgroundImageObject(image);
2429
+ const serializableOptions = { ...options };
2430
+ delete serializableOptions.signal;
2431
+ this.setBackgroundImageObject(image, true, serializableOptions);
2000
2432
  } catch (error) {
2001
2433
  this.events.emit("error", { message: "Failed to set background image", error });
2002
2434
  throw error;
2003
2435
  }
2004
2436
  }
2005
2437
  /** Used by state restoration and advanced integrations with an existing Fabric object. */
2006
- setBackgroundImageObject(image, save = true) {
2438
+ setBackgroundImageObject(image, save = true, options = null) {
2007
2439
  this.designBackgroundImage = image;
2440
+ this.backgroundImageOptions = image ? options : null;
2008
2441
  this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
2009
2442
  this.canvas.requestRenderAll();
2010
2443
  if (save) this.history.save();
@@ -2162,6 +2595,7 @@ var CanvasEditor = class {
2162
2595
  }
2163
2596
  // ─── Cleanup ────────────────────────────────────────
2164
2597
  dispose() {
2598
+ this.masks.dispose();
2165
2599
  this.snapping.dispose();
2166
2600
  this.crop.dispose();
2167
2601
  this.history.dispose();
@@ -2216,7 +2650,49 @@ var CANVAS_SIZE_PRESETS = [
2216
2650
  { id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
2217
2651
  { id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
2218
2652
  ];
2653
+
2654
+ // src/annotations.ts
2655
+ var AnnotationOverlay = class {
2656
+ items = /* @__PURE__ */ new Map();
2657
+ transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
2658
+ set(annotation) {
2659
+ this.items.set(annotation.id, structuredClone(annotation));
2660
+ }
2661
+ remove(id) {
2662
+ return this.items.delete(id);
2663
+ }
2664
+ clear() {
2665
+ this.items.clear();
2666
+ }
2667
+ getAll() {
2668
+ return [...this.items.values()].map((item) => structuredClone(item));
2669
+ }
2670
+ setTransform(transform) {
2671
+ if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
2672
+ throw new Error("Annotation zoom must be positive");
2673
+ }
2674
+ this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
2675
+ }
2676
+ documentToViewport(point) {
2677
+ return {
2678
+ x: point.x * this.transform.zoom + this.transform.panX,
2679
+ y: point.y * this.transform.zoom + this.transform.panY
2680
+ };
2681
+ }
2682
+ viewportToDocument(point) {
2683
+ return {
2684
+ x: (point.x - this.transform.panX) / this.transform.zoom,
2685
+ y: (point.y - this.transform.panY) / this.transform.zoom
2686
+ };
2687
+ }
2688
+ documentToDevice(point) {
2689
+ const viewport = this.documentToViewport(point);
2690
+ const ratio = this.transform.devicePixelRatio ?? 1;
2691
+ return { x: viewport.x * ratio, y: viewport.y * ratio };
2692
+ }
2693
+ };
2219
2694
  export {
2695
+ AnnotationOverlay,
2220
2696
  CANVAS_SIZE_PRESETS,
2221
2697
  CanvasEditor,
2222
2698
  CropController,
@@ -2227,6 +2703,8 @@ export {
2227
2703
  Layer,
2228
2704
  LayerManager,
2229
2705
  LicenseManager,
2706
+ MaskController,
2707
+ MaskRefinementError,
2230
2708
  PatternManager,
2231
2709
  ProjectManager,
2232
2710
  SnapManager,
@@ -2240,6 +2718,7 @@ export {
2240
2718
  computePrintAreaClip,
2241
2719
  computeTilePositions,
2242
2720
  deserializeEditor,
2721
+ displaceRgba,
2243
2722
  drawTiles,
2244
2723
  escapeXml,
2245
2724
  exportDataURL,