@overtone-art/canvas-editor-core 0.4.0 → 0.5.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
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AnnotationOverlay: () => AnnotationOverlay,
24
+ CANVAS_MASK_TARGET: () => CANVAS_MASK_TARGET,
24
25
  CANVAS_SIZE_PRESETS: () => CANVAS_SIZE_PRESETS,
25
26
  CanvasEditor: () => CanvasEditor,
26
27
  CropController: () => CropController,
@@ -33,6 +34,7 @@ __export(index_exports, {
33
34
  HistoryManager: () => HistoryManager,
34
35
  Layer: () => Layer,
35
36
  LayerManager: () => LayerManager,
37
+ LayerMaskManager: () => LayerMaskManager,
36
38
  LicenseManager: () => LicenseManager,
37
39
  MaskController: () => MaskController,
38
40
  MaskPresetManager: () => MaskPresetManager,
@@ -54,6 +56,7 @@ __export(index_exports, {
54
56
  buildCurvePathData: () => buildCurvePathData,
55
57
  clamp: () => clamp,
56
58
  clearTextureMaskCache: () => clearTextureMaskCache,
59
+ composeMaskGroup: () => composeMaskGroup,
57
60
  computeCoverPlacement: () => computeCoverPlacement,
58
61
  computePrintAreaClip: () => computePrintAreaClip,
59
62
  computeTilePositions: () => computeTilePositions,
@@ -66,11 +69,13 @@ __export(index_exports, {
66
69
  exportPNG: () => exportPNG,
67
70
  exportPrintArea: () => exportPrintArea,
68
71
  exportSVG: () => exportSVG,
72
+ fitToBox: () => fitToBox,
69
73
  generateId: () => generateId,
70
74
  isCssColor: () => isCssColor,
71
75
  isMaskPresetId: () => isMaskPresetId,
72
76
  isShapeMaskId: () => isShapeMaskId,
73
77
  isTextureMaskId: () => isTextureMaskId,
78
+ needsAbsoluteSpace: () => needsAbsoluteSpace,
74
79
  readLayerShadow: () => readLayerShadow,
75
80
  renderTextureMask: () => renderTextureMask,
76
81
  resetTransform: () => resetTransform,
@@ -78,12 +83,15 @@ __export(index_exports, {
78
83
  round2: () => round2,
79
84
  sanitizeSvg: () => sanitizeSvg,
80
85
  serializeEditor: () => serializeEditor,
81
- shapeMaskPathData: () => shapeMaskPathData
86
+ shapeMaskPathData: () => shapeMaskPathData,
87
+ toCanvasSpace: () => toCanvasSpace,
88
+ toHostSpace: () => toHostSpace,
89
+ unwrapGroup: () => unwrapGroup
82
90
  });
83
91
  module.exports = __toCommonJS(index_exports);
84
92
 
85
93
  // src/editor.ts
86
- var import_fabric10 = require("fabric");
94
+ var import_fabric17 = require("fabric");
87
95
 
88
96
  // src/events.ts
89
97
  var EventEmitter = class {
@@ -245,7 +253,13 @@ var LayerManager = class {
245
253
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
246
254
  layer.renderProxy = proxy;
247
255
  if (proxy) {
248
- proxy.set({ visible: layer.visible, opacity: layer.opacity });
256
+ proxy._layerId = id;
257
+ proxy.set({
258
+ visible: layer.visible,
259
+ opacity: layer.opacity,
260
+ selectable: !layer.locked,
261
+ evented: !layer.locked
262
+ });
249
263
  this.canvas.add(proxy);
250
264
  this.syncZOrder();
251
265
  }
@@ -284,7 +298,7 @@ var LayerManager = class {
284
298
  } else {
285
299
  const layer = this.get(id);
286
300
  if (layer) {
287
- this.canvas.setActiveObject(layer.fabricObject);
301
+ this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
288
302
  }
289
303
  }
290
304
  this.canvas.requestRenderAll();
@@ -319,8 +333,9 @@ var LayerManager = class {
319
333
  if (!layer) return;
320
334
  if (layer.locked === locked) return;
321
335
  layer.locked = locked;
322
- layer.fabricObject.selectable = !locked;
323
- layer.fabricObject.evented = !locked;
336
+ const target = layer.renderProxy ?? layer.fabricObject;
337
+ target.selectable = !locked;
338
+ target.evented = !locked;
324
339
  this.canvas.requestRenderAll();
325
340
  this.emitChanged();
326
341
  this.onPropertyChanged?.();
@@ -987,41 +1002,37 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
987
1002
  /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
988
1003
  source;
989
1004
  config;
1005
+ /** The print area this pattern fills, in canvas units. */
1006
+ area;
990
1007
  snapshotEl = null;
991
1008
  snapshotScale = 0;
992
- constructor(source, config, width, height) {
1009
+ constructor(source, config, area) {
993
1010
  super({
994
- // Explicit origin: fabric v7 defaults to `center`, and a centre-origin
995
- // object placed at 0,0 covers a quarter of the canvas. That exact bug is
996
- // why the old bake-into-the-layer pattern landed in the top-left corner.
997
- originX: "left",
998
- originY: "top",
999
- left: 0,
1000
- top: 0,
1001
- width,
1002
- height,
1003
- // Pointer events belong to the source underneath, and fabric's object
1004
- // cache would freeze the tiling at screen resolution.
1005
- selectable: false,
1006
- evented: false,
1011
+ // Centre origin: the box tracks a tile, and a tile is placed by its centre.
1012
+ originX: "center",
1013
+ originY: "center",
1007
1014
  objectCaching: false,
1008
- hasControls: false,
1009
- hasBorders: false
1015
+ // Side handles would imply a non-uniform tile scale; the config has one.
1016
+ lockScalingFlip: true
1010
1017
  });
1011
1018
  this.source = source;
1012
1019
  this.config = config;
1020
+ this.area = area;
1021
+ this.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false });
1022
+ this.syncBox();
1013
1023
  }
1014
- /** Swap in a new config; the next render picks it up. */
1024
+ /** Swap in a new config and re-fit the box to the tile it now describes. */
1015
1025
  setConfig(config) {
1016
1026
  this.config = config;
1017
- this.dirty = true;
1027
+ this.syncBox();
1018
1028
  }
1019
- /** Resize to a new print area. */
1020
- setArea(width, height) {
1021
- this.set({ width, height });
1022
- this.setCoords();
1029
+ /** Re-fit to a new print area (canvas resize). */
1030
+ setArea(area) {
1031
+ this.area = area;
1032
+ this.invalidate();
1033
+ this.syncBox();
1023
1034
  }
1024
- /** Drop the cached source snapshot (e.g. the source was edited). */
1035
+ /** Drop the cached source snapshot (the source was edited). */
1025
1036
  invalidate() {
1026
1037
  this.snapshotEl = null;
1027
1038
  this.snapshotScale = 0;
@@ -1032,40 +1043,105 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
1032
1043
  this.snapshotEl = null;
1033
1044
  this.snapshotScale = 0;
1034
1045
  }
1046
+ /**
1047
+ * Put the box back on the anchor tile: the tile's size, the pattern angle, and
1048
+ * the grid origin displaced by the configured phase shift. Resets any live
1049
+ * gesture scale, so it must run only once that gesture has been folded in.
1050
+ */
1051
+ syncBox() {
1052
+ const { tileW, tileH } = this.baseTile();
1053
+ const origin = this.source.getCenterPoint();
1054
+ const anchor = this.anchorFromOrigin(origin, tileW, tileH, this.config.angle);
1055
+ this.set({
1056
+ left: anchor.x,
1057
+ top: anchor.y,
1058
+ width: tileW,
1059
+ height: tileH,
1060
+ scaleX: 1,
1061
+ scaleY: 1,
1062
+ angle: this.config.angle
1063
+ });
1064
+ this.setCoords();
1065
+ this.dirty = true;
1066
+ }
1067
+ /** Grid rotation in play right now — the live handle during a rotate gesture. */
1068
+ liveAngle() {
1069
+ return this.angle ?? 0;
1070
+ }
1071
+ /** Tile scale in play right now, as a config percentage. */
1072
+ liveScale() {
1073
+ return (this.config.scale ?? 100) * Math.abs(this.scaleX ?? 1);
1074
+ }
1075
+ /**
1076
+ * Where the source's centre would have to sit for the box to stay put — i.e.
1077
+ * the grid origin implied by a drag. `PatternManager` moves the source there.
1078
+ */
1079
+ liveOrigin() {
1080
+ const { tileW, tileH } = this.liveTile();
1081
+ const centre = this.getCenterPoint();
1082
+ const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1083
+ return new import_fabric2.Point(centre.x - shift.x, centre.y - shift.y);
1084
+ }
1035
1085
  _render(ctx) {
1036
- const width = this.width ?? 0;
1037
- const height = this.height ?? 0;
1086
+ const { width, height } = this.area;
1038
1087
  if (width <= 0 || height <= 0) return;
1039
- const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
1040
- const snapshot = this.ensureSnapshot(contextScale(ctx) * tileScale);
1088
+ const { tileW, tileH } = this.liveTile();
1089
+ const snapshot = this.ensureSnapshot(contextScale(ctx) * (tileW / Math.max(1, this.baseW())));
1041
1090
  if (!snapshot) return;
1042
- const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1091
+ const centre = this.getCenterPoint();
1092
+ const angle = this.liveAngle();
1043
1093
  ctx.save();
1044
- ctx.translate(-width / 2, -height / 2);
1045
- drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1094
+ ctx.scale(1 / (this.scaleX || 1), 1 / (this.scaleY || 1));
1095
+ ctx.rotate(-angle * Math.PI / 180);
1096
+ ctx.translate(-centre.x, -centre.y);
1097
+ const shift = this.shiftVector(tileW, tileH, angle);
1098
+ const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
1099
+ const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
1100
+ drawTiles(ctx, snapshot, config, width, height, tileW, tileH, origin);
1046
1101
  ctx.restore();
1047
1102
  }
1048
1103
  /** Raster fallback for SVG export — one `<image>` covering the print area. */
1049
1104
  _toSVG() {
1050
- const width = this.width ?? 0;
1051
- const height = this.height ?? 0;
1105
+ const { width, height } = this.area;
1052
1106
  if (width <= 0 || height <= 0) return [];
1053
1107
  const el = document.createElement("canvas");
1054
1108
  el.width = Math.max(1, Math.round(width));
1055
1109
  el.height = Math.max(1, Math.round(height));
1056
1110
  const ctx = el.getContext("2d");
1057
1111
  if (!ctx) return [];
1058
- const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
1059
- const snapshot = this.ensureSnapshot(tileScale);
1112
+ const { tileW, tileH } = this.liveTile();
1113
+ const snapshot = this.ensureSnapshot(tileW / Math.max(1, this.baseW()));
1060
1114
  if (!snapshot) return [];
1061
- const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1062
- drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1115
+ const centre = this.getCenterPoint();
1116
+ const angle = this.liveAngle();
1117
+ const shift = this.shiftVector(tileW, tileH, angle);
1118
+ drawTiles(
1119
+ ctx,
1120
+ snapshot,
1121
+ { ...this.config, angle, offsetX: 0, offsetY: 0 },
1122
+ width,
1123
+ height,
1124
+ tileW,
1125
+ tileH,
1126
+ { x: centre.x - shift.x, y: centre.y - shift.y }
1127
+ );
1063
1128
  return [
1064
- `<image x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" `,
1065
- `xlink:href="${el.toDataURL("image/png")}"></image>
1129
+ `<g transform="rotate(${-angle}) translate(${-centre.x} ${-centre.y})">`,
1130
+ `<image x="0" y="0" width="${width}" height="${height}" `,
1131
+ `xlink:href="${el.toDataURL("image/png")}"></image></g>
1066
1132
  `
1067
1133
  ];
1068
1134
  }
1135
+ /**
1136
+ * The proxy holds a live reference to its source, which would make a
1137
+ * serialized canvas circular. Nothing persists this object (only layers are
1138
+ * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1139
+ */
1140
+ toObject() {
1141
+ const plain = super.toObject();
1142
+ delete plain.source;
1143
+ return plain;
1144
+ }
1069
1145
  /**
1070
1146
  * Fabric's generic clone round-trips through `toObject()` + the class
1071
1147
  * registry, which cannot carry a live source reference. Export paths clone
@@ -1073,46 +1149,64 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
1073
1149
  * tiling. The copy shares the source (it only ever reads from it).
1074
1150
  */
1075
1151
  clone() {
1076
- const copy = new _TiledPatternObject(
1077
- this.source,
1078
- this.config,
1079
- this.width ?? 0,
1080
- this.height ?? 0
1081
- );
1152
+ const copy = new _TiledPatternObject(this.source, this.config, this.area);
1082
1153
  copy.set({
1083
1154
  left: this.left,
1084
1155
  top: this.top,
1156
+ angle: this.angle,
1157
+ scaleX: this.scaleX,
1158
+ scaleY: this.scaleY,
1159
+ width: this.width,
1160
+ height: this.height,
1085
1161
  visible: this.visible,
1086
1162
  opacity: this.opacity
1087
1163
  });
1088
1164
  return Promise.resolve(copy);
1089
1165
  }
1090
- /**
1091
- * The proxy holds a live reference to its source, which would make a
1092
- * serialized canvas circular. Nothing persists this object (only layers are
1093
- * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1094
- */
1095
- toObject() {
1096
- const plain = super.toObject();
1097
- delete plain.source;
1098
- return plain;
1166
+ /** The source's on-canvas width, before the tile scale. */
1167
+ baseW() {
1168
+ return Math.max(1, this.source.getBoundingRect().width);
1099
1169
  }
1100
- /** Tile size in canvas units, floored so a tiny tile can't spawn a huge loop. */
1101
- tileSize(snapshot, tileScale, width, height) {
1102
- const floor = Math.max(2, Math.sqrt(width * width + height * height) / 200);
1170
+ /** Tile size from the config alone, ignoring any in-flight gesture. */
1171
+ baseTile() {
1172
+ const rect = this.source.getBoundingRect();
1173
+ const scale = Math.max(1, this.config.scale ?? 100) / 100;
1174
+ const floor = this.tileFloor();
1103
1175
  return {
1104
- tileW: Math.max(floor, snapshot.width / this.snapshotScale * tileScale),
1105
- tileH: Math.max(floor, snapshot.height / this.snapshotScale * tileScale)
1176
+ tileW: Math.max(floor, rect.width * scale),
1177
+ tileH: Math.max(floor, rect.height * scale)
1106
1178
  };
1107
1179
  }
1108
- /** Grid anchor: the source's centre, in this object's coordinates. */
1109
- gridOrigin() {
1110
- const centre = this.source.getCenterPoint();
1111
- return { x: centre.x - (this.left ?? 0), y: centre.y - (this.top ?? 0) };
1180
+ /** Tile size as drawn right now, including a live scale gesture. */
1181
+ liveTile() {
1182
+ const base = this.baseTile();
1183
+ const floor = this.tileFloor();
1184
+ return {
1185
+ tileW: Math.max(floor, base.tileW * Math.abs(this.scaleX ?? 1)),
1186
+ tileH: Math.max(floor, base.tileH * Math.abs(this.scaleY ?? 1))
1187
+ };
1188
+ }
1189
+ /** Smallest tile the draw loop may use, so a tiny tile can't flood the area. */
1190
+ tileFloor() {
1191
+ const { width, height } = this.area;
1192
+ return Math.max(2, Math.sqrt(width * width + height * height) / 200);
1193
+ }
1194
+ /** Phase shift (`offsetX`/`offsetY`) as a canvas-space vector. */
1195
+ shiftVector(tileW, tileH, angle) {
1196
+ const dx = tileW * (clampPercent(this.config.offsetX) / 100);
1197
+ const dy = tileH * (clampPercent(this.config.offsetY) / 100);
1198
+ const radians = angle * Math.PI / 180;
1199
+ const cos = Math.cos(radians);
1200
+ const sin = Math.sin(radians);
1201
+ return new import_fabric2.Point(dx * cos - dy * sin, dx * sin + dy * cos);
1202
+ }
1203
+ anchorFromOrigin(origin, tileW, tileH, angle) {
1204
+ const shift = this.shiftVector(tileW, tileH, angle);
1205
+ return new import_fabric2.Point(origin.x + shift.x, origin.y + shift.y);
1112
1206
  }
1113
1207
  /**
1114
- * Snapshot the source at (at least) `scale`, reusing the cached one when it is
1115
- * still sharp enough and the source has not changed.
1208
+ * Snapshot the source at (at least) `scale`, reusing the cached one while it
1209
+ * is still sharp enough and the source has not changed.
1116
1210
  */
1117
1211
  ensureSnapshot(scale) {
1118
1212
  const wanted = this.clampScale(scale);
@@ -1143,6 +1237,10 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
1143
1237
  return Math.max(0.05, Math.min(requested, budgeted));
1144
1238
  }
1145
1239
  };
1240
+ function clampPercent(value) {
1241
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1242
+ return Math.max(-100, Math.min(100, value));
1243
+ }
1146
1244
  function contextScale(ctx) {
1147
1245
  if (typeof ctx.getTransform !== "function") return 1;
1148
1246
  try {
@@ -1154,13 +1252,15 @@ function contextScale(ctx) {
1154
1252
  }
1155
1253
 
1156
1254
  // src/pattern/pattern-manager.ts
1255
+ var MIN_TILE_SCALE = 10;
1256
+ var MAX_TILE_SCALE = 300;
1157
1257
  var PatternManager = class {
1158
1258
  constructor(canvas, layers, history, events) {
1159
1259
  this.canvas = canvas;
1160
1260
  this.layers = layers;
1161
1261
  this.history = history;
1162
1262
  this.events = events;
1163
- this.canvas.on("object:modified", this.onSourceModified);
1263
+ this.canvas.on("object:modified", this.onObjectModified);
1164
1264
  this.canvas.on("text:changed", this.onSourceModified);
1165
1265
  this.events.on("layer:removed", this.onLayerRemoved);
1166
1266
  }
@@ -1169,6 +1269,11 @@ var PatternManager = class {
1169
1269
  history;
1170
1270
  events;
1171
1271
  proxies = /* @__PURE__ */ new Map();
1272
+ onObjectModified = (event) => {
1273
+ const target = event.target;
1274
+ if (target instanceof TiledPatternObject) this.commitGesture(target);
1275
+ else this.invalidateFor(target);
1276
+ };
1172
1277
  onSourceModified = (event) => {
1173
1278
  this.invalidateFor(event.target);
1174
1279
  };
@@ -1210,7 +1315,11 @@ var PatternManager = class {
1210
1315
  try {
1211
1316
  this.detach(layerId);
1212
1317
  restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1213
- layer.fabricObject.set({ opacity: layer.opacity });
1318
+ layer.fabricObject.set({
1319
+ opacity: layer.opacity,
1320
+ selectable: !layer.locked,
1321
+ evented: !layer.locked
1322
+ });
1214
1323
  this.layers.setMeta(layerId, { pattern: void 0 });
1215
1324
  this.canvas.requestRenderAll();
1216
1325
  this.history.save();
@@ -1242,12 +1351,8 @@ var PatternManager = class {
1242
1351
  }
1243
1352
  /** Re-fit every proxy to the print area (canvas resize). */
1244
1353
  syncArea() {
1245
- const width = this.canvas.getWidth();
1246
- const height = this.canvas.getHeight();
1247
- for (const proxy of this.proxies.values()) {
1248
- proxy.setArea(width, height);
1249
- proxy.invalidate();
1250
- }
1354
+ const area = this.area();
1355
+ for (const proxy of this.proxies.values()) proxy.setArea(area);
1251
1356
  if (this.proxies.size > 0) this.canvas.requestRenderAll();
1252
1357
  }
1253
1358
  /** Drop a layer's cached source snapshot (its content changed). */
@@ -1255,6 +1360,7 @@ var PatternManager = class {
1255
1360
  const proxy = this.proxies.get(layerId);
1256
1361
  if (!proxy) return;
1257
1362
  proxy.invalidate();
1363
+ proxy.syncBox();
1258
1364
  this.canvas.requestRenderAll();
1259
1365
  }
1260
1366
  /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
@@ -1264,7 +1370,7 @@ var PatternManager = class {
1264
1370
  this.attach(layer, state.config);
1265
1371
  }
1266
1372
  dispose() {
1267
- this.canvas.off("object:modified", this.onSourceModified);
1373
+ this.canvas.off("object:modified", this.onObjectModified);
1268
1374
  this.canvas.off("text:changed", this.onSourceModified);
1269
1375
  this.events.off("layer:removed", this.onLayerRemoved);
1270
1376
  this.clearProxies();
@@ -1275,17 +1381,38 @@ var PatternManager = class {
1275
1381
  this.proxies.clear();
1276
1382
  }
1277
1383
  attach(layer, config) {
1278
- const proxy = new TiledPatternObject(
1279
- layer.fabricObject,
1280
- config,
1281
- this.canvas.getWidth(),
1282
- this.canvas.getHeight()
1283
- );
1284
- layer.fabricObject.set({ opacity: 0 });
1384
+ const proxy = new TiledPatternObject(layer.fabricObject, config, this.area());
1385
+ const wasActive = this.canvas.getActiveObject() === layer.fabricObject;
1386
+ layer.fabricObject.set({ opacity: 0, selectable: false, evented: false });
1285
1387
  this.proxies.set(layer.id, proxy);
1286
1388
  this.layers.setRenderProxy(layer.id, proxy);
1389
+ if (wasActive) this.canvas.setActiveObject(proxy);
1287
1390
  this.canvas.requestRenderAll();
1288
1391
  }
1392
+ /**
1393
+ * Fold a finished drag / scale / rotate on the proxy back into the pattern:
1394
+ * position becomes the grid origin (carried by the source), scale becomes the
1395
+ * tile scale, rotation becomes the pattern angle. Read the live transform
1396
+ * first — writing the config re-fits the box and destroys it.
1397
+ */
1398
+ commitGesture(proxy) {
1399
+ const layer = this.layers.findByObject(proxy);
1400
+ const state = layer?.meta.pattern;
1401
+ if (!layer || !state) return;
1402
+ const origin = proxy.liveOrigin();
1403
+ const scale = clamp2(proxy.liveScale(), MIN_TILE_SCALE, MAX_TILE_SCALE);
1404
+ const angle = normalizeAngle(proxy.liveAngle());
1405
+ layer.fabricObject.setPositionByOrigin(origin, "center", "center");
1406
+ layer.fabricObject.setCoords();
1407
+ const config = { ...state.config, scale, angle };
1408
+ this.layers.setMeta(layer.id, { pattern: { ...state, config } });
1409
+ proxy.setConfig(config);
1410
+ this.canvas.requestRenderAll();
1411
+ this.history.save();
1412
+ }
1413
+ area() {
1414
+ return { width: this.canvas.getWidth(), height: this.canvas.getHeight() };
1415
+ }
1289
1416
  detach(layerId) {
1290
1417
  const proxy = this.proxies.get(layerId);
1291
1418
  if (!proxy) {
@@ -1302,6 +1429,15 @@ var PatternManager = class {
1302
1429
  if (layer) this.invalidate(layer.id);
1303
1430
  }
1304
1431
  };
1432
+ function clamp2(value, min, max) {
1433
+ if (!Number.isFinite(value)) return min;
1434
+ return Math.max(min, Math.min(max, value));
1435
+ }
1436
+ function normalizeAngle(angle) {
1437
+ if (!Number.isFinite(angle)) return 0;
1438
+ const wrapped = (angle % 360 + 360) % 360;
1439
+ return Math.round(wrapped > 180 ? wrapped - 360 : wrapped);
1440
+ }
1305
1441
  function isLegacyState(state) {
1306
1442
  return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
1307
1443
  }
@@ -2852,6 +2988,737 @@ var MaskController = class {
2852
2988
  }
2853
2989
  };
2854
2990
 
2991
+ // src/masks/manager.ts
2992
+ var import_fabric16 = require("fabric");
2993
+
2994
+ // src/masks/compose.ts
2995
+ var import_fabric10 = require("fabric");
2996
+ var MODE_OPERATION = {
2997
+ add: "source-over",
2998
+ subtract: "destination-out",
2999
+ intersect: "destination-in"
3000
+ };
3001
+ function neutralize(child) {
3002
+ child.set({ opacity: 0, globalCompositeOperation: "source-over" });
3003
+ }
3004
+ function baseRect(box) {
3005
+ return new import_fabric10.Rect({
3006
+ left: box.left,
3007
+ top: box.top,
3008
+ width: Math.max(1, box.width),
3009
+ height: Math.max(1, box.height),
3010
+ originX: "left",
3011
+ originY: "top",
3012
+ fill: "#000000",
3013
+ objectCaching: false
3014
+ });
3015
+ }
3016
+ function composeMaskGroup(children, entries, options) {
3017
+ if (children.length === 0) return void 0;
3018
+ children.forEach((child, index) => {
3019
+ const entry = entries[index];
3020
+ child.set({ objectCaching: false });
3021
+ if (!entry || !entry.visible) {
3022
+ neutralize(child);
3023
+ return;
3024
+ }
3025
+ child.set({
3026
+ opacity: entry.opacity,
3027
+ globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
3028
+ });
3029
+ });
3030
+ const first = entries.find((entry) => entry.visible);
3031
+ const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
3032
+ return new import_fabric10.Group(withBase, {
3033
+ absolutePositioned: options.absolute,
3034
+ // Cached, so the children's compositing operations resolve against each
3035
+ // other instead of against the page underneath the mask.
3036
+ objectCaching: true,
3037
+ subTargetCheck: false,
3038
+ interactive: false
3039
+ });
3040
+ }
3041
+ function needsAbsoluteSpace(entries) {
3042
+ return entries.some((entry) => !entry.linked);
3043
+ }
3044
+
3045
+ // src/masks/edit.ts
3046
+ var import_fabric12 = require("fabric");
3047
+
3048
+ // src/masks/space.ts
3049
+ var import_fabric11 = require("fabric");
3050
+ function matrixOf(object) {
3051
+ return object.calcTransformMatrix();
3052
+ }
3053
+ function applyMatrix(object, matrix) {
3054
+ const decomposed = import_fabric11.util.qrDecompose(matrix);
3055
+ object.set({
3056
+ flipX: false,
3057
+ flipY: false,
3058
+ originX: "center",
3059
+ originY: "center",
3060
+ left: decomposed.translateX,
3061
+ top: decomposed.translateY,
3062
+ scaleX: decomposed.scaleX,
3063
+ scaleY: decomposed.scaleY,
3064
+ angle: decomposed.angle,
3065
+ skewX: decomposed.skewX,
3066
+ skewY: 0
3067
+ });
3068
+ object.setCoords();
3069
+ }
3070
+ function toCanvasSpace(object, host) {
3071
+ applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
3072
+ }
3073
+ function toHostSpace(object, host) {
3074
+ applyMatrix(
3075
+ object,
3076
+ import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object))
3077
+ );
3078
+ }
3079
+ function relativeMatrix(object, host) {
3080
+ return import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object));
3081
+ }
3082
+ function applyRelativeMatrix(object, host, rel) {
3083
+ applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), rel));
3084
+ }
3085
+ function asObject(clip) {
3086
+ return clip;
3087
+ }
3088
+ function toMatrix(values) {
3089
+ if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
3090
+ return null;
3091
+ return [values[0], values[1], values[2], values[3], values[4], values[5]];
3092
+ }
3093
+ function fitToBox(object, box, zoom = 1) {
3094
+ const width = Math.max(1, box.width) * zoom;
3095
+ const height = Math.max(1, box.height) * zoom;
3096
+ object.set({
3097
+ originX: "center",
3098
+ originY: "center",
3099
+ angle: 0,
3100
+ skewX: 0,
3101
+ skewY: 0,
3102
+ left: box.left + box.width / 2,
3103
+ top: box.top + box.height / 2,
3104
+ scaleX: width / Math.max(1, object.width ?? 1),
3105
+ scaleY: height / Math.max(1, object.height ?? 1)
3106
+ });
3107
+ object.setCoords();
3108
+ }
3109
+ function unwrapGroup(group) {
3110
+ const children = group.removeAll();
3111
+ for (const child of children) child.setCoords();
3112
+ return children;
3113
+ }
3114
+
3115
+ // src/masks/edit.ts
3116
+ var MaskEditController = class {
3117
+ constructor(canvas, onCommit) {
3118
+ this.canvas = canvas;
3119
+ this.onCommit = onCommit;
3120
+ }
3121
+ canvas;
3122
+ onCommit;
3123
+ handle = null;
3124
+ editing = null;
3125
+ child = null;
3126
+ group = null;
3127
+ active() {
3128
+ return this.editing;
3129
+ }
3130
+ /** Put a handle on the canvas for `child`, an object inside `group`. */
3131
+ async begin(edit, group, child) {
3132
+ this.end();
3133
+ const handle = await child.clone();
3134
+ applyMatrix(handle, matrixOf(child));
3135
+ handle.set({
3136
+ // Outline only: the mask's own fill would sit over the artwork it is
3137
+ // supposed to be revealing.
3138
+ fill: "rgba(0,0,0,0.001)",
3139
+ stroke: "#e0b055",
3140
+ strokeWidth: 2,
3141
+ strokeDashArray: [6, 4],
3142
+ strokeUniform: true,
3143
+ opacity: 1,
3144
+ selectable: true,
3145
+ evented: true,
3146
+ hasControls: true,
3147
+ hasBorders: true,
3148
+ objectCaching: false,
3149
+ excludeFromExport: true
3150
+ });
3151
+ this.handle = handle;
3152
+ this.editing = edit;
3153
+ this.child = child;
3154
+ this.group = group;
3155
+ this.canvas.add(handle);
3156
+ this.canvas.setActiveObject(handle);
3157
+ this.canvas.on("object:moving", this.onTransform);
3158
+ this.canvas.on("object:scaling", this.onTransform);
3159
+ this.canvas.on("object:rotating", this.onTransform);
3160
+ this.canvas.on("object:modified", this.onModified);
3161
+ this.canvas.requestRenderAll();
3162
+ return true;
3163
+ }
3164
+ /** Take the handle down. The geometry it wrote is already in the clip. */
3165
+ end() {
3166
+ if (!this.handle) return;
3167
+ this.canvas.off("object:moving", this.onTransform);
3168
+ this.canvas.off("object:scaling", this.onTransform);
3169
+ this.canvas.off("object:rotating", this.onTransform);
3170
+ this.canvas.off("object:modified", this.onModified);
3171
+ if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
3172
+ this.canvas.remove(this.handle);
3173
+ this.handle = null;
3174
+ this.editing = null;
3175
+ this.child = null;
3176
+ this.group = null;
3177
+ this.canvas.requestRenderAll();
3178
+ }
3179
+ dispose() {
3180
+ this.end();
3181
+ }
3182
+ onTransform = (event) => {
3183
+ if (!this.handle || event.target !== this.handle) return;
3184
+ this.write();
3185
+ };
3186
+ onModified = (event) => {
3187
+ if (!this.handle || event.target !== this.handle) return;
3188
+ this.write();
3189
+ this.onCommit();
3190
+ };
3191
+ /** Handle transform (canvas space) → clip child transform (group-relative). */
3192
+ write() {
3193
+ if (!this.handle || !this.child || !this.group) return;
3194
+ applyMatrix(
3195
+ this.child,
3196
+ import_fabric12.util.multiplyTransformMatrices(
3197
+ import_fabric12.util.invertTransform(matrixOf(this.group)),
3198
+ matrixOf(this.handle)
3199
+ )
3200
+ );
3201
+ this.group.dirty = true;
3202
+ this.group.set({ dirty: true });
3203
+ this.canvas.requestRenderAll();
3204
+ }
3205
+ };
3206
+
3207
+ // src/masks/store.ts
3208
+ var import_fabric15 = require("fabric");
3209
+
3210
+ // src/masks/host.ts
3211
+ var import_fabric13 = require("fabric");
3212
+ function findCanvasHost(layers) {
3213
+ return layers.getAll().find((layer) => layer.meta.canvasMask);
3214
+ }
3215
+ function createCanvasHost(canvas, layers) {
3216
+ const rect = new import_fabric13.Rect({
3217
+ left: 0,
3218
+ top: 0,
3219
+ width: canvas.getWidth(),
3220
+ height: canvas.getHeight(),
3221
+ originX: "left",
3222
+ originY: "top",
3223
+ fill: "#000000",
3224
+ globalCompositeOperation: "destination-in",
3225
+ // It is edited from the layer list, never on the canvas: a drag box on
3226
+ // something that cannot be dragged only reads as broken.
3227
+ selectable: false,
3228
+ evented: false,
3229
+ hasControls: false,
3230
+ hasBorders: false,
3231
+ objectCaching: false
3232
+ });
3233
+ const layer = layers.add("mask", rect, "Design mask");
3234
+ layer.meta.canvasMask = true;
3235
+ layers.setLocked(layer.id, true);
3236
+ return layer;
3237
+ }
3238
+ function pinCanvasHost(layers) {
3239
+ const all = layers.getAll();
3240
+ const hostIndex = all.findIndex((layer) => layer.meta.canvasMask);
3241
+ if (hostIndex === -1) return false;
3242
+ let lastContent = -1;
3243
+ all.forEach((layer, index) => {
3244
+ if (layer.type !== "mask") lastContent = index;
3245
+ });
3246
+ if (hostIndex >= lastContent) return false;
3247
+ layers.reorder(all[hostIndex].id, all.length - 1);
3248
+ return true;
3249
+ }
3250
+ function hostBoxOf(canvas, host, absolute) {
3251
+ if (!host) {
3252
+ return { left: 0, top: 0, width: canvas.getWidth(), height: canvas.getHeight() };
3253
+ }
3254
+ if (absolute) {
3255
+ host.setCoords();
3256
+ const rect = host.getBoundingRect();
3257
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
3258
+ }
3259
+ const width = Math.max(1, host.width ?? 1);
3260
+ const height = Math.max(1, host.height ?? 1);
3261
+ return { left: -width / 2, top: -height / 2, width, height };
3262
+ }
3263
+
3264
+ // src/masks/install.ts
3265
+ var import_fabric14 = require("fabric");
3266
+ function convertSpace(host, sources, absolute) {
3267
+ const wasAbsolute = host.clipPath instanceof import_fabric14.Group ? host.clipPath.absolutePositioned : absolute;
3268
+ if (absolute === wasAbsolute) return;
3269
+ for (const source of sources) {
3270
+ if (absolute) toCanvasSpace(source, host);
3271
+ else toHostSpace(source, host);
3272
+ }
3273
+ }
3274
+ function withRelativeTransforms(entries, sources, host, absolute) {
3275
+ return entries.map((entry, index) => {
3276
+ const source = sources[index];
3277
+ if (absolute && entry.linked && source) {
3278
+ return { ...entry, rel: [...relativeMatrix(source, host)] };
3279
+ }
3280
+ if (!entry.rel) return entry;
3281
+ const rest = { ...entry };
3282
+ delete rest.rel;
3283
+ return rest;
3284
+ });
3285
+ }
3286
+ function installClip(host, entries, sources, box, absolute) {
3287
+ host.clipPath = composeMaskGroup(sources, entries, { box, absolute });
3288
+ host.dirty = true;
3289
+ host.setCoords();
3290
+ }
3291
+
3292
+ // src/masks/store.ts
3293
+ var CANVAS_MASK_TARGET = "canvas";
3294
+ var DEFAULT_ENTRY = {
3295
+ mode: "add",
3296
+ linked: true,
3297
+ visible: true,
3298
+ opacity: 1
3299
+ };
3300
+ var MaskStackStore = class {
3301
+ constructor(canvas, layers, history, events) {
3302
+ this.canvas = canvas;
3303
+ this.layers = layers;
3304
+ this.history = history;
3305
+ this.events = events;
3306
+ }
3307
+ canvas;
3308
+ layers;
3309
+ history;
3310
+ events;
3311
+ selected = null;
3312
+ pinning = false;
3313
+ list(target) {
3314
+ return this.hostLayer(target)?.meta.maskStack ?? [];
3315
+ }
3316
+ get(target, maskId) {
3317
+ return this.list(target).find((entry) => entry.id === maskId);
3318
+ }
3319
+ /** Every target that currently carries at least one mask. */
3320
+ targets() {
3321
+ return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
3322
+ }
3323
+ /** The box a mask is fitted to, in the space the stack is composed in. */
3324
+ hostBox(target, absolute = target === CANVAS_MASK_TARGET || needsAbsoluteSpace(this.list(target))) {
3325
+ return hostBoxOf(this.canvas, this.host(target), absolute);
3326
+ }
3327
+ /** The host layer of a target, optionally creating the design overlay. */
3328
+ hostLayer(target, create = false) {
3329
+ if (target !== CANVAS_MASK_TARGET) return this.layers.get(target);
3330
+ const existing = findCanvasHost(this.layers);
3331
+ if (existing || !create) return existing;
3332
+ return createCanvasHost(this.canvas, this.layers);
3333
+ }
3334
+ host(target, create = false) {
3335
+ return this.hostLayer(target, create)?.fabricObject ?? null;
3336
+ }
3337
+ /** Re-pin the design overlay, guarding the reorder that re-triggers this. */
3338
+ pin() {
3339
+ if (this.pinning) return;
3340
+ this.pinning = true;
3341
+ try {
3342
+ pinCanvasHost(this.layers);
3343
+ } finally {
3344
+ this.pinning = false;
3345
+ }
3346
+ }
3347
+ /**
3348
+ * Take the current geometry back out of the composed clip, entry-aligned.
3349
+ *
3350
+ * The entry list is the authority on how to read the clip: with no entries the
3351
+ * clip predates the stack (a mask preset, or a single `clipPath` an older host
3352
+ * installed) and is one mask whole — including when it happens to be a group,
3353
+ * which is why this cannot just unwrap anything group-shaped.
3354
+ */
3355
+ unwrap(target, host) {
3356
+ const clip = host.clipPath;
3357
+ if (!clip) return [];
3358
+ const entries = this.list(target);
3359
+ if (entries.length === 0 || !(clip instanceof import_fabric15.Group)) return [asObject(clip)];
3360
+ const children = unwrapGroup(clip);
3361
+ const extra = children.length - entries.length;
3362
+ return extra > 0 ? children.slice(extra) : children;
3363
+ }
3364
+ /**
3365
+ * Entries for a stack, adopting a pre-stack clip as the first one. Without
3366
+ * this, the first `add()` on an already-masked layer would compose a clip it
3367
+ * has no entry for and silently throw that mask away.
3368
+ */
3369
+ entriesFor(target, sources) {
3370
+ const existing = this.list(target);
3371
+ if (existing.length > 0 || sources.length !== 1) return [...existing];
3372
+ const layer = this.hostLayer(target);
3373
+ const preset = layer?.meta.maskPreset;
3374
+ if (layer) {
3375
+ delete layer.meta.maskPreset;
3376
+ }
3377
+ return [
3378
+ {
3379
+ ...DEFAULT_ENTRY,
3380
+ id: generateId(),
3381
+ name: typeof preset === "string" ? preset : "Mask",
3382
+ linked: target === CANVAS_MASK_TARGET ? false : !sources[0].absolutePositioned
3383
+ }
3384
+ ];
3385
+ }
3386
+ /**
3387
+ * Install a stack: convert geometry into the space the stack needs, compose the
3388
+ * clip, store the entries, and commit one history checkpoint for the lot.
3389
+ */
3390
+ commit(target, host, entries, sources, save = true, forceAbsolute = false) {
3391
+ const layer = this.hostLayer(target);
3392
+ if (!layer) return;
3393
+ const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
3394
+ convertSpace(host, sources, absolute);
3395
+ const next = withRelativeTransforms(entries, sources, host, absolute);
3396
+ installClip(host, next, sources, this.hostBox(target, absolute), absolute);
3397
+ if (next.length === 0) {
3398
+ delete layer.meta.maskStack;
3399
+ if (layer.meta.canvasMask) this.layers.remove(layer.id);
3400
+ } else {
3401
+ layer.meta.maskStack = next;
3402
+ }
3403
+ this.canvas.requestRenderAll();
3404
+ this.events.emit("masks:changed", { target });
3405
+ this.events.emit("layer:modified", { layerId: layer.id });
3406
+ if (save) this.history.save();
3407
+ }
3408
+ };
3409
+
3410
+ // src/masks/manager.ts
3411
+ var LayerMaskManager = class extends MaskStackStore {
3412
+ // Committing mid-drag would recompose the group the handle writes into and
3413
+ // leave it pointing at a discarded object; the geometry is settled on endEdit.
3414
+ edits = new MaskEditController(this.canvas, () => this.history.save());
3415
+ onLayersChanged = () => this.pin();
3416
+ // Selecting a layer means the user has moved on from the mask they had open;
3417
+ // leaving both selected would show mask controls for an unrelated layer.
3418
+ onLayerSelected = () => {
3419
+ if (this.selected) this.select(null, null);
3420
+ };
3421
+ onObjectModified = (event) => {
3422
+ const object = event.target;
3423
+ if (!object) return;
3424
+ const layer = this.layers.findByObject(object);
3425
+ if (layer) this.reflow(layer.id);
3426
+ };
3427
+ constructor(canvas, layers, history, events) {
3428
+ super(canvas, layers, history, events);
3429
+ this.events.on("layers:changed", this.onLayersChanged);
3430
+ this.events.on("layer:selected", this.onLayerSelected);
3431
+ this.canvas.on("object:modified", this.onObjectModified);
3432
+ }
3433
+ dispose() {
3434
+ this.edits.dispose();
3435
+ this.events.off("layers:changed", this.onLayersChanged);
3436
+ this.events.off("layer:selected", this.onLayerSelected);
3437
+ this.canvas.off("object:modified", this.onObjectModified);
3438
+ this.selected = null;
3439
+ }
3440
+ // ─── Geometry editing ────────────────────────────────
3441
+ /** The mask currently being dragged on the canvas, if any. */
3442
+ editing() {
3443
+ return this.edits.active();
3444
+ }
3445
+ /**
3446
+ * Put drag handles on one mask. The stack is composed in canvas space for the
3447
+ * duration — a linked mask would otherwise sit in the host's space, where the
3448
+ * handle's own canvas coordinates mean something else entirely. `endEdit`
3449
+ * returns it to whichever space its entries call for.
3450
+ */
3451
+ async beginEdit(target, maskId) {
3452
+ const host = this.host(target);
3453
+ if (!host) return false;
3454
+ const entries = this.list(target);
3455
+ const index = entries.findIndex((entry) => entry.id === maskId);
3456
+ if (index === -1) return false;
3457
+ this.endEdit(false);
3458
+ this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3459
+ const group = host.clipPath instanceof import_fabric16.Group ? host.clipPath : null;
3460
+ if (!group) return false;
3461
+ const children = group.getObjects();
3462
+ const child = children[children.length - entries.length + index];
3463
+ if (!child) return false;
3464
+ this.select(target, maskId);
3465
+ return this.edits.begin({ target, maskId }, group, child);
3466
+ }
3467
+ /** Take the handles down and settle the stack back into its own space. */
3468
+ endEdit(save = true) {
3469
+ const editing = this.edits.active();
3470
+ this.edits.end();
3471
+ if (editing) this.rebuild(editing.target, save);
3472
+ }
3473
+ // ─── Selection ───────────────────────────────────────
3474
+ getSelected() {
3475
+ return this.selected;
3476
+ }
3477
+ select(target, maskId) {
3478
+ this.selected = target && maskId ? { target, maskId } : null;
3479
+ this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
3480
+ }
3481
+ // ─── Mutations ───────────────────────────────────────
3482
+ add(target, object, options = {}) {
3483
+ const host = this.host(target, true);
3484
+ if (!host) return null;
3485
+ const sources = this.unwrap(target, host);
3486
+ const entries = this.entriesFor(target, sources);
3487
+ const entry = {
3488
+ ...DEFAULT_ENTRY,
3489
+ id: generateId(),
3490
+ name: options.name ?? "Mask",
3491
+ ...options.mode ? { mode: options.mode } : {},
3492
+ ...options.linked === void 0 ? {} : { linked: options.linked },
3493
+ ...options.meta ? { meta: options.meta } : {}
3494
+ };
3495
+ if (target === CANVAS_MASK_TARGET) entry.linked = false;
3496
+ if (options.fit !== false) {
3497
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3498
+ }
3499
+ sources.push(object);
3500
+ entries.push(entry);
3501
+ this.commit(target, host, entries, sources);
3502
+ return entry;
3503
+ }
3504
+ /** Swap one mask's geometry, keeping its identity, mode and position in the stack. */
3505
+ replaceGeometry(target, maskId, object, options = {}) {
3506
+ const host = this.host(target);
3507
+ if (!host) return false;
3508
+ const entries = [...this.list(target)];
3509
+ const index = entries.findIndex((entry) => entry.id === maskId);
3510
+ if (index === -1) return false;
3511
+ const sources = this.unwrap(target, host);
3512
+ if (options.fit !== false) {
3513
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3514
+ }
3515
+ sources[index] = object;
3516
+ entries[index] = {
3517
+ ...entries[index],
3518
+ ...options.name ? { name: options.name } : {},
3519
+ ...options.meta ? { meta: options.meta } : {}
3520
+ };
3521
+ this.commit(target, host, entries, sources);
3522
+ return true;
3523
+ }
3524
+ /**
3525
+ * Re-fit one mask to its host's box (or `box`) at `fit` scale, keeping the
3526
+ * geometry it already has. This is what a zoom control drives: re-picking the
3527
+ * mask would cost another render of a generated alpha map just to resize it.
3528
+ */
3529
+ refit(target, maskId, options = {}) {
3530
+ const host = this.host(target);
3531
+ if (!host) return false;
3532
+ const entries = [...this.list(target)];
3533
+ const index = entries.findIndex((entry) => entry.id === maskId);
3534
+ if (index === -1) return false;
3535
+ const sources = this.unwrap(target, host);
3536
+ const source = sources[index];
3537
+ if (!source) return false;
3538
+ fitToBox(source, options.box ?? this.hostBox(target), options.fit ?? 1);
3539
+ this.commit(target, host, entries, sources, options.save ?? true);
3540
+ return true;
3541
+ }
3542
+ remove(target, maskId) {
3543
+ const host = this.host(target);
3544
+ if (!host) return false;
3545
+ const entries = [...this.list(target)];
3546
+ const index = entries.findIndex((entry) => entry.id === maskId);
3547
+ if (index === -1) return false;
3548
+ const sources = this.unwrap(target, host);
3549
+ entries.splice(index, 1);
3550
+ sources.splice(index, 1);
3551
+ if (this.selected?.maskId === maskId) this.select(null, null);
3552
+ this.commit(target, host, entries, sources);
3553
+ return true;
3554
+ }
3555
+ clear(target) {
3556
+ const host = this.host(target);
3557
+ if (!host) return false;
3558
+ if (this.list(target).length === 0) return false;
3559
+ if (this.selected?.target === target) this.select(null, null);
3560
+ this.commit(target, host, [], []);
3561
+ return true;
3562
+ }
3563
+ reorder(target, maskId, index) {
3564
+ const host = this.host(target);
3565
+ if (!host) return false;
3566
+ const entries = [...this.list(target)];
3567
+ const from = entries.findIndex((entry2) => entry2.id === maskId);
3568
+ if (from === -1) return false;
3569
+ const to = Math.max(0, Math.min(entries.length - 1, Math.round(index)));
3570
+ if (from === to) return false;
3571
+ const sources = this.unwrap(target, host);
3572
+ const [entry] = entries.splice(from, 1);
3573
+ const [source] = sources.splice(from, 1);
3574
+ entries.splice(to, 0, entry);
3575
+ sources.splice(to, 0, source);
3576
+ this.commit(target, host, entries, sources);
3577
+ return true;
3578
+ }
3579
+ /** Replace a mask's host metadata (which preset or generator produced it). */
3580
+ setMeta(target, maskId, meta) {
3581
+ return this.patch(target, maskId, () => ({ meta }));
3582
+ }
3583
+ setMode(target, maskId, mode) {
3584
+ return this.patch(target, maskId, (entry) => entry.mode === mode ? null : { mode });
3585
+ }
3586
+ setVisible(target, maskId, visible) {
3587
+ return this.patch(target, maskId, (entry) => entry.visible === visible ? null : { visible });
3588
+ }
3589
+ setOpacity(target, maskId, opacity) {
3590
+ if (!Number.isFinite(opacity)) return false;
3591
+ const next = Math.max(0, Math.min(1, opacity));
3592
+ return this.patch(
3593
+ target,
3594
+ maskId,
3595
+ (entry) => entry.opacity === next ? null : { opacity: next }
3596
+ );
3597
+ }
3598
+ setName(target, maskId, name) {
3599
+ const trimmed = name.trim();
3600
+ if (!trimmed) return false;
3601
+ return this.patch(
3602
+ target,
3603
+ maskId,
3604
+ (entry) => entry.name === trimmed ? null : { name: trimmed }
3605
+ );
3606
+ }
3607
+ /**
3608
+ * Link or unlink one mask. Unlinking pins it where it currently appears;
3609
+ * re-linking records its position relative to the host so later host moves
3610
+ * carry it along.
3611
+ */
3612
+ setLinked(target, maskId, linked) {
3613
+ if (target === CANVAS_MASK_TARGET) return false;
3614
+ const host = this.host(target);
3615
+ if (!host) return false;
3616
+ const entries = [...this.list(target)];
3617
+ const index = entries.findIndex((entry) => entry.id === maskId);
3618
+ if (index === -1 || entries[index].linked === linked) return false;
3619
+ const sources = this.unwrap(target, host);
3620
+ entries[index] = { ...entries[index], linked };
3621
+ this.commit(target, host, entries, sources);
3622
+ return true;
3623
+ }
3624
+ /**
3625
+ * Consume a layer, turning its artwork into a mask. Without an explicit target
3626
+ * it masks the layer directly beneath it, and the bottom layer masks the whole
3627
+ * design — there is nothing under it to clip.
3628
+ */
3629
+ convertLayer(layerId, target) {
3630
+ const layer = this.layers.get(layerId);
3631
+ if (!layer || layer.meta.canvasMask) return null;
3632
+ const ordered = this.layers.getAll();
3633
+ const index = ordered.findIndex((candidate) => candidate.id === layerId);
3634
+ const below = index > 0 ? ordered[index - 1] : void 0;
3635
+ const resolved = target ?? (below && !below.meta.canvasMask ? below.id : CANVAS_MASK_TARGET);
3636
+ if (resolved === layerId) return null;
3637
+ const object = layer.fabricObject;
3638
+ if (!object.fill && object.type !== "image") object.set({ fill: "#000000" });
3639
+ this.history.beginTransaction();
3640
+ try {
3641
+ if (this.canvas.getActiveObject() === object) this.canvas.discardActiveObject();
3642
+ this.canvas.remove(object);
3643
+ this.layers.remove(layerId);
3644
+ const entry = this.add(resolved, object, { name: layer.name, fit: false });
3645
+ return entry ? { target: resolved, entry } : null;
3646
+ } finally {
3647
+ this.history.endTransaction();
3648
+ }
3649
+ }
3650
+ // ─── Rebuild / restore ───────────────────────────────
3651
+ /** Recompose one stack's clip from the geometry it already holds. */
3652
+ rebuild(target, save = false) {
3653
+ const host = this.host(target);
3654
+ if (!host) return;
3655
+ const sources = this.unwrap(target, host);
3656
+ if (sources.length === 0) return;
3657
+ this.commit(target, host, this.entriesFor(target, sources), sources, save);
3658
+ }
3659
+ /** Re-derive linked masks after the host moved (canvas-space stacks only). */
3660
+ reflow(target) {
3661
+ const host = this.host(target);
3662
+ if (!host) return;
3663
+ const entries = this.list(target);
3664
+ if (entries.length === 0 || !needsAbsoluteSpace(entries)) return;
3665
+ if (!entries.some((entry) => entry.linked && entry.rel)) return;
3666
+ const sources = this.unwrap(target, host);
3667
+ entries.forEach((entry, index) => {
3668
+ const source = sources[index];
3669
+ const rel = toMatrix(entry.rel);
3670
+ if (!source || !entry.linked || !rel) return;
3671
+ applyRelativeMatrix(source, host, rel);
3672
+ });
3673
+ this.commit(target, host, [...entries], sources);
3674
+ }
3675
+ /**
3676
+ * Adopt a clip this manager did not install — a host's own single `clipPath` —
3677
+ * as a one-entry stack, so it shows up in the UI as a mask row before anything
3678
+ * is added to it. A mask preset is left alone unless asked for by name: it is
3679
+ * still owned by `MaskPresetManager` until a stack operation takes it over.
3680
+ */
3681
+ adopt(target, name) {
3682
+ const layer = this.hostLayer(target);
3683
+ const clip = layer?.fabricObject.clipPath;
3684
+ if (!layer || !clip || layer.meta.pattern) return null;
3685
+ if ((layer.meta.maskStack ?? []).length > 0) return null;
3686
+ const entry = {
3687
+ ...DEFAULT_ENTRY,
3688
+ id: generateId(),
3689
+ name: name ?? "Mask",
3690
+ linked: target === CANVAS_MASK_TARGET ? false : !clip.absolutePositioned
3691
+ };
3692
+ layer.meta.maskStack = [entry];
3693
+ delete layer.meta.maskPreset;
3694
+ this.rebuild(target);
3695
+ return entry;
3696
+ }
3697
+ /** After a state restore: re-pin the design overlay and recompose every stack. */
3698
+ refreshAll() {
3699
+ this.selected = null;
3700
+ this.pin();
3701
+ for (const layer of this.layers.getAll()) {
3702
+ if ((layer.meta.maskStack ?? []).length === 0) continue;
3703
+ this.rebuild(layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
3704
+ }
3705
+ }
3706
+ // ─── Internals ───────────────────────────────────────
3707
+ patch(target, maskId, change) {
3708
+ const host = this.host(target);
3709
+ if (!host) return false;
3710
+ const entries = [...this.list(target)];
3711
+ const index = entries.findIndex((entry) => entry.id === maskId);
3712
+ if (index === -1) return false;
3713
+ const patch = change(entries[index]);
3714
+ if (!patch) return false;
3715
+ const sources = this.unwrap(target, host);
3716
+ entries[index] = { ...entries[index], ...patch };
3717
+ this.commit(target, host, entries, sources);
3718
+ return true;
3719
+ }
3720
+ };
3721
+
2855
3722
  // src/editor.ts
2856
3723
  var MIN_ZOOM = 0.1;
2857
3724
  var MAX_ZOOM = 8;
@@ -2869,6 +3736,8 @@ var CanvasEditor = class {
2869
3736
  patterns;
2870
3737
  curves;
2871
3738
  maskPresets;
3739
+ /** Stacked boolean masks, per layer and for the design as a whole. */
3740
+ layerMasks;
2872
3741
  fonts;
2873
3742
  licensing;
2874
3743
  pages;
@@ -2893,7 +3762,7 @@ var CanvasEditor = class {
2893
3762
  const widthPx = this.units.toPixels(config.width);
2894
3763
  const heightPx = this.units.toPixels(config.height);
2895
3764
  this.designBackground = config.backgroundColor ?? "#ffffff";
2896
- this.canvas = new import_fabric10.Canvas(canvasElement, {
3765
+ this.canvas = new import_fabric17.Canvas(canvasElement, {
2897
3766
  width: widthPx,
2898
3767
  height: heightPx,
2899
3768
  backgroundColor: this.designBackground,
@@ -2917,6 +3786,7 @@ var CanvasEditor = class {
2917
3786
  this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2918
3787
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2919
3788
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
3789
+ this.layerMasks = new LayerMaskManager(this.canvas, this.layers, this.history, this.events);
2920
3790
  this.setupCanvasEvents();
2921
3791
  this.refreshSelectionStyle();
2922
3792
  this.history.saveImmediate();
@@ -2926,12 +3796,13 @@ var CanvasEditor = class {
2926
3796
  // ─── Layer Operations ────────────────────────────────
2927
3797
  async addImage(url, options) {
2928
3798
  try {
2929
- const img = await import_fabric10.FabricImage.fromURL(
3799
+ const img = await import_fabric17.FabricImage.fromURL(
2930
3800
  url,
2931
3801
  {},
2932
3802
  { originX: "left", originY: "top", ...options }
2933
3803
  );
2934
3804
  const layer = this.layers.add("image", img);
3805
+ this.layers.select(layer.id);
2935
3806
  this.history.save();
2936
3807
  return layer;
2937
3808
  } catch (error) {
@@ -2951,7 +3822,7 @@ var CanvasEditor = class {
2951
3822
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
2952
3823
  const previous = layer.fabricObject;
2953
3824
  try {
2954
- const replacement = await import_fabric10.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
3825
+ const replacement = await import_fabric17.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2955
3826
  replacement.set({
2956
3827
  left: previous.left,
2957
3828
  top: previous.top,
@@ -2980,7 +3851,7 @@ var CanvasEditor = class {
2980
3851
  }
2981
3852
  }
2982
3853
  addText(text, options) {
2983
- const textbox = new import_fabric10.Textbox(text, {
3854
+ const textbox = new import_fabric17.Textbox(text, {
2984
3855
  fontSize: 32,
2985
3856
  fontFamily: "Arial",
2986
3857
  fill: "#000000",
@@ -2991,12 +3862,14 @@ var CanvasEditor = class {
2991
3862
  ...options
2992
3863
  });
2993
3864
  const layer = this.layers.add("text", textbox);
3865
+ this.layers.select(layer.id);
2994
3866
  this.history.save();
2995
3867
  return layer;
2996
3868
  }
2997
3869
  addShape(plugin, options) {
2998
3870
  const obj = plugin.create(options);
2999
3871
  const layer = this.layers.add("shape", obj, plugin.name);
3872
+ this.layers.select(layer.id);
3000
3873
  this.history.save();
3001
3874
  return layer;
3002
3875
  }
@@ -3019,10 +3892,10 @@ var CanvasEditor = class {
3019
3892
  return value === void 0 ? token : escapeXml(value);
3020
3893
  })
3021
3894
  );
3022
- const { objects, options } = await (0, import_fabric10.loadSVGFromString)(resolved);
3895
+ const { objects, options } = await (0, import_fabric17.loadSVGFromString)(resolved);
3023
3896
  const validObjects = objects.filter((object) => object !== null);
3024
3897
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
3025
- const group = import_fabric10.util.groupSVGElements(validObjects, options);
3898
+ const group = import_fabric17.util.groupSVGElements(validObjects, options);
3026
3899
  group.set({
3027
3900
  left: this.canvas.getWidth() / 2,
3028
3901
  top: this.canvas.getHeight() / 2,
@@ -3030,6 +3903,7 @@ var CanvasEditor = class {
3030
3903
  originY: "center"
3031
3904
  });
3032
3905
  const layer = this.layers.add("template", group, template.name);
3906
+ this.layers.select(layer.id);
3033
3907
  this.history.save();
3034
3908
  return layer;
3035
3909
  }
@@ -3081,7 +3955,7 @@ var CanvasEditor = class {
3081
3955
  opacity: layer.opacity
3082
3956
  });
3083
3957
  this.patterns.attachTo(copy);
3084
- this.canvas.setActiveObject(clone);
3958
+ this.layers.select(copy.id);
3085
3959
  this.canvas.requestRenderAll();
3086
3960
  this.history.save();
3087
3961
  return copy;
@@ -3094,10 +3968,10 @@ var CanvasEditor = class {
3094
3968
  const next = { ...previous, ...adjustments };
3095
3969
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
3096
3970
  image.filters = [
3097
- new import_fabric10.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
3098
- new import_fabric10.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
3099
- new import_fabric10.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
3100
- new import_fabric10.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
3971
+ new import_fabric17.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
3972
+ new import_fabric17.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
3973
+ new import_fabric17.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
3974
+ new import_fabric17.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
3101
3975
  ];
3102
3976
  layer.meta.imageAdjustments = next;
3103
3977
  image.applyFilters();
@@ -3114,7 +3988,7 @@ var CanvasEditor = class {
3114
3988
  const childData = children.map((layer) => structuredClone(layer.toData()));
3115
3989
  const objects = children.map((layer) => layer.fabricObject);
3116
3990
  for (const layer of children) this.layers.remove(layer.id);
3117
- const group = new import_fabric10.Group(objects);
3991
+ const group = new import_fabric17.Group(objects);
3118
3992
  const grouped = this.layers.add("group", group, name);
3119
3993
  grouped.meta.groupChildren = childData;
3120
3994
  this.layers.select(grouped.id);
@@ -3129,11 +4003,9 @@ var CanvasEditor = class {
3129
4003
  if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
3130
4004
  const group = grouped.fabricObject;
3131
4005
  return this.history.transaction(() => {
3132
- const transform = group.calcTransformMatrix();
3133
4006
  const objects = group.removeAll();
3134
4007
  this.layers.remove(id);
3135
4008
  const restored = objects.map((object, index) => {
3136
- import_fabric10.util.addTransformToObject(object, transform);
3137
4009
  object.setCoords();
3138
4010
  const data = childData[index];
3139
4011
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -3161,6 +4033,7 @@ var CanvasEditor = class {
3161
4033
  try {
3162
4034
  await deserializeEditor(this, state);
3163
4035
  await this.patterns.rehydrateAll();
4036
+ this.layerMasks.refreshAll();
3164
4037
  } catch (error) {
3165
4038
  if (!managedByHistory) {
3166
4039
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -3189,7 +4062,7 @@ var CanvasEditor = class {
3189
4062
  const layer = this.layers.get(id);
3190
4063
  if (!layer) throw new Error(`Layer not found: ${id}`);
3191
4064
  try {
3192
- if (options.resolution === "source" && layer.fabricObject instanceof import_fabric10.FabricImage) {
4065
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric17.FabricImage) {
3193
4066
  const image = await layer.fabricObject.clone();
3194
4067
  image.set({
3195
4068
  left: 0,
@@ -3465,7 +4338,7 @@ var CanvasEditor = class {
3465
4338
  return;
3466
4339
  }
3467
4340
  try {
3468
- const image = await import_fabric10.FabricImage.fromURL(
4341
+ const image = await import_fabric17.FabricImage.fromURL(
3469
4342
  url,
3470
4343
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
3471
4344
  { originX: "left", originY: "top" }
@@ -3733,6 +4606,7 @@ var CanvasEditor = class {
3733
4606
  // ─── Cleanup ────────────────────────────────────────
3734
4607
  dispose() {
3735
4608
  this.masks.dispose();
4609
+ this.layerMasks.dispose();
3736
4610
  this.snapping.dispose();
3737
4611
  this.crop.dispose();
3738
4612
  this.history.dispose();
@@ -3856,6 +4730,7 @@ var AnnotationOverlay = class {
3856
4730
  // Annotate the CommonJS export names for ESM import in node:
3857
4731
  0 && (module.exports = {
3858
4732
  AnnotationOverlay,
4733
+ CANVAS_MASK_TARGET,
3859
4734
  CANVAS_SIZE_PRESETS,
3860
4735
  CanvasEditor,
3861
4736
  CropController,
@@ -3868,6 +4743,7 @@ var AnnotationOverlay = class {
3868
4743
  HistoryManager,
3869
4744
  Layer,
3870
4745
  LayerManager,
4746
+ LayerMaskManager,
3871
4747
  LicenseManager,
3872
4748
  MaskController,
3873
4749
  MaskPresetManager,
@@ -3889,6 +4765,7 @@ var AnnotationOverlay = class {
3889
4765
  buildCurvePathData,
3890
4766
  clamp,
3891
4767
  clearTextureMaskCache,
4768
+ composeMaskGroup,
3892
4769
  computeCoverPlacement,
3893
4770
  computePrintAreaClip,
3894
4771
  computeTilePositions,
@@ -3901,11 +4778,13 @@ var AnnotationOverlay = class {
3901
4778
  exportPNG,
3902
4779
  exportPrintArea,
3903
4780
  exportSVG,
4781
+ fitToBox,
3904
4782
  generateId,
3905
4783
  isCssColor,
3906
4784
  isMaskPresetId,
3907
4785
  isShapeMaskId,
3908
4786
  isTextureMaskId,
4787
+ needsAbsoluteSpace,
3909
4788
  readLayerShadow,
3910
4789
  renderTextureMask,
3911
4790
  resetTransform,
@@ -3913,6 +4792,9 @@ var AnnotationOverlay = class {
3913
4792
  round2,
3914
4793
  sanitizeSvg,
3915
4794
  serializeEditor,
3916
- shapeMaskPathData
4795
+ shapeMaskPathData,
4796
+ toCanvasSpace,
4797
+ toHostSpace,
4798
+ unwrapGroup
3917
4799
  });
3918
4800
  //# sourceMappingURL=index.js.map