@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.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-MCBRZQ4M.mjs";
12
12
 
13
13
  // src/editor.ts
14
- import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
14
+ import { Canvas, FabricImage as FabricImage3, Group as Group6, Textbox, filters, loadSVGFromString, util as util5 } from "fabric";
15
15
 
16
16
  // src/events.ts
17
17
  var EventEmitter = class {
@@ -173,7 +173,13 @@ var LayerManager = class {
173
173
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
174
174
  layer.renderProxy = proxy;
175
175
  if (proxy) {
176
- proxy.set({ visible: layer.visible, opacity: layer.opacity });
176
+ proxy._layerId = id;
177
+ proxy.set({
178
+ visible: layer.visible,
179
+ opacity: layer.opacity,
180
+ selectable: !layer.locked,
181
+ evented: !layer.locked
182
+ });
177
183
  this.canvas.add(proxy);
178
184
  this.syncZOrder();
179
185
  }
@@ -212,7 +218,7 @@ var LayerManager = class {
212
218
  } else {
213
219
  const layer = this.get(id);
214
220
  if (layer) {
215
- this.canvas.setActiveObject(layer.fabricObject);
221
+ this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
216
222
  }
217
223
  }
218
224
  this.canvas.requestRenderAll();
@@ -247,8 +253,9 @@ var LayerManager = class {
247
253
  if (!layer) return;
248
254
  if (layer.locked === locked) return;
249
255
  layer.locked = locked;
250
- layer.fabricObject.selectable = !locked;
251
- layer.fabricObject.evented = !locked;
256
+ const target = layer.renderProxy ?? layer.fabricObject;
257
+ target.selectable = !locked;
258
+ target.evented = !locked;
252
259
  this.canvas.requestRenderAll();
253
260
  this.emitChanged();
254
261
  this.onPropertyChanged?.();
@@ -846,7 +853,7 @@ var STROKE2 = "#22c55e";
846
853
  import { util } from "fabric";
847
854
 
848
855
  // src/pattern/tiled-pattern-object.ts
849
- import { FabricObject } from "fabric";
856
+ import { FabricObject, Point } from "fabric";
850
857
 
851
858
  // src/pattern/tile-geometry.ts
852
859
  var MAX_TILES_PER_AXIS = 200;
@@ -915,41 +922,37 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
915
922
  /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
916
923
  source;
917
924
  config;
925
+ /** The print area this pattern fills, in canvas units. */
926
+ area;
918
927
  snapshotEl = null;
919
928
  snapshotScale = 0;
920
- constructor(source, config, width, height) {
929
+ constructor(source, config, area) {
921
930
  super({
922
- // Explicit origin: fabric v7 defaults to `center`, and a centre-origin
923
- // object placed at 0,0 covers a quarter of the canvas. That exact bug is
924
- // why the old bake-into-the-layer pattern landed in the top-left corner.
925
- originX: "left",
926
- originY: "top",
927
- left: 0,
928
- top: 0,
929
- width,
930
- height,
931
- // Pointer events belong to the source underneath, and fabric's object
932
- // cache would freeze the tiling at screen resolution.
933
- selectable: false,
934
- evented: false,
931
+ // Centre origin: the box tracks a tile, and a tile is placed by its centre.
932
+ originX: "center",
933
+ originY: "center",
935
934
  objectCaching: false,
936
- hasControls: false,
937
- hasBorders: false
935
+ // Side handles would imply a non-uniform tile scale; the config has one.
936
+ lockScalingFlip: true
938
937
  });
939
938
  this.source = source;
940
939
  this.config = config;
940
+ this.area = area;
941
+ this.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false });
942
+ this.syncBox();
941
943
  }
942
- /** Swap in a new config; the next render picks it up. */
944
+ /** Swap in a new config and re-fit the box to the tile it now describes. */
943
945
  setConfig(config) {
944
946
  this.config = config;
945
- this.dirty = true;
947
+ this.syncBox();
946
948
  }
947
- /** Resize to a new print area. */
948
- setArea(width, height) {
949
- this.set({ width, height });
950
- this.setCoords();
949
+ /** Re-fit to a new print area (canvas resize). */
950
+ setArea(area) {
951
+ this.area = area;
952
+ this.invalidate();
953
+ this.syncBox();
951
954
  }
952
- /** Drop the cached source snapshot (e.g. the source was edited). */
955
+ /** Drop the cached source snapshot (the source was edited). */
953
956
  invalidate() {
954
957
  this.snapshotEl = null;
955
958
  this.snapshotScale = 0;
@@ -960,40 +963,105 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
960
963
  this.snapshotEl = null;
961
964
  this.snapshotScale = 0;
962
965
  }
966
+ /**
967
+ * Put the box back on the anchor tile: the tile's size, the pattern angle, and
968
+ * the grid origin displaced by the configured phase shift. Resets any live
969
+ * gesture scale, so it must run only once that gesture has been folded in.
970
+ */
971
+ syncBox() {
972
+ const { tileW, tileH } = this.baseTile();
973
+ const origin = this.source.getCenterPoint();
974
+ const anchor = this.anchorFromOrigin(origin, tileW, tileH, this.config.angle);
975
+ this.set({
976
+ left: anchor.x,
977
+ top: anchor.y,
978
+ width: tileW,
979
+ height: tileH,
980
+ scaleX: 1,
981
+ scaleY: 1,
982
+ angle: this.config.angle
983
+ });
984
+ this.setCoords();
985
+ this.dirty = true;
986
+ }
987
+ /** Grid rotation in play right now — the live handle during a rotate gesture. */
988
+ liveAngle() {
989
+ return this.angle ?? 0;
990
+ }
991
+ /** Tile scale in play right now, as a config percentage. */
992
+ liveScale() {
993
+ return (this.config.scale ?? 100) * Math.abs(this.scaleX ?? 1);
994
+ }
995
+ /**
996
+ * Where the source's centre would have to sit for the box to stay put — i.e.
997
+ * the grid origin implied by a drag. `PatternManager` moves the source there.
998
+ */
999
+ liveOrigin() {
1000
+ const { tileW, tileH } = this.liveTile();
1001
+ const centre = this.getCenterPoint();
1002
+ const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1003
+ return new Point(centre.x - shift.x, centre.y - shift.y);
1004
+ }
963
1005
  _render(ctx) {
964
- const width = this.width ?? 0;
965
- const height = this.height ?? 0;
1006
+ const { width, height } = this.area;
966
1007
  if (width <= 0 || height <= 0) return;
967
- const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
968
- const snapshot = this.ensureSnapshot(contextScale(ctx) * tileScale);
1008
+ const { tileW, tileH } = this.liveTile();
1009
+ const snapshot = this.ensureSnapshot(contextScale(ctx) * (tileW / Math.max(1, this.baseW())));
969
1010
  if (!snapshot) return;
970
- const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1011
+ const centre = this.getCenterPoint();
1012
+ const angle = this.liveAngle();
971
1013
  ctx.save();
972
- ctx.translate(-width / 2, -height / 2);
973
- drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1014
+ ctx.scale(1 / (this.scaleX || 1), 1 / (this.scaleY || 1));
1015
+ ctx.rotate(-angle * Math.PI / 180);
1016
+ ctx.translate(-centre.x, -centre.y);
1017
+ const shift = this.shiftVector(tileW, tileH, angle);
1018
+ const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
1019
+ const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
1020
+ drawTiles(ctx, snapshot, config, width, height, tileW, tileH, origin);
974
1021
  ctx.restore();
975
1022
  }
976
1023
  /** Raster fallback for SVG export — one `<image>` covering the print area. */
977
1024
  _toSVG() {
978
- const width = this.width ?? 0;
979
- const height = this.height ?? 0;
1025
+ const { width, height } = this.area;
980
1026
  if (width <= 0 || height <= 0) return [];
981
1027
  const el = document.createElement("canvas");
982
1028
  el.width = Math.max(1, Math.round(width));
983
1029
  el.height = Math.max(1, Math.round(height));
984
1030
  const ctx = el.getContext("2d");
985
1031
  if (!ctx) return [];
986
- const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
987
- const snapshot = this.ensureSnapshot(tileScale);
1032
+ const { tileW, tileH } = this.liveTile();
1033
+ const snapshot = this.ensureSnapshot(tileW / Math.max(1, this.baseW()));
988
1034
  if (!snapshot) return [];
989
- const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
990
- drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1035
+ const centre = this.getCenterPoint();
1036
+ const angle = this.liveAngle();
1037
+ const shift = this.shiftVector(tileW, tileH, angle);
1038
+ drawTiles(
1039
+ ctx,
1040
+ snapshot,
1041
+ { ...this.config, angle, offsetX: 0, offsetY: 0 },
1042
+ width,
1043
+ height,
1044
+ tileW,
1045
+ tileH,
1046
+ { x: centre.x - shift.x, y: centre.y - shift.y }
1047
+ );
991
1048
  return [
992
- `<image x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" `,
993
- `xlink:href="${el.toDataURL("image/png")}"></image>
1049
+ `<g transform="rotate(${-angle}) translate(${-centre.x} ${-centre.y})">`,
1050
+ `<image x="0" y="0" width="${width}" height="${height}" `,
1051
+ `xlink:href="${el.toDataURL("image/png")}"></image></g>
994
1052
  `
995
1053
  ];
996
1054
  }
1055
+ /**
1056
+ * The proxy holds a live reference to its source, which would make a
1057
+ * serialized canvas circular. Nothing persists this object (only layers are
1058
+ * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1059
+ */
1060
+ toObject() {
1061
+ const plain = super.toObject();
1062
+ delete plain.source;
1063
+ return plain;
1064
+ }
997
1065
  /**
998
1066
  * Fabric's generic clone round-trips through `toObject()` + the class
999
1067
  * registry, which cannot carry a live source reference. Export paths clone
@@ -1001,46 +1069,64 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1001
1069
  * tiling. The copy shares the source (it only ever reads from it).
1002
1070
  */
1003
1071
  clone() {
1004
- const copy = new _TiledPatternObject(
1005
- this.source,
1006
- this.config,
1007
- this.width ?? 0,
1008
- this.height ?? 0
1009
- );
1072
+ const copy = new _TiledPatternObject(this.source, this.config, this.area);
1010
1073
  copy.set({
1011
1074
  left: this.left,
1012
1075
  top: this.top,
1076
+ angle: this.angle,
1077
+ scaleX: this.scaleX,
1078
+ scaleY: this.scaleY,
1079
+ width: this.width,
1080
+ height: this.height,
1013
1081
  visible: this.visible,
1014
1082
  opacity: this.opacity
1015
1083
  });
1016
1084
  return Promise.resolve(copy);
1017
1085
  }
1018
- /**
1019
- * The proxy holds a live reference to its source, which would make a
1020
- * serialized canvas circular. Nothing persists this object (only layers are
1021
- * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1022
- */
1023
- toObject() {
1024
- const plain = super.toObject();
1025
- delete plain.source;
1026
- return plain;
1086
+ /** The source's on-canvas width, before the tile scale. */
1087
+ baseW() {
1088
+ return Math.max(1, this.source.getBoundingRect().width);
1089
+ }
1090
+ /** Tile size from the config alone, ignoring any in-flight gesture. */
1091
+ baseTile() {
1092
+ const rect = this.source.getBoundingRect();
1093
+ const scale = Math.max(1, this.config.scale ?? 100) / 100;
1094
+ const floor = this.tileFloor();
1095
+ return {
1096
+ tileW: Math.max(floor, rect.width * scale),
1097
+ tileH: Math.max(floor, rect.height * scale)
1098
+ };
1027
1099
  }
1028
- /** Tile size in canvas units, floored so a tiny tile can't spawn a huge loop. */
1029
- tileSize(snapshot, tileScale, width, height) {
1030
- const floor = Math.max(2, Math.sqrt(width * width + height * height) / 200);
1100
+ /** Tile size as drawn right now, including a live scale gesture. */
1101
+ liveTile() {
1102
+ const base = this.baseTile();
1103
+ const floor = this.tileFloor();
1031
1104
  return {
1032
- tileW: Math.max(floor, snapshot.width / this.snapshotScale * tileScale),
1033
- tileH: Math.max(floor, snapshot.height / this.snapshotScale * tileScale)
1105
+ tileW: Math.max(floor, base.tileW * Math.abs(this.scaleX ?? 1)),
1106
+ tileH: Math.max(floor, base.tileH * Math.abs(this.scaleY ?? 1))
1034
1107
  };
1035
1108
  }
1036
- /** Grid anchor: the source's centre, in this object's coordinates. */
1037
- gridOrigin() {
1038
- const centre = this.source.getCenterPoint();
1039
- return { x: centre.x - (this.left ?? 0), y: centre.y - (this.top ?? 0) };
1109
+ /** Smallest tile the draw loop may use, so a tiny tile can't flood the area. */
1110
+ tileFloor() {
1111
+ const { width, height } = this.area;
1112
+ return Math.max(2, Math.sqrt(width * width + height * height) / 200);
1113
+ }
1114
+ /** Phase shift (`offsetX`/`offsetY`) as a canvas-space vector. */
1115
+ shiftVector(tileW, tileH, angle) {
1116
+ const dx = tileW * (clampPercent(this.config.offsetX) / 100);
1117
+ const dy = tileH * (clampPercent(this.config.offsetY) / 100);
1118
+ const radians = angle * Math.PI / 180;
1119
+ const cos = Math.cos(radians);
1120
+ const sin = Math.sin(radians);
1121
+ return new Point(dx * cos - dy * sin, dx * sin + dy * cos);
1122
+ }
1123
+ anchorFromOrigin(origin, tileW, tileH, angle) {
1124
+ const shift = this.shiftVector(tileW, tileH, angle);
1125
+ return new Point(origin.x + shift.x, origin.y + shift.y);
1040
1126
  }
1041
1127
  /**
1042
- * Snapshot the source at (at least) `scale`, reusing the cached one when it is
1043
- * still sharp enough and the source has not changed.
1128
+ * Snapshot the source at (at least) `scale`, reusing the cached one while it
1129
+ * is still sharp enough and the source has not changed.
1044
1130
  */
1045
1131
  ensureSnapshot(scale) {
1046
1132
  const wanted = this.clampScale(scale);
@@ -1071,6 +1157,10 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1071
1157
  return Math.max(0.05, Math.min(requested, budgeted));
1072
1158
  }
1073
1159
  };
1160
+ function clampPercent(value) {
1161
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1162
+ return Math.max(-100, Math.min(100, value));
1163
+ }
1074
1164
  function contextScale(ctx) {
1075
1165
  if (typeof ctx.getTransform !== "function") return 1;
1076
1166
  try {
@@ -1082,13 +1172,15 @@ function contextScale(ctx) {
1082
1172
  }
1083
1173
 
1084
1174
  // src/pattern/pattern-manager.ts
1175
+ var MIN_TILE_SCALE = 10;
1176
+ var MAX_TILE_SCALE = 300;
1085
1177
  var PatternManager = class {
1086
1178
  constructor(canvas, layers, history, events) {
1087
1179
  this.canvas = canvas;
1088
1180
  this.layers = layers;
1089
1181
  this.history = history;
1090
1182
  this.events = events;
1091
- this.canvas.on("object:modified", this.onSourceModified);
1183
+ this.canvas.on("object:modified", this.onObjectModified);
1092
1184
  this.canvas.on("text:changed", this.onSourceModified);
1093
1185
  this.events.on("layer:removed", this.onLayerRemoved);
1094
1186
  }
@@ -1097,6 +1189,11 @@ var PatternManager = class {
1097
1189
  history;
1098
1190
  events;
1099
1191
  proxies = /* @__PURE__ */ new Map();
1192
+ onObjectModified = (event) => {
1193
+ const target = event.target;
1194
+ if (target instanceof TiledPatternObject) this.commitGesture(target);
1195
+ else this.invalidateFor(target);
1196
+ };
1100
1197
  onSourceModified = (event) => {
1101
1198
  this.invalidateFor(event.target);
1102
1199
  };
@@ -1138,7 +1235,11 @@ var PatternManager = class {
1138
1235
  try {
1139
1236
  this.detach(layerId);
1140
1237
  restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1141
- layer.fabricObject.set({ opacity: layer.opacity });
1238
+ layer.fabricObject.set({
1239
+ opacity: layer.opacity,
1240
+ selectable: !layer.locked,
1241
+ evented: !layer.locked
1242
+ });
1142
1243
  this.layers.setMeta(layerId, { pattern: void 0 });
1143
1244
  this.canvas.requestRenderAll();
1144
1245
  this.history.save();
@@ -1170,12 +1271,8 @@ var PatternManager = class {
1170
1271
  }
1171
1272
  /** Re-fit every proxy to the print area (canvas resize). */
1172
1273
  syncArea() {
1173
- const width = this.canvas.getWidth();
1174
- const height = this.canvas.getHeight();
1175
- for (const proxy of this.proxies.values()) {
1176
- proxy.setArea(width, height);
1177
- proxy.invalidate();
1178
- }
1274
+ const area = this.area();
1275
+ for (const proxy of this.proxies.values()) proxy.setArea(area);
1179
1276
  if (this.proxies.size > 0) this.canvas.requestRenderAll();
1180
1277
  }
1181
1278
  /** Drop a layer's cached source snapshot (its content changed). */
@@ -1183,6 +1280,7 @@ var PatternManager = class {
1183
1280
  const proxy = this.proxies.get(layerId);
1184
1281
  if (!proxy) return;
1185
1282
  proxy.invalidate();
1283
+ proxy.syncBox();
1186
1284
  this.canvas.requestRenderAll();
1187
1285
  }
1188
1286
  /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
@@ -1192,7 +1290,7 @@ var PatternManager = class {
1192
1290
  this.attach(layer, state.config);
1193
1291
  }
1194
1292
  dispose() {
1195
- this.canvas.off("object:modified", this.onSourceModified);
1293
+ this.canvas.off("object:modified", this.onObjectModified);
1196
1294
  this.canvas.off("text:changed", this.onSourceModified);
1197
1295
  this.events.off("layer:removed", this.onLayerRemoved);
1198
1296
  this.clearProxies();
@@ -1203,16 +1301,37 @@ var PatternManager = class {
1203
1301
  this.proxies.clear();
1204
1302
  }
1205
1303
  attach(layer, config) {
1206
- const proxy = new TiledPatternObject(
1207
- layer.fabricObject,
1208
- config,
1209
- this.canvas.getWidth(),
1210
- this.canvas.getHeight()
1211
- );
1212
- layer.fabricObject.set({ opacity: 0 });
1304
+ const proxy = new TiledPatternObject(layer.fabricObject, config, this.area());
1305
+ const wasActive = this.canvas.getActiveObject() === layer.fabricObject;
1306
+ layer.fabricObject.set({ opacity: 0, selectable: false, evented: false });
1213
1307
  this.proxies.set(layer.id, proxy);
1214
1308
  this.layers.setRenderProxy(layer.id, proxy);
1309
+ if (wasActive) this.canvas.setActiveObject(proxy);
1310
+ this.canvas.requestRenderAll();
1311
+ }
1312
+ /**
1313
+ * Fold a finished drag / scale / rotate on the proxy back into the pattern:
1314
+ * position becomes the grid origin (carried by the source), scale becomes the
1315
+ * tile scale, rotation becomes the pattern angle. Read the live transform
1316
+ * first — writing the config re-fits the box and destroys it.
1317
+ */
1318
+ commitGesture(proxy) {
1319
+ const layer = this.layers.findByObject(proxy);
1320
+ const state = layer?.meta.pattern;
1321
+ if (!layer || !state) return;
1322
+ const origin = proxy.liveOrigin();
1323
+ const scale = clamp2(proxy.liveScale(), MIN_TILE_SCALE, MAX_TILE_SCALE);
1324
+ const angle = normalizeAngle(proxy.liveAngle());
1325
+ layer.fabricObject.setPositionByOrigin(origin, "center", "center");
1326
+ layer.fabricObject.setCoords();
1327
+ const config = { ...state.config, scale, angle };
1328
+ this.layers.setMeta(layer.id, { pattern: { ...state, config } });
1329
+ proxy.setConfig(config);
1215
1330
  this.canvas.requestRenderAll();
1331
+ this.history.save();
1332
+ }
1333
+ area() {
1334
+ return { width: this.canvas.getWidth(), height: this.canvas.getHeight() };
1216
1335
  }
1217
1336
  detach(layerId) {
1218
1337
  const proxy = this.proxies.get(layerId);
@@ -1230,6 +1349,15 @@ var PatternManager = class {
1230
1349
  if (layer) this.invalidate(layer.id);
1231
1350
  }
1232
1351
  };
1352
+ function clamp2(value, min, max) {
1353
+ if (!Number.isFinite(value)) return min;
1354
+ return Math.max(min, Math.min(max, value));
1355
+ }
1356
+ function normalizeAngle(angle) {
1357
+ if (!Number.isFinite(angle)) return 0;
1358
+ const wrapped = (angle % 360 + 360) % 360;
1359
+ return Math.round(wrapped > 180 ? wrapped - 360 : wrapped);
1360
+ }
1233
1361
  function isLegacyState(state) {
1234
1362
  return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
1235
1363
  }
@@ -2529,6 +2657,737 @@ var MaskController = class {
2529
2657
  }
2530
2658
  };
2531
2659
 
2660
+ // src/masks/manager.ts
2661
+ import { Group as Group5 } from "fabric";
2662
+
2663
+ // src/masks/compose.ts
2664
+ import { Group, Rect as Rect2 } from "fabric";
2665
+ var MODE_OPERATION = {
2666
+ add: "source-over",
2667
+ subtract: "destination-out",
2668
+ intersect: "destination-in"
2669
+ };
2670
+ function neutralize(child) {
2671
+ child.set({ opacity: 0, globalCompositeOperation: "source-over" });
2672
+ }
2673
+ function baseRect(box) {
2674
+ return new Rect2({
2675
+ left: box.left,
2676
+ top: box.top,
2677
+ width: Math.max(1, box.width),
2678
+ height: Math.max(1, box.height),
2679
+ originX: "left",
2680
+ originY: "top",
2681
+ fill: "#000000",
2682
+ objectCaching: false
2683
+ });
2684
+ }
2685
+ function composeMaskGroup(children, entries, options) {
2686
+ if (children.length === 0) return void 0;
2687
+ children.forEach((child, index) => {
2688
+ const entry = entries[index];
2689
+ child.set({ objectCaching: false });
2690
+ if (!entry || !entry.visible) {
2691
+ neutralize(child);
2692
+ return;
2693
+ }
2694
+ child.set({
2695
+ opacity: entry.opacity,
2696
+ globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
2697
+ });
2698
+ });
2699
+ const first = entries.find((entry) => entry.visible);
2700
+ const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
2701
+ return new Group(withBase, {
2702
+ absolutePositioned: options.absolute,
2703
+ // Cached, so the children's compositing operations resolve against each
2704
+ // other instead of against the page underneath the mask.
2705
+ objectCaching: true,
2706
+ subTargetCheck: false,
2707
+ interactive: false
2708
+ });
2709
+ }
2710
+ function needsAbsoluteSpace(entries) {
2711
+ return entries.some((entry) => !entry.linked);
2712
+ }
2713
+
2714
+ // src/masks/edit.ts
2715
+ import { util as util4 } from "fabric";
2716
+
2717
+ // src/masks/space.ts
2718
+ import { util as util3 } from "fabric";
2719
+ function matrixOf(object) {
2720
+ return object.calcTransformMatrix();
2721
+ }
2722
+ function applyMatrix(object, matrix) {
2723
+ const decomposed = util3.qrDecompose(matrix);
2724
+ object.set({
2725
+ flipX: false,
2726
+ flipY: false,
2727
+ originX: "center",
2728
+ originY: "center",
2729
+ left: decomposed.translateX,
2730
+ top: decomposed.translateY,
2731
+ scaleX: decomposed.scaleX,
2732
+ scaleY: decomposed.scaleY,
2733
+ angle: decomposed.angle,
2734
+ skewX: decomposed.skewX,
2735
+ skewY: 0
2736
+ });
2737
+ object.setCoords();
2738
+ }
2739
+ function toCanvasSpace(object, host) {
2740
+ applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
2741
+ }
2742
+ function toHostSpace(object, host) {
2743
+ applyMatrix(
2744
+ object,
2745
+ util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object))
2746
+ );
2747
+ }
2748
+ function relativeMatrix(object, host) {
2749
+ return util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object));
2750
+ }
2751
+ function applyRelativeMatrix(object, host, rel) {
2752
+ applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), rel));
2753
+ }
2754
+ function asObject(clip) {
2755
+ return clip;
2756
+ }
2757
+ function toMatrix(values) {
2758
+ if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
2759
+ return null;
2760
+ return [values[0], values[1], values[2], values[3], values[4], values[5]];
2761
+ }
2762
+ function fitToBox(object, box, zoom = 1) {
2763
+ const width = Math.max(1, box.width) * zoom;
2764
+ const height = Math.max(1, box.height) * zoom;
2765
+ object.set({
2766
+ originX: "center",
2767
+ originY: "center",
2768
+ angle: 0,
2769
+ skewX: 0,
2770
+ skewY: 0,
2771
+ left: box.left + box.width / 2,
2772
+ top: box.top + box.height / 2,
2773
+ scaleX: width / Math.max(1, object.width ?? 1),
2774
+ scaleY: height / Math.max(1, object.height ?? 1)
2775
+ });
2776
+ object.setCoords();
2777
+ }
2778
+ function unwrapGroup(group) {
2779
+ const children = group.removeAll();
2780
+ for (const child of children) child.setCoords();
2781
+ return children;
2782
+ }
2783
+
2784
+ // src/masks/edit.ts
2785
+ var MaskEditController = class {
2786
+ constructor(canvas, onCommit) {
2787
+ this.canvas = canvas;
2788
+ this.onCommit = onCommit;
2789
+ }
2790
+ canvas;
2791
+ onCommit;
2792
+ handle = null;
2793
+ editing = null;
2794
+ child = null;
2795
+ group = null;
2796
+ active() {
2797
+ return this.editing;
2798
+ }
2799
+ /** Put a handle on the canvas for `child`, an object inside `group`. */
2800
+ async begin(edit, group, child) {
2801
+ this.end();
2802
+ const handle = await child.clone();
2803
+ applyMatrix(handle, matrixOf(child));
2804
+ handle.set({
2805
+ // Outline only: the mask's own fill would sit over the artwork it is
2806
+ // supposed to be revealing.
2807
+ fill: "rgba(0,0,0,0.001)",
2808
+ stroke: "#e0b055",
2809
+ strokeWidth: 2,
2810
+ strokeDashArray: [6, 4],
2811
+ strokeUniform: true,
2812
+ opacity: 1,
2813
+ selectable: true,
2814
+ evented: true,
2815
+ hasControls: true,
2816
+ hasBorders: true,
2817
+ objectCaching: false,
2818
+ excludeFromExport: true
2819
+ });
2820
+ this.handle = handle;
2821
+ this.editing = edit;
2822
+ this.child = child;
2823
+ this.group = group;
2824
+ this.canvas.add(handle);
2825
+ this.canvas.setActiveObject(handle);
2826
+ this.canvas.on("object:moving", this.onTransform);
2827
+ this.canvas.on("object:scaling", this.onTransform);
2828
+ this.canvas.on("object:rotating", this.onTransform);
2829
+ this.canvas.on("object:modified", this.onModified);
2830
+ this.canvas.requestRenderAll();
2831
+ return true;
2832
+ }
2833
+ /** Take the handle down. The geometry it wrote is already in the clip. */
2834
+ end() {
2835
+ if (!this.handle) return;
2836
+ this.canvas.off("object:moving", this.onTransform);
2837
+ this.canvas.off("object:scaling", this.onTransform);
2838
+ this.canvas.off("object:rotating", this.onTransform);
2839
+ this.canvas.off("object:modified", this.onModified);
2840
+ if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
2841
+ this.canvas.remove(this.handle);
2842
+ this.handle = null;
2843
+ this.editing = null;
2844
+ this.child = null;
2845
+ this.group = null;
2846
+ this.canvas.requestRenderAll();
2847
+ }
2848
+ dispose() {
2849
+ this.end();
2850
+ }
2851
+ onTransform = (event) => {
2852
+ if (!this.handle || event.target !== this.handle) return;
2853
+ this.write();
2854
+ };
2855
+ onModified = (event) => {
2856
+ if (!this.handle || event.target !== this.handle) return;
2857
+ this.write();
2858
+ this.onCommit();
2859
+ };
2860
+ /** Handle transform (canvas space) → clip child transform (group-relative). */
2861
+ write() {
2862
+ if (!this.handle || !this.child || !this.group) return;
2863
+ applyMatrix(
2864
+ this.child,
2865
+ util4.multiplyTransformMatrices(
2866
+ util4.invertTransform(matrixOf(this.group)),
2867
+ matrixOf(this.handle)
2868
+ )
2869
+ );
2870
+ this.group.dirty = true;
2871
+ this.group.set({ dirty: true });
2872
+ this.canvas.requestRenderAll();
2873
+ }
2874
+ };
2875
+
2876
+ // src/masks/store.ts
2877
+ import { Group as Group4 } from "fabric";
2878
+
2879
+ // src/masks/host.ts
2880
+ import { Rect as Rect3 } from "fabric";
2881
+ function findCanvasHost(layers) {
2882
+ return layers.getAll().find((layer) => layer.meta.canvasMask);
2883
+ }
2884
+ function createCanvasHost(canvas, layers) {
2885
+ const rect = new Rect3({
2886
+ left: 0,
2887
+ top: 0,
2888
+ width: canvas.getWidth(),
2889
+ height: canvas.getHeight(),
2890
+ originX: "left",
2891
+ originY: "top",
2892
+ fill: "#000000",
2893
+ globalCompositeOperation: "destination-in",
2894
+ // It is edited from the layer list, never on the canvas: a drag box on
2895
+ // something that cannot be dragged only reads as broken.
2896
+ selectable: false,
2897
+ evented: false,
2898
+ hasControls: false,
2899
+ hasBorders: false,
2900
+ objectCaching: false
2901
+ });
2902
+ const layer = layers.add("mask", rect, "Design mask");
2903
+ layer.meta.canvasMask = true;
2904
+ layers.setLocked(layer.id, true);
2905
+ return layer;
2906
+ }
2907
+ function pinCanvasHost(layers) {
2908
+ const all = layers.getAll();
2909
+ const hostIndex = all.findIndex((layer) => layer.meta.canvasMask);
2910
+ if (hostIndex === -1) return false;
2911
+ let lastContent = -1;
2912
+ all.forEach((layer, index) => {
2913
+ if (layer.type !== "mask") lastContent = index;
2914
+ });
2915
+ if (hostIndex >= lastContent) return false;
2916
+ layers.reorder(all[hostIndex].id, all.length - 1);
2917
+ return true;
2918
+ }
2919
+ function hostBoxOf(canvas, host, absolute) {
2920
+ if (!host) {
2921
+ return { left: 0, top: 0, width: canvas.getWidth(), height: canvas.getHeight() };
2922
+ }
2923
+ if (absolute) {
2924
+ host.setCoords();
2925
+ const rect = host.getBoundingRect();
2926
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
2927
+ }
2928
+ const width = Math.max(1, host.width ?? 1);
2929
+ const height = Math.max(1, host.height ?? 1);
2930
+ return { left: -width / 2, top: -height / 2, width, height };
2931
+ }
2932
+
2933
+ // src/masks/install.ts
2934
+ import { Group as Group3 } from "fabric";
2935
+ function convertSpace(host, sources, absolute) {
2936
+ const wasAbsolute = host.clipPath instanceof Group3 ? host.clipPath.absolutePositioned : absolute;
2937
+ if (absolute === wasAbsolute) return;
2938
+ for (const source of sources) {
2939
+ if (absolute) toCanvasSpace(source, host);
2940
+ else toHostSpace(source, host);
2941
+ }
2942
+ }
2943
+ function withRelativeTransforms(entries, sources, host, absolute) {
2944
+ return entries.map((entry, index) => {
2945
+ const source = sources[index];
2946
+ if (absolute && entry.linked && source) {
2947
+ return { ...entry, rel: [...relativeMatrix(source, host)] };
2948
+ }
2949
+ if (!entry.rel) return entry;
2950
+ const rest = { ...entry };
2951
+ delete rest.rel;
2952
+ return rest;
2953
+ });
2954
+ }
2955
+ function installClip(host, entries, sources, box, absolute) {
2956
+ host.clipPath = composeMaskGroup(sources, entries, { box, absolute });
2957
+ host.dirty = true;
2958
+ host.setCoords();
2959
+ }
2960
+
2961
+ // src/masks/store.ts
2962
+ var CANVAS_MASK_TARGET = "canvas";
2963
+ var DEFAULT_ENTRY = {
2964
+ mode: "add",
2965
+ linked: true,
2966
+ visible: true,
2967
+ opacity: 1
2968
+ };
2969
+ var MaskStackStore = class {
2970
+ constructor(canvas, layers, history, events) {
2971
+ this.canvas = canvas;
2972
+ this.layers = layers;
2973
+ this.history = history;
2974
+ this.events = events;
2975
+ }
2976
+ canvas;
2977
+ layers;
2978
+ history;
2979
+ events;
2980
+ selected = null;
2981
+ pinning = false;
2982
+ list(target) {
2983
+ return this.hostLayer(target)?.meta.maskStack ?? [];
2984
+ }
2985
+ get(target, maskId) {
2986
+ return this.list(target).find((entry) => entry.id === maskId);
2987
+ }
2988
+ /** Every target that currently carries at least one mask. */
2989
+ targets() {
2990
+ return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
2991
+ }
2992
+ /** The box a mask is fitted to, in the space the stack is composed in. */
2993
+ hostBox(target, absolute = target === CANVAS_MASK_TARGET || needsAbsoluteSpace(this.list(target))) {
2994
+ return hostBoxOf(this.canvas, this.host(target), absolute);
2995
+ }
2996
+ /** The host layer of a target, optionally creating the design overlay. */
2997
+ hostLayer(target, create = false) {
2998
+ if (target !== CANVAS_MASK_TARGET) return this.layers.get(target);
2999
+ const existing = findCanvasHost(this.layers);
3000
+ if (existing || !create) return existing;
3001
+ return createCanvasHost(this.canvas, this.layers);
3002
+ }
3003
+ host(target, create = false) {
3004
+ return this.hostLayer(target, create)?.fabricObject ?? null;
3005
+ }
3006
+ /** Re-pin the design overlay, guarding the reorder that re-triggers this. */
3007
+ pin() {
3008
+ if (this.pinning) return;
3009
+ this.pinning = true;
3010
+ try {
3011
+ pinCanvasHost(this.layers);
3012
+ } finally {
3013
+ this.pinning = false;
3014
+ }
3015
+ }
3016
+ /**
3017
+ * Take the current geometry back out of the composed clip, entry-aligned.
3018
+ *
3019
+ * The entry list is the authority on how to read the clip: with no entries the
3020
+ * clip predates the stack (a mask preset, or a single `clipPath` an older host
3021
+ * installed) and is one mask whole — including when it happens to be a group,
3022
+ * which is why this cannot just unwrap anything group-shaped.
3023
+ */
3024
+ unwrap(target, host) {
3025
+ const clip = host.clipPath;
3026
+ if (!clip) return [];
3027
+ const entries = this.list(target);
3028
+ if (entries.length === 0 || !(clip instanceof Group4)) return [asObject(clip)];
3029
+ const children = unwrapGroup(clip);
3030
+ const extra = children.length - entries.length;
3031
+ return extra > 0 ? children.slice(extra) : children;
3032
+ }
3033
+ /**
3034
+ * Entries for a stack, adopting a pre-stack clip as the first one. Without
3035
+ * this, the first `add()` on an already-masked layer would compose a clip it
3036
+ * has no entry for and silently throw that mask away.
3037
+ */
3038
+ entriesFor(target, sources) {
3039
+ const existing = this.list(target);
3040
+ if (existing.length > 0 || sources.length !== 1) return [...existing];
3041
+ const layer = this.hostLayer(target);
3042
+ const preset = layer?.meta.maskPreset;
3043
+ if (layer) {
3044
+ delete layer.meta.maskPreset;
3045
+ }
3046
+ return [
3047
+ {
3048
+ ...DEFAULT_ENTRY,
3049
+ id: generateId(),
3050
+ name: typeof preset === "string" ? preset : "Mask",
3051
+ linked: target === CANVAS_MASK_TARGET ? false : !sources[0].absolutePositioned
3052
+ }
3053
+ ];
3054
+ }
3055
+ /**
3056
+ * Install a stack: convert geometry into the space the stack needs, compose the
3057
+ * clip, store the entries, and commit one history checkpoint for the lot.
3058
+ */
3059
+ commit(target, host, entries, sources, save = true, forceAbsolute = false) {
3060
+ const layer = this.hostLayer(target);
3061
+ if (!layer) return;
3062
+ const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
3063
+ convertSpace(host, sources, absolute);
3064
+ const next = withRelativeTransforms(entries, sources, host, absolute);
3065
+ installClip(host, next, sources, this.hostBox(target, absolute), absolute);
3066
+ if (next.length === 0) {
3067
+ delete layer.meta.maskStack;
3068
+ if (layer.meta.canvasMask) this.layers.remove(layer.id);
3069
+ } else {
3070
+ layer.meta.maskStack = next;
3071
+ }
3072
+ this.canvas.requestRenderAll();
3073
+ this.events.emit("masks:changed", { target });
3074
+ this.events.emit("layer:modified", { layerId: layer.id });
3075
+ if (save) this.history.save();
3076
+ }
3077
+ };
3078
+
3079
+ // src/masks/manager.ts
3080
+ var LayerMaskManager = class extends MaskStackStore {
3081
+ // Committing mid-drag would recompose the group the handle writes into and
3082
+ // leave it pointing at a discarded object; the geometry is settled on endEdit.
3083
+ edits = new MaskEditController(this.canvas, () => this.history.save());
3084
+ onLayersChanged = () => this.pin();
3085
+ // Selecting a layer means the user has moved on from the mask they had open;
3086
+ // leaving both selected would show mask controls for an unrelated layer.
3087
+ onLayerSelected = () => {
3088
+ if (this.selected) this.select(null, null);
3089
+ };
3090
+ onObjectModified = (event) => {
3091
+ const object = event.target;
3092
+ if (!object) return;
3093
+ const layer = this.layers.findByObject(object);
3094
+ if (layer) this.reflow(layer.id);
3095
+ };
3096
+ constructor(canvas, layers, history, events) {
3097
+ super(canvas, layers, history, events);
3098
+ this.events.on("layers:changed", this.onLayersChanged);
3099
+ this.events.on("layer:selected", this.onLayerSelected);
3100
+ this.canvas.on("object:modified", this.onObjectModified);
3101
+ }
3102
+ dispose() {
3103
+ this.edits.dispose();
3104
+ this.events.off("layers:changed", this.onLayersChanged);
3105
+ this.events.off("layer:selected", this.onLayerSelected);
3106
+ this.canvas.off("object:modified", this.onObjectModified);
3107
+ this.selected = null;
3108
+ }
3109
+ // ─── Geometry editing ────────────────────────────────
3110
+ /** The mask currently being dragged on the canvas, if any. */
3111
+ editing() {
3112
+ return this.edits.active();
3113
+ }
3114
+ /**
3115
+ * Put drag handles on one mask. The stack is composed in canvas space for the
3116
+ * duration — a linked mask would otherwise sit in the host's space, where the
3117
+ * handle's own canvas coordinates mean something else entirely. `endEdit`
3118
+ * returns it to whichever space its entries call for.
3119
+ */
3120
+ async beginEdit(target, maskId) {
3121
+ const host = this.host(target);
3122
+ if (!host) return false;
3123
+ const entries = this.list(target);
3124
+ const index = entries.findIndex((entry) => entry.id === maskId);
3125
+ if (index === -1) return false;
3126
+ this.endEdit(false);
3127
+ this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3128
+ const group = host.clipPath instanceof Group5 ? host.clipPath : null;
3129
+ if (!group) return false;
3130
+ const children = group.getObjects();
3131
+ const child = children[children.length - entries.length + index];
3132
+ if (!child) return false;
3133
+ this.select(target, maskId);
3134
+ return this.edits.begin({ target, maskId }, group, child);
3135
+ }
3136
+ /** Take the handles down and settle the stack back into its own space. */
3137
+ endEdit(save = true) {
3138
+ const editing = this.edits.active();
3139
+ this.edits.end();
3140
+ if (editing) this.rebuild(editing.target, save);
3141
+ }
3142
+ // ─── Selection ───────────────────────────────────────
3143
+ getSelected() {
3144
+ return this.selected;
3145
+ }
3146
+ select(target, maskId) {
3147
+ this.selected = target && maskId ? { target, maskId } : null;
3148
+ this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
3149
+ }
3150
+ // ─── Mutations ───────────────────────────────────────
3151
+ add(target, object, options = {}) {
3152
+ const host = this.host(target, true);
3153
+ if (!host) return null;
3154
+ const sources = this.unwrap(target, host);
3155
+ const entries = this.entriesFor(target, sources);
3156
+ const entry = {
3157
+ ...DEFAULT_ENTRY,
3158
+ id: generateId(),
3159
+ name: options.name ?? "Mask",
3160
+ ...options.mode ? { mode: options.mode } : {},
3161
+ ...options.linked === void 0 ? {} : { linked: options.linked },
3162
+ ...options.meta ? { meta: options.meta } : {}
3163
+ };
3164
+ if (target === CANVAS_MASK_TARGET) entry.linked = false;
3165
+ if (options.fit !== false) {
3166
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3167
+ }
3168
+ sources.push(object);
3169
+ entries.push(entry);
3170
+ this.commit(target, host, entries, sources);
3171
+ return entry;
3172
+ }
3173
+ /** Swap one mask's geometry, keeping its identity, mode and position in the stack. */
3174
+ replaceGeometry(target, maskId, object, options = {}) {
3175
+ const host = this.host(target);
3176
+ if (!host) return false;
3177
+ const entries = [...this.list(target)];
3178
+ const index = entries.findIndex((entry) => entry.id === maskId);
3179
+ if (index === -1) return false;
3180
+ const sources = this.unwrap(target, host);
3181
+ if (options.fit !== false) {
3182
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3183
+ }
3184
+ sources[index] = object;
3185
+ entries[index] = {
3186
+ ...entries[index],
3187
+ ...options.name ? { name: options.name } : {},
3188
+ ...options.meta ? { meta: options.meta } : {}
3189
+ };
3190
+ this.commit(target, host, entries, sources);
3191
+ return true;
3192
+ }
3193
+ /**
3194
+ * Re-fit one mask to its host's box (or `box`) at `fit` scale, keeping the
3195
+ * geometry it already has. This is what a zoom control drives: re-picking the
3196
+ * mask would cost another render of a generated alpha map just to resize it.
3197
+ */
3198
+ refit(target, maskId, options = {}) {
3199
+ const host = this.host(target);
3200
+ if (!host) return false;
3201
+ const entries = [...this.list(target)];
3202
+ const index = entries.findIndex((entry) => entry.id === maskId);
3203
+ if (index === -1) return false;
3204
+ const sources = this.unwrap(target, host);
3205
+ const source = sources[index];
3206
+ if (!source) return false;
3207
+ fitToBox(source, options.box ?? this.hostBox(target), options.fit ?? 1);
3208
+ this.commit(target, host, entries, sources, options.save ?? true);
3209
+ return true;
3210
+ }
3211
+ remove(target, maskId) {
3212
+ const host = this.host(target);
3213
+ if (!host) return false;
3214
+ const entries = [...this.list(target)];
3215
+ const index = entries.findIndex((entry) => entry.id === maskId);
3216
+ if (index === -1) return false;
3217
+ const sources = this.unwrap(target, host);
3218
+ entries.splice(index, 1);
3219
+ sources.splice(index, 1);
3220
+ if (this.selected?.maskId === maskId) this.select(null, null);
3221
+ this.commit(target, host, entries, sources);
3222
+ return true;
3223
+ }
3224
+ clear(target) {
3225
+ const host = this.host(target);
3226
+ if (!host) return false;
3227
+ if (this.list(target).length === 0) return false;
3228
+ if (this.selected?.target === target) this.select(null, null);
3229
+ this.commit(target, host, [], []);
3230
+ return true;
3231
+ }
3232
+ reorder(target, maskId, index) {
3233
+ const host = this.host(target);
3234
+ if (!host) return false;
3235
+ const entries = [...this.list(target)];
3236
+ const from = entries.findIndex((entry2) => entry2.id === maskId);
3237
+ if (from === -1) return false;
3238
+ const to = Math.max(0, Math.min(entries.length - 1, Math.round(index)));
3239
+ if (from === to) return false;
3240
+ const sources = this.unwrap(target, host);
3241
+ const [entry] = entries.splice(from, 1);
3242
+ const [source] = sources.splice(from, 1);
3243
+ entries.splice(to, 0, entry);
3244
+ sources.splice(to, 0, source);
3245
+ this.commit(target, host, entries, sources);
3246
+ return true;
3247
+ }
3248
+ /** Replace a mask's host metadata (which preset or generator produced it). */
3249
+ setMeta(target, maskId, meta) {
3250
+ return this.patch(target, maskId, () => ({ meta }));
3251
+ }
3252
+ setMode(target, maskId, mode) {
3253
+ return this.patch(target, maskId, (entry) => entry.mode === mode ? null : { mode });
3254
+ }
3255
+ setVisible(target, maskId, visible) {
3256
+ return this.patch(target, maskId, (entry) => entry.visible === visible ? null : { visible });
3257
+ }
3258
+ setOpacity(target, maskId, opacity) {
3259
+ if (!Number.isFinite(opacity)) return false;
3260
+ const next = Math.max(0, Math.min(1, opacity));
3261
+ return this.patch(
3262
+ target,
3263
+ maskId,
3264
+ (entry) => entry.opacity === next ? null : { opacity: next }
3265
+ );
3266
+ }
3267
+ setName(target, maskId, name) {
3268
+ const trimmed = name.trim();
3269
+ if (!trimmed) return false;
3270
+ return this.patch(
3271
+ target,
3272
+ maskId,
3273
+ (entry) => entry.name === trimmed ? null : { name: trimmed }
3274
+ );
3275
+ }
3276
+ /**
3277
+ * Link or unlink one mask. Unlinking pins it where it currently appears;
3278
+ * re-linking records its position relative to the host so later host moves
3279
+ * carry it along.
3280
+ */
3281
+ setLinked(target, maskId, linked) {
3282
+ if (target === CANVAS_MASK_TARGET) return false;
3283
+ const host = this.host(target);
3284
+ if (!host) return false;
3285
+ const entries = [...this.list(target)];
3286
+ const index = entries.findIndex((entry) => entry.id === maskId);
3287
+ if (index === -1 || entries[index].linked === linked) return false;
3288
+ const sources = this.unwrap(target, host);
3289
+ entries[index] = { ...entries[index], linked };
3290
+ this.commit(target, host, entries, sources);
3291
+ return true;
3292
+ }
3293
+ /**
3294
+ * Consume a layer, turning its artwork into a mask. Without an explicit target
3295
+ * it masks the layer directly beneath it, and the bottom layer masks the whole
3296
+ * design — there is nothing under it to clip.
3297
+ */
3298
+ convertLayer(layerId, target) {
3299
+ const layer = this.layers.get(layerId);
3300
+ if (!layer || layer.meta.canvasMask) return null;
3301
+ const ordered = this.layers.getAll();
3302
+ const index = ordered.findIndex((candidate) => candidate.id === layerId);
3303
+ const below = index > 0 ? ordered[index - 1] : void 0;
3304
+ const resolved = target ?? (below && !below.meta.canvasMask ? below.id : CANVAS_MASK_TARGET);
3305
+ if (resolved === layerId) return null;
3306
+ const object = layer.fabricObject;
3307
+ if (!object.fill && object.type !== "image") object.set({ fill: "#000000" });
3308
+ this.history.beginTransaction();
3309
+ try {
3310
+ if (this.canvas.getActiveObject() === object) this.canvas.discardActiveObject();
3311
+ this.canvas.remove(object);
3312
+ this.layers.remove(layerId);
3313
+ const entry = this.add(resolved, object, { name: layer.name, fit: false });
3314
+ return entry ? { target: resolved, entry } : null;
3315
+ } finally {
3316
+ this.history.endTransaction();
3317
+ }
3318
+ }
3319
+ // ─── Rebuild / restore ───────────────────────────────
3320
+ /** Recompose one stack's clip from the geometry it already holds. */
3321
+ rebuild(target, save = false) {
3322
+ const host = this.host(target);
3323
+ if (!host) return;
3324
+ const sources = this.unwrap(target, host);
3325
+ if (sources.length === 0) return;
3326
+ this.commit(target, host, this.entriesFor(target, sources), sources, save);
3327
+ }
3328
+ /** Re-derive linked masks after the host moved (canvas-space stacks only). */
3329
+ reflow(target) {
3330
+ const host = this.host(target);
3331
+ if (!host) return;
3332
+ const entries = this.list(target);
3333
+ if (entries.length === 0 || !needsAbsoluteSpace(entries)) return;
3334
+ if (!entries.some((entry) => entry.linked && entry.rel)) return;
3335
+ const sources = this.unwrap(target, host);
3336
+ entries.forEach((entry, index) => {
3337
+ const source = sources[index];
3338
+ const rel = toMatrix(entry.rel);
3339
+ if (!source || !entry.linked || !rel) return;
3340
+ applyRelativeMatrix(source, host, rel);
3341
+ });
3342
+ this.commit(target, host, [...entries], sources);
3343
+ }
3344
+ /**
3345
+ * Adopt a clip this manager did not install — a host's own single `clipPath` —
3346
+ * as a one-entry stack, so it shows up in the UI as a mask row before anything
3347
+ * is added to it. A mask preset is left alone unless asked for by name: it is
3348
+ * still owned by `MaskPresetManager` until a stack operation takes it over.
3349
+ */
3350
+ adopt(target, name) {
3351
+ const layer = this.hostLayer(target);
3352
+ const clip = layer?.fabricObject.clipPath;
3353
+ if (!layer || !clip || layer.meta.pattern) return null;
3354
+ if ((layer.meta.maskStack ?? []).length > 0) return null;
3355
+ const entry = {
3356
+ ...DEFAULT_ENTRY,
3357
+ id: generateId(),
3358
+ name: name ?? "Mask",
3359
+ linked: target === CANVAS_MASK_TARGET ? false : !clip.absolutePositioned
3360
+ };
3361
+ layer.meta.maskStack = [entry];
3362
+ delete layer.meta.maskPreset;
3363
+ this.rebuild(target);
3364
+ return entry;
3365
+ }
3366
+ /** After a state restore: re-pin the design overlay and recompose every stack. */
3367
+ refreshAll() {
3368
+ this.selected = null;
3369
+ this.pin();
3370
+ for (const layer of this.layers.getAll()) {
3371
+ if ((layer.meta.maskStack ?? []).length === 0) continue;
3372
+ this.rebuild(layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
3373
+ }
3374
+ }
3375
+ // ─── Internals ───────────────────────────────────────
3376
+ patch(target, maskId, change) {
3377
+ const host = this.host(target);
3378
+ if (!host) return false;
3379
+ const entries = [...this.list(target)];
3380
+ const index = entries.findIndex((entry) => entry.id === maskId);
3381
+ if (index === -1) return false;
3382
+ const patch = change(entries[index]);
3383
+ if (!patch) return false;
3384
+ const sources = this.unwrap(target, host);
3385
+ entries[index] = { ...entries[index], ...patch };
3386
+ this.commit(target, host, entries, sources);
3387
+ return true;
3388
+ }
3389
+ };
3390
+
2532
3391
  // src/editor.ts
2533
3392
  var MIN_ZOOM = 0.1;
2534
3393
  var MAX_ZOOM = 8;
@@ -2546,6 +3405,8 @@ var CanvasEditor = class {
2546
3405
  patterns;
2547
3406
  curves;
2548
3407
  maskPresets;
3408
+ /** Stacked boolean masks, per layer and for the design as a whole. */
3409
+ layerMasks;
2549
3410
  fonts;
2550
3411
  licensing;
2551
3412
  pages;
@@ -2594,6 +3455,7 @@ var CanvasEditor = class {
2594
3455
  this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2595
3456
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2596
3457
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
3458
+ this.layerMasks = new LayerMaskManager(this.canvas, this.layers, this.history, this.events);
2597
3459
  this.setupCanvasEvents();
2598
3460
  this.refreshSelectionStyle();
2599
3461
  this.history.saveImmediate();
@@ -2609,6 +3471,7 @@ var CanvasEditor = class {
2609
3471
  { originX: "left", originY: "top", ...options }
2610
3472
  );
2611
3473
  const layer = this.layers.add("image", img);
3474
+ this.layers.select(layer.id);
2612
3475
  this.history.save();
2613
3476
  return layer;
2614
3477
  } catch (error) {
@@ -2668,12 +3531,14 @@ var CanvasEditor = class {
2668
3531
  ...options
2669
3532
  });
2670
3533
  const layer = this.layers.add("text", textbox);
3534
+ this.layers.select(layer.id);
2671
3535
  this.history.save();
2672
3536
  return layer;
2673
3537
  }
2674
3538
  addShape(plugin, options) {
2675
3539
  const obj = plugin.create(options);
2676
3540
  const layer = this.layers.add("shape", obj, plugin.name);
3541
+ this.layers.select(layer.id);
2677
3542
  this.history.save();
2678
3543
  return layer;
2679
3544
  }
@@ -2699,7 +3564,7 @@ var CanvasEditor = class {
2699
3564
  const { objects, options } = await loadSVGFromString(resolved);
2700
3565
  const validObjects = objects.filter((object) => object !== null);
2701
3566
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
2702
- const group = util3.groupSVGElements(validObjects, options);
3567
+ const group = util5.groupSVGElements(validObjects, options);
2703
3568
  group.set({
2704
3569
  left: this.canvas.getWidth() / 2,
2705
3570
  top: this.canvas.getHeight() / 2,
@@ -2707,6 +3572,7 @@ var CanvasEditor = class {
2707
3572
  originY: "center"
2708
3573
  });
2709
3574
  const layer = this.layers.add("template", group, template.name);
3575
+ this.layers.select(layer.id);
2710
3576
  this.history.save();
2711
3577
  return layer;
2712
3578
  }
@@ -2758,7 +3624,7 @@ var CanvasEditor = class {
2758
3624
  opacity: layer.opacity
2759
3625
  });
2760
3626
  this.patterns.attachTo(copy);
2761
- this.canvas.setActiveObject(clone);
3627
+ this.layers.select(copy.id);
2762
3628
  this.canvas.requestRenderAll();
2763
3629
  this.history.save();
2764
3630
  return copy;
@@ -2791,7 +3657,7 @@ var CanvasEditor = class {
2791
3657
  const childData = children.map((layer) => structuredClone(layer.toData()));
2792
3658
  const objects = children.map((layer) => layer.fabricObject);
2793
3659
  for (const layer of children) this.layers.remove(layer.id);
2794
- const group = new Group(objects);
3660
+ const group = new Group6(objects);
2795
3661
  const grouped = this.layers.add("group", group, name);
2796
3662
  grouped.meta.groupChildren = childData;
2797
3663
  this.layers.select(grouped.id);
@@ -2806,11 +3672,9 @@ var CanvasEditor = class {
2806
3672
  if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
2807
3673
  const group = grouped.fabricObject;
2808
3674
  return this.history.transaction(() => {
2809
- const transform = group.calcTransformMatrix();
2810
3675
  const objects = group.removeAll();
2811
3676
  this.layers.remove(id);
2812
3677
  const restored = objects.map((object, index) => {
2813
- util3.addTransformToObject(object, transform);
2814
3678
  object.setCoords();
2815
3679
  const data = childData[index];
2816
3680
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -2838,6 +3702,7 @@ var CanvasEditor = class {
2838
3702
  try {
2839
3703
  await deserializeEditor(this, state);
2840
3704
  await this.patterns.rehydrateAll();
3705
+ this.layerMasks.refreshAll();
2841
3706
  } catch (error) {
2842
3707
  if (!managedByHistory) {
2843
3708
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -3410,6 +4275,7 @@ var CanvasEditor = class {
3410
4275
  // ─── Cleanup ────────────────────────────────────────
3411
4276
  dispose() {
3412
4277
  this.masks.dispose();
4278
+ this.layerMasks.dispose();
3413
4279
  this.snapping.dispose();
3414
4280
  this.crop.dispose();
3415
4281
  this.history.dispose();
@@ -3532,6 +4398,7 @@ var AnnotationOverlay = class {
3532
4398
  };
3533
4399
  export {
3534
4400
  AnnotationOverlay,
4401
+ CANVAS_MASK_TARGET,
3535
4402
  CANVAS_SIZE_PRESETS,
3536
4403
  CanvasEditor,
3537
4404
  CropController,
@@ -3544,6 +4411,7 @@ export {
3544
4411
  HistoryManager,
3545
4412
  Layer,
3546
4413
  LayerManager,
4414
+ LayerMaskManager,
3547
4415
  LicenseManager,
3548
4416
  MaskController,
3549
4417
  MaskPresetManager,
@@ -3565,6 +4433,7 @@ export {
3565
4433
  buildCurvePathData,
3566
4434
  clamp,
3567
4435
  clearTextureMaskCache,
4436
+ composeMaskGroup,
3568
4437
  computeCoverPlacement,
3569
4438
  computePrintAreaClip,
3570
4439
  computeTilePositions,
@@ -3577,11 +4446,13 @@ export {
3577
4446
  exportPNG,
3578
4447
  exportPrintArea,
3579
4448
  exportSVG,
4449
+ fitToBox,
3580
4450
  generateId,
3581
4451
  isCssColor,
3582
4452
  isMaskPresetId,
3583
4453
  isShapeMaskId,
3584
4454
  isTextureMaskId,
4455
+ needsAbsoluteSpace,
3585
4456
  readLayerShadow,
3586
4457
  renderTextureMask,
3587
4458
  resetTransform,
@@ -3589,6 +4460,9 @@ export {
3589
4460
  round2,
3590
4461
  sanitizeSvg,
3591
4462
  serializeEditor,
3592
- shapeMaskPathData
4463
+ shapeMaskPathData,
4464
+ toCanvasSpace,
4465
+ toHostSpace,
4466
+ unwrapGroup
3593
4467
  };
3594
4468
  //# sourceMappingURL=index.mjs.map