@overtone-art/canvas-editor-core 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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;
@@ -879,8 +886,10 @@ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
879
886
  }
880
887
  return placements;
881
888
  }
882
- function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
889
+ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin, draw) {
883
890
  const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
891
+ const drawW = draw && draw.width > 0 ? draw.width : baseW;
892
+ const drawH = draw && draw.height > 0 ? draw.height : baseH;
884
893
  ctx.save();
885
894
  ctx.translate(anchor.x, anchor.y);
886
895
  ctx.rotate(config.angle * Math.PI / 180);
@@ -888,7 +897,7 @@ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
888
897
  ctx.save();
889
898
  ctx.translate(tile.x, tile.y);
890
899
  ctx.rotate(tile.rotation);
891
- ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
900
+ ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
892
901
  ctx.restore();
893
902
  }
894
903
  ctx.restore();
@@ -910,46 +919,43 @@ function mod2(n) {
910
919
  var MAX_SNAPSHOT_PIXELS = 16e6;
911
920
  var MAX_SNAPSHOT_SCALE = 8;
912
921
  var SNAPSHOT_SHRINK_FACTOR = 2;
922
+ var TILE_BLEED_DEVICE_PX = 2;
913
923
  var TiledPatternObject = class _TiledPatternObject extends FabricObject {
914
924
  static type = "TiledPattern";
915
925
  /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
916
926
  source;
917
927
  config;
928
+ /** The print area this pattern fills, in canvas units. */
929
+ area;
918
930
  snapshotEl = null;
919
931
  snapshotScale = 0;
920
- constructor(source, config, width, height) {
932
+ constructor(source, config, area) {
921
933
  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,
934
+ // Centre origin: the box tracks a tile, and a tile is placed by its centre.
935
+ originX: "center",
936
+ originY: "center",
935
937
  objectCaching: false,
936
- hasControls: false,
937
- hasBorders: false
938
+ // Side handles would imply a non-uniform tile scale; the config has one.
939
+ lockScalingFlip: true
938
940
  });
939
941
  this.source = source;
940
942
  this.config = config;
943
+ this.area = area;
944
+ this.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false });
945
+ this.syncBox();
941
946
  }
942
- /** Swap in a new config; the next render picks it up. */
947
+ /** Swap in a new config and re-fit the box to the tile it now describes. */
943
948
  setConfig(config) {
944
949
  this.config = config;
945
- this.dirty = true;
950
+ this.syncBox();
946
951
  }
947
- /** Resize to a new print area. */
948
- setArea(width, height) {
949
- this.set({ width, height });
950
- this.setCoords();
952
+ /** Re-fit to a new print area (canvas resize). */
953
+ setArea(area) {
954
+ this.area = area;
955
+ this.invalidate();
956
+ this.syncBox();
951
957
  }
952
- /** Drop the cached source snapshot (e.g. the source was edited). */
958
+ /** Drop the cached source snapshot (the source was edited). */
953
959
  invalidate() {
954
960
  this.snapshotEl = null;
955
961
  this.snapshotScale = 0;
@@ -960,40 +966,118 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
960
966
  this.snapshotEl = null;
961
967
  this.snapshotScale = 0;
962
968
  }
969
+ /**
970
+ * Put the box back on the anchor tile: the tile's size, the pattern angle, and
971
+ * the grid origin displaced by the configured phase shift. Resets any live
972
+ * gesture scale, so it must run only once that gesture has been folded in.
973
+ */
974
+ syncBox() {
975
+ const { tileW, tileH } = this.baseTile();
976
+ const origin = this.source.getCenterPoint();
977
+ const anchor = this.anchorFromOrigin(origin, tileW, tileH, this.config.angle);
978
+ this.set({
979
+ left: anchor.x,
980
+ top: anchor.y,
981
+ width: tileW,
982
+ height: tileH,
983
+ scaleX: 1,
984
+ scaleY: 1,
985
+ angle: this.config.angle
986
+ });
987
+ this.setCoords();
988
+ this.dirty = true;
989
+ }
990
+ /** Grid rotation in play right now — the live handle during a rotate gesture. */
991
+ liveAngle() {
992
+ return this.angle ?? 0;
993
+ }
994
+ /** Tile scale in play right now, as a config percentage. */
995
+ liveScale() {
996
+ return (this.config.scale ?? 100) * Math.abs(this.scaleX ?? 1);
997
+ }
998
+ /**
999
+ * Where the source's centre would have to sit for the box to stay put — i.e.
1000
+ * the grid origin implied by a drag. `PatternManager` moves the source there.
1001
+ */
1002
+ liveOrigin() {
1003
+ const { tileW, tileH } = this.liveTile();
1004
+ const centre = this.getCenterPoint();
1005
+ const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1006
+ return new Point(centre.x - shift.x, centre.y - shift.y);
1007
+ }
963
1008
  _render(ctx) {
964
- const width = this.width ?? 0;
965
- const height = this.height ?? 0;
1009
+ const { width, height } = this.area;
966
1010
  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);
1011
+ const { tileW, tileH } = this.liveTile();
1012
+ const scale = contextScale(ctx);
1013
+ const snapshot = this.ensureSnapshot(scale * (tileW / Math.max(1, this.baseW())));
969
1014
  if (!snapshot) return;
970
- const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1015
+ const centre = this.getCenterPoint();
1016
+ const angle = this.liveAngle();
971
1017
  ctx.save();
972
- ctx.translate(-width / 2, -height / 2);
973
- drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1018
+ ctx.scale(1 / (this.scaleX || 1), 1 / (this.scaleY || 1));
1019
+ ctx.rotate(-angle * Math.PI / 180);
1020
+ ctx.translate(-centre.x, -centre.y);
1021
+ const shift = this.shiftVector(tileW, tileH, angle);
1022
+ const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
1023
+ const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
1024
+ drawTiles(
1025
+ ctx,
1026
+ snapshot,
1027
+ config,
1028
+ width,
1029
+ height,
1030
+ tileW,
1031
+ tileH,
1032
+ origin,
1033
+ this.drawSize(tileW, tileH, TILE_BLEED_DEVICE_PX / scale)
1034
+ );
974
1035
  ctx.restore();
975
1036
  }
976
1037
  /** Raster fallback for SVG export — one `<image>` covering the print area. */
977
1038
  _toSVG() {
978
- const width = this.width ?? 0;
979
- const height = this.height ?? 0;
1039
+ const { width, height } = this.area;
980
1040
  if (width <= 0 || height <= 0) return [];
981
1041
  const el = document.createElement("canvas");
982
1042
  el.width = Math.max(1, Math.round(width));
983
1043
  el.height = Math.max(1, Math.round(height));
984
1044
  const ctx = el.getContext("2d");
985
1045
  if (!ctx) return [];
986
- const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
987
- const snapshot = this.ensureSnapshot(tileScale);
1046
+ const { tileW, tileH } = this.liveTile();
1047
+ const snapshot = this.ensureSnapshot(tileW / Math.max(1, this.baseW()));
988
1048
  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());
1049
+ const centre = this.getCenterPoint();
1050
+ const angle = this.liveAngle();
1051
+ const shift = this.shiftVector(tileW, tileH, angle);
1052
+ drawTiles(
1053
+ ctx,
1054
+ snapshot,
1055
+ { ...this.config, angle, offsetX: 0, offsetY: 0 },
1056
+ width,
1057
+ height,
1058
+ tileW,
1059
+ tileH,
1060
+ { x: centre.x - shift.x, y: centre.y - shift.y },
1061
+ // The fallback raster is built at 1:1, so a device pixel is a canvas unit.
1062
+ this.drawSize(tileW, tileH, TILE_BLEED_DEVICE_PX)
1063
+ );
991
1064
  return [
992
- `<image x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" `,
993
- `xlink:href="${el.toDataURL("image/png")}"></image>
1065
+ `<g transform="rotate(${-angle}) translate(${-centre.x} ${-centre.y})">`,
1066
+ `<image x="0" y="0" width="${width}" height="${height}" `,
1067
+ `xlink:href="${el.toDataURL("image/png")}"></image></g>
994
1068
  `
995
1069
  ];
996
1070
  }
1071
+ /**
1072
+ * The proxy holds a live reference to its source, which would make a
1073
+ * serialized canvas circular. Nothing persists this object (only layers are
1074
+ * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1075
+ */
1076
+ toObject() {
1077
+ const plain = super.toObject();
1078
+ delete plain.source;
1079
+ return plain;
1080
+ }
997
1081
  /**
998
1082
  * Fabric's generic clone round-trips through `toObject()` + the class
999
1083
  * registry, which cannot carry a live source reference. Export paths clone
@@ -1001,46 +1085,110 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1001
1085
  * tiling. The copy shares the source (it only ever reads from it).
1002
1086
  */
1003
1087
  clone() {
1004
- const copy = new _TiledPatternObject(
1005
- this.source,
1006
- this.config,
1007
- this.width ?? 0,
1008
- this.height ?? 0
1009
- );
1088
+ const copy = new _TiledPatternObject(this.source, this.config, this.area);
1010
1089
  copy.set({
1011
1090
  left: this.left,
1012
1091
  top: this.top,
1092
+ angle: this.angle,
1093
+ scaleX: this.scaleX,
1094
+ scaleY: this.scaleY,
1095
+ width: this.width,
1096
+ height: this.height,
1013
1097
  visible: this.visible,
1014
1098
  opacity: this.opacity
1015
1099
  });
1016
1100
  return Promise.resolve(copy);
1017
1101
  }
1018
1102
  /**
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.
1103
+ * The source's tile footprint: its bounding box, minus a stroke it reserves
1104
+ * room for but never paints.
1105
+ *
1106
+ * fabric keeps `strokeWidth` inside an object's box whether or not a `stroke`
1107
+ * colour paints it, and the built-in shapes arrive with `strokeWidth: 1` and no
1108
+ * stroke. Stepping the grid by that box puts a transparent seam between every
1109
+ * pair of neighbours at zero spacing: the artwork is a pixel narrower than the
1110
+ * box it is stepped by.
1022
1111
  */
1023
- toObject() {
1024
- const plain = super.toObject();
1025
- delete plain.source;
1026
- return plain;
1112
+ sourceBox() {
1113
+ const rect = this.source.getBoundingRect();
1114
+ if (paintsStroke(this.source)) return { width: rect.width, height: rect.height };
1115
+ const dims = this.source._getTransformedDimensions({ strokeWidth: 0 });
1116
+ const radians = (this.source.angle ?? 0) * Math.PI / 180;
1117
+ const cos = Math.abs(Math.cos(radians));
1118
+ const sin = Math.abs(Math.sin(radians));
1119
+ return {
1120
+ width: dims.x * cos + dims.y * sin,
1121
+ height: dims.x * sin + dims.y * cos
1122
+ };
1123
+ }
1124
+ /** The source's on-canvas width, before the tile scale. */
1125
+ baseW() {
1126
+ return Math.max(1, this.sourceBox().width);
1127
+ }
1128
+ /** The source's on-canvas height, before the tile scale. */
1129
+ baseH() {
1130
+ return Math.max(1, this.sourceBox().height);
1131
+ }
1132
+ /**
1133
+ * Size to paint the snapshot at, for a tile of `tileW × tileH`.
1134
+ *
1135
+ * The snapshot is NOT exactly the source's box: `toCanvasElement` rounds the
1136
+ * raster up to whole pixels and pads it further for a shadow. Painting it into
1137
+ * the tile step would squeeze the artwork inside that padding — every tile
1138
+ * shrinks by up to a pixel and the grid shows transparent seams at zero
1139
+ * spacing. So the bitmap is painted at its own footprint, scaled by the same
1140
+ * factor the tile is, and the step stays the source's box.
1141
+ */
1142
+ drawSize(tileW, tileH, bleed = 0) {
1143
+ const snapshot = this.snapshotEl;
1144
+ if (!snapshot || this.snapshotScale <= 0) {
1145
+ return { width: tileW + bleed, height: tileH + bleed };
1146
+ }
1147
+ return {
1148
+ width: snapshot.width / this.snapshotScale * (tileW / this.baseW()) + bleed,
1149
+ height: snapshot.height / this.snapshotScale * (tileH / this.baseH()) + bleed
1150
+ };
1151
+ }
1152
+ /** Tile size from the config alone, ignoring any in-flight gesture. */
1153
+ baseTile() {
1154
+ const box = this.sourceBox();
1155
+ const scale = Math.max(1, this.config.scale ?? 100) / 100;
1156
+ const floor = this.tileFloor();
1157
+ return {
1158
+ tileW: Math.max(floor, box.width * scale),
1159
+ tileH: Math.max(floor, box.height * scale)
1160
+ };
1027
1161
  }
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);
1162
+ /** Tile size as drawn right now, including a live scale gesture. */
1163
+ liveTile() {
1164
+ const base = this.baseTile();
1165
+ const floor = this.tileFloor();
1031
1166
  return {
1032
- tileW: Math.max(floor, snapshot.width / this.snapshotScale * tileScale),
1033
- tileH: Math.max(floor, snapshot.height / this.snapshotScale * tileScale)
1167
+ tileW: Math.max(floor, base.tileW * Math.abs(this.scaleX ?? 1)),
1168
+ tileH: Math.max(floor, base.tileH * Math.abs(this.scaleY ?? 1))
1034
1169
  };
1035
1170
  }
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) };
1171
+ /** Smallest tile the draw loop may use, so a tiny tile can't flood the area. */
1172
+ tileFloor() {
1173
+ const { width, height } = this.area;
1174
+ return Math.max(2, Math.sqrt(width * width + height * height) / 200);
1175
+ }
1176
+ /** Phase shift (`offsetX`/`offsetY`) as a canvas-space vector. */
1177
+ shiftVector(tileW, tileH, angle) {
1178
+ const dx = tileW * (clampPercent(this.config.offsetX) / 100);
1179
+ const dy = tileH * (clampPercent(this.config.offsetY) / 100);
1180
+ const radians = angle * Math.PI / 180;
1181
+ const cos = Math.cos(radians);
1182
+ const sin = Math.sin(radians);
1183
+ return new Point(dx * cos - dy * sin, dx * sin + dy * cos);
1184
+ }
1185
+ anchorFromOrigin(origin, tileW, tileH, angle) {
1186
+ const shift = this.shiftVector(tileW, tileH, angle);
1187
+ return new Point(origin.x + shift.x, origin.y + shift.y);
1040
1188
  }
1041
1189
  /**
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.
1190
+ * Snapshot the source at (at least) `scale`, reusing the cached one while it
1191
+ * is still sharp enough and the source has not changed.
1044
1192
  */
1045
1193
  ensureSnapshot(scale) {
1046
1194
  const wanted = this.clampScale(scale);
@@ -1071,6 +1219,16 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1071
1219
  return Math.max(0.05, Math.min(requested, budgeted));
1072
1220
  }
1073
1221
  };
1222
+ function paintsStroke(object) {
1223
+ if (!object.strokeWidth || object.strokeWidth <= 0) return false;
1224
+ const { stroke } = object;
1225
+ if (typeof stroke === "string") return stroke !== "" && stroke !== "transparent";
1226
+ return stroke !== null && stroke !== void 0;
1227
+ }
1228
+ function clampPercent(value) {
1229
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1230
+ return Math.max(-100, Math.min(100, value));
1231
+ }
1074
1232
  function contextScale(ctx) {
1075
1233
  if (typeof ctx.getTransform !== "function") return 1;
1076
1234
  try {
@@ -1082,13 +1240,15 @@ function contextScale(ctx) {
1082
1240
  }
1083
1241
 
1084
1242
  // src/pattern/pattern-manager.ts
1243
+ var MIN_TILE_SCALE = 10;
1244
+ var MAX_TILE_SCALE = 300;
1085
1245
  var PatternManager = class {
1086
1246
  constructor(canvas, layers, history, events) {
1087
1247
  this.canvas = canvas;
1088
1248
  this.layers = layers;
1089
1249
  this.history = history;
1090
1250
  this.events = events;
1091
- this.canvas.on("object:modified", this.onSourceModified);
1251
+ this.canvas.on("object:modified", this.onObjectModified);
1092
1252
  this.canvas.on("text:changed", this.onSourceModified);
1093
1253
  this.events.on("layer:removed", this.onLayerRemoved);
1094
1254
  }
@@ -1097,6 +1257,11 @@ var PatternManager = class {
1097
1257
  history;
1098
1258
  events;
1099
1259
  proxies = /* @__PURE__ */ new Map();
1260
+ onObjectModified = (event) => {
1261
+ const target = event.target;
1262
+ if (target instanceof TiledPatternObject) this.commitGesture(target);
1263
+ else this.invalidateFor(target);
1264
+ };
1100
1265
  onSourceModified = (event) => {
1101
1266
  this.invalidateFor(event.target);
1102
1267
  };
@@ -1138,7 +1303,11 @@ var PatternManager = class {
1138
1303
  try {
1139
1304
  this.detach(layerId);
1140
1305
  restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1141
- layer.fabricObject.set({ opacity: layer.opacity });
1306
+ layer.fabricObject.set({
1307
+ opacity: layer.opacity,
1308
+ selectable: !layer.locked,
1309
+ evented: !layer.locked
1310
+ });
1142
1311
  this.layers.setMeta(layerId, { pattern: void 0 });
1143
1312
  this.canvas.requestRenderAll();
1144
1313
  this.history.save();
@@ -1170,12 +1339,8 @@ var PatternManager = class {
1170
1339
  }
1171
1340
  /** Re-fit every proxy to the print area (canvas resize). */
1172
1341
  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
- }
1342
+ const area = this.area();
1343
+ for (const proxy of this.proxies.values()) proxy.setArea(area);
1179
1344
  if (this.proxies.size > 0) this.canvas.requestRenderAll();
1180
1345
  }
1181
1346
  /** Drop a layer's cached source snapshot (its content changed). */
@@ -1183,6 +1348,7 @@ var PatternManager = class {
1183
1348
  const proxy = this.proxies.get(layerId);
1184
1349
  if (!proxy) return;
1185
1350
  proxy.invalidate();
1351
+ proxy.syncBox();
1186
1352
  this.canvas.requestRenderAll();
1187
1353
  }
1188
1354
  /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
@@ -1192,7 +1358,7 @@ var PatternManager = class {
1192
1358
  this.attach(layer, state.config);
1193
1359
  }
1194
1360
  dispose() {
1195
- this.canvas.off("object:modified", this.onSourceModified);
1361
+ this.canvas.off("object:modified", this.onObjectModified);
1196
1362
  this.canvas.off("text:changed", this.onSourceModified);
1197
1363
  this.events.off("layer:removed", this.onLayerRemoved);
1198
1364
  this.clearProxies();
@@ -1203,17 +1369,38 @@ var PatternManager = class {
1203
1369
  this.proxies.clear();
1204
1370
  }
1205
1371
  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 });
1372
+ const proxy = new TiledPatternObject(layer.fabricObject, config, this.area());
1373
+ const wasActive = this.canvas.getActiveObject() === layer.fabricObject;
1374
+ layer.fabricObject.set({ opacity: 0, selectable: false, evented: false });
1213
1375
  this.proxies.set(layer.id, proxy);
1214
1376
  this.layers.setRenderProxy(layer.id, proxy);
1377
+ if (wasActive) this.canvas.setActiveObject(proxy);
1215
1378
  this.canvas.requestRenderAll();
1216
1379
  }
1380
+ /**
1381
+ * Fold a finished drag / scale / rotate on the proxy back into the pattern:
1382
+ * position becomes the grid origin (carried by the source), scale becomes the
1383
+ * tile scale, rotation becomes the pattern angle. Read the live transform
1384
+ * first — writing the config re-fits the box and destroys it.
1385
+ */
1386
+ commitGesture(proxy) {
1387
+ const layer = this.layers.findByObject(proxy);
1388
+ const state = layer?.meta.pattern;
1389
+ if (!layer || !state) return;
1390
+ const origin = proxy.liveOrigin();
1391
+ const scale = clamp2(proxy.liveScale(), MIN_TILE_SCALE, MAX_TILE_SCALE);
1392
+ const angle = normalizeAngle(proxy.liveAngle());
1393
+ layer.fabricObject.setPositionByOrigin(origin, "center", "center");
1394
+ layer.fabricObject.setCoords();
1395
+ const config = { ...state.config, scale, angle };
1396
+ this.layers.setMeta(layer.id, { pattern: { ...state, config } });
1397
+ proxy.setConfig(config);
1398
+ this.canvas.requestRenderAll();
1399
+ this.history.save();
1400
+ }
1401
+ area() {
1402
+ return { width: this.canvas.getWidth(), height: this.canvas.getHeight() };
1403
+ }
1217
1404
  detach(layerId) {
1218
1405
  const proxy = this.proxies.get(layerId);
1219
1406
  if (!proxy) {
@@ -1230,6 +1417,15 @@ var PatternManager = class {
1230
1417
  if (layer) this.invalidate(layer.id);
1231
1418
  }
1232
1419
  };
1420
+ function clamp2(value, min, max) {
1421
+ if (!Number.isFinite(value)) return min;
1422
+ return Math.max(min, Math.min(max, value));
1423
+ }
1424
+ function normalizeAngle(angle) {
1425
+ if (!Number.isFinite(angle)) return 0;
1426
+ const wrapped = (angle % 360 + 360) % 360;
1427
+ return Math.round(wrapped > 180 ? wrapped - 360 : wrapped);
1428
+ }
1233
1429
  function isLegacyState(state) {
1234
1430
  return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
1235
1431
  }
@@ -2529,6 +2725,737 @@ var MaskController = class {
2529
2725
  }
2530
2726
  };
2531
2727
 
2728
+ // src/masks/manager.ts
2729
+ import { Group as Group5 } from "fabric";
2730
+
2731
+ // src/masks/compose.ts
2732
+ import { Group, Rect as Rect2 } from "fabric";
2733
+ var MODE_OPERATION = {
2734
+ add: "source-over",
2735
+ subtract: "destination-out",
2736
+ intersect: "destination-in"
2737
+ };
2738
+ function neutralize(child) {
2739
+ child.set({ opacity: 0, globalCompositeOperation: "source-over" });
2740
+ }
2741
+ function baseRect(box) {
2742
+ return new Rect2({
2743
+ left: box.left,
2744
+ top: box.top,
2745
+ width: Math.max(1, box.width),
2746
+ height: Math.max(1, box.height),
2747
+ originX: "left",
2748
+ originY: "top",
2749
+ fill: "#000000",
2750
+ objectCaching: false
2751
+ });
2752
+ }
2753
+ function composeMaskGroup(children, entries, options) {
2754
+ if (children.length === 0) return void 0;
2755
+ children.forEach((child, index) => {
2756
+ const entry = entries[index];
2757
+ child.set({ objectCaching: false });
2758
+ if (!entry || !entry.visible) {
2759
+ neutralize(child);
2760
+ return;
2761
+ }
2762
+ child.set({
2763
+ opacity: entry.opacity,
2764
+ globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
2765
+ });
2766
+ });
2767
+ const first = entries.find((entry) => entry.visible);
2768
+ const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
2769
+ return new Group(withBase, {
2770
+ absolutePositioned: options.absolute,
2771
+ // Cached, so the children's compositing operations resolve against each
2772
+ // other instead of against the page underneath the mask.
2773
+ objectCaching: true,
2774
+ subTargetCheck: false,
2775
+ interactive: false
2776
+ });
2777
+ }
2778
+ function needsAbsoluteSpace(entries) {
2779
+ return entries.some((entry) => !entry.linked);
2780
+ }
2781
+
2782
+ // src/masks/edit.ts
2783
+ import { util as util4 } from "fabric";
2784
+
2785
+ // src/masks/space.ts
2786
+ import { util as util3 } from "fabric";
2787
+ function matrixOf(object) {
2788
+ return object.calcTransformMatrix();
2789
+ }
2790
+ function applyMatrix(object, matrix) {
2791
+ const decomposed = util3.qrDecompose(matrix);
2792
+ object.set({
2793
+ flipX: false,
2794
+ flipY: false,
2795
+ originX: "center",
2796
+ originY: "center",
2797
+ left: decomposed.translateX,
2798
+ top: decomposed.translateY,
2799
+ scaleX: decomposed.scaleX,
2800
+ scaleY: decomposed.scaleY,
2801
+ angle: decomposed.angle,
2802
+ skewX: decomposed.skewX,
2803
+ skewY: 0
2804
+ });
2805
+ object.setCoords();
2806
+ }
2807
+ function toCanvasSpace(object, host) {
2808
+ applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
2809
+ }
2810
+ function toHostSpace(object, host) {
2811
+ applyMatrix(
2812
+ object,
2813
+ util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object))
2814
+ );
2815
+ }
2816
+ function relativeMatrix(object, host) {
2817
+ return util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object));
2818
+ }
2819
+ function applyRelativeMatrix(object, host, rel) {
2820
+ applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), rel));
2821
+ }
2822
+ function asObject(clip) {
2823
+ return clip;
2824
+ }
2825
+ function toMatrix(values) {
2826
+ if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
2827
+ return null;
2828
+ return [values[0], values[1], values[2], values[3], values[4], values[5]];
2829
+ }
2830
+ function fitToBox(object, box, zoom = 1) {
2831
+ const width = Math.max(1, box.width) * zoom;
2832
+ const height = Math.max(1, box.height) * zoom;
2833
+ object.set({
2834
+ originX: "center",
2835
+ originY: "center",
2836
+ angle: 0,
2837
+ skewX: 0,
2838
+ skewY: 0,
2839
+ left: box.left + box.width / 2,
2840
+ top: box.top + box.height / 2,
2841
+ scaleX: width / Math.max(1, object.width ?? 1),
2842
+ scaleY: height / Math.max(1, object.height ?? 1)
2843
+ });
2844
+ object.setCoords();
2845
+ }
2846
+ function unwrapGroup(group) {
2847
+ const children = group.removeAll();
2848
+ for (const child of children) child.setCoords();
2849
+ return children;
2850
+ }
2851
+
2852
+ // src/masks/edit.ts
2853
+ var MaskEditController = class {
2854
+ constructor(canvas, onCommit) {
2855
+ this.canvas = canvas;
2856
+ this.onCommit = onCommit;
2857
+ }
2858
+ canvas;
2859
+ onCommit;
2860
+ handle = null;
2861
+ editing = null;
2862
+ child = null;
2863
+ group = null;
2864
+ active() {
2865
+ return this.editing;
2866
+ }
2867
+ /** Put a handle on the canvas for `child`, an object inside `group`. */
2868
+ async begin(edit, group, child) {
2869
+ this.end();
2870
+ const handle = await child.clone();
2871
+ applyMatrix(handle, matrixOf(child));
2872
+ handle.set({
2873
+ // Outline only: the mask's own fill would sit over the artwork it is
2874
+ // supposed to be revealing.
2875
+ fill: "rgba(0,0,0,0.001)",
2876
+ stroke: "#e0b055",
2877
+ strokeWidth: 2,
2878
+ strokeDashArray: [6, 4],
2879
+ strokeUniform: true,
2880
+ opacity: 1,
2881
+ selectable: true,
2882
+ evented: true,
2883
+ hasControls: true,
2884
+ hasBorders: true,
2885
+ objectCaching: false,
2886
+ excludeFromExport: true
2887
+ });
2888
+ this.handle = handle;
2889
+ this.editing = edit;
2890
+ this.child = child;
2891
+ this.group = group;
2892
+ this.canvas.add(handle);
2893
+ this.canvas.setActiveObject(handle);
2894
+ this.canvas.on("object:moving", this.onTransform);
2895
+ this.canvas.on("object:scaling", this.onTransform);
2896
+ this.canvas.on("object:rotating", this.onTransform);
2897
+ this.canvas.on("object:modified", this.onModified);
2898
+ this.canvas.requestRenderAll();
2899
+ return true;
2900
+ }
2901
+ /** Take the handle down. The geometry it wrote is already in the clip. */
2902
+ end() {
2903
+ if (!this.handle) return;
2904
+ this.canvas.off("object:moving", this.onTransform);
2905
+ this.canvas.off("object:scaling", this.onTransform);
2906
+ this.canvas.off("object:rotating", this.onTransform);
2907
+ this.canvas.off("object:modified", this.onModified);
2908
+ if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
2909
+ this.canvas.remove(this.handle);
2910
+ this.handle = null;
2911
+ this.editing = null;
2912
+ this.child = null;
2913
+ this.group = null;
2914
+ this.canvas.requestRenderAll();
2915
+ }
2916
+ dispose() {
2917
+ this.end();
2918
+ }
2919
+ onTransform = (event) => {
2920
+ if (!this.handle || event.target !== this.handle) return;
2921
+ this.write();
2922
+ };
2923
+ onModified = (event) => {
2924
+ if (!this.handle || event.target !== this.handle) return;
2925
+ this.write();
2926
+ this.onCommit();
2927
+ };
2928
+ /** Handle transform (canvas space) → clip child transform (group-relative). */
2929
+ write() {
2930
+ if (!this.handle || !this.child || !this.group) return;
2931
+ applyMatrix(
2932
+ this.child,
2933
+ util4.multiplyTransformMatrices(
2934
+ util4.invertTransform(matrixOf(this.group)),
2935
+ matrixOf(this.handle)
2936
+ )
2937
+ );
2938
+ this.group.dirty = true;
2939
+ this.group.set({ dirty: true });
2940
+ this.canvas.requestRenderAll();
2941
+ }
2942
+ };
2943
+
2944
+ // src/masks/store.ts
2945
+ import { Group as Group4 } from "fabric";
2946
+
2947
+ // src/masks/host.ts
2948
+ import { Rect as Rect3 } from "fabric";
2949
+ function findCanvasHost(layers) {
2950
+ return layers.getAll().find((layer) => layer.meta.canvasMask);
2951
+ }
2952
+ function createCanvasHost(canvas, layers) {
2953
+ const rect = new Rect3({
2954
+ left: 0,
2955
+ top: 0,
2956
+ width: canvas.getWidth(),
2957
+ height: canvas.getHeight(),
2958
+ originX: "left",
2959
+ originY: "top",
2960
+ fill: "#000000",
2961
+ globalCompositeOperation: "destination-in",
2962
+ // It is edited from the layer list, never on the canvas: a drag box on
2963
+ // something that cannot be dragged only reads as broken.
2964
+ selectable: false,
2965
+ evented: false,
2966
+ hasControls: false,
2967
+ hasBorders: false,
2968
+ objectCaching: false
2969
+ });
2970
+ const layer = layers.add("mask", rect, "Design mask");
2971
+ layer.meta.canvasMask = true;
2972
+ layers.setLocked(layer.id, true);
2973
+ return layer;
2974
+ }
2975
+ function pinCanvasHost(layers) {
2976
+ const all = layers.getAll();
2977
+ const hostIndex = all.findIndex((layer) => layer.meta.canvasMask);
2978
+ if (hostIndex === -1) return false;
2979
+ let lastContent = -1;
2980
+ all.forEach((layer, index) => {
2981
+ if (layer.type !== "mask") lastContent = index;
2982
+ });
2983
+ if (hostIndex >= lastContent) return false;
2984
+ layers.reorder(all[hostIndex].id, all.length - 1);
2985
+ return true;
2986
+ }
2987
+ function hostBoxOf(canvas, host, absolute) {
2988
+ if (!host) {
2989
+ return { left: 0, top: 0, width: canvas.getWidth(), height: canvas.getHeight() };
2990
+ }
2991
+ if (absolute) {
2992
+ host.setCoords();
2993
+ const rect = host.getBoundingRect();
2994
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
2995
+ }
2996
+ const width = Math.max(1, host.width ?? 1);
2997
+ const height = Math.max(1, host.height ?? 1);
2998
+ return { left: -width / 2, top: -height / 2, width, height };
2999
+ }
3000
+
3001
+ // src/masks/install.ts
3002
+ import { Group as Group3 } from "fabric";
3003
+ function convertSpace(host, sources, absolute) {
3004
+ const wasAbsolute = host.clipPath instanceof Group3 ? host.clipPath.absolutePositioned : absolute;
3005
+ if (absolute === wasAbsolute) return;
3006
+ for (const source of sources) {
3007
+ if (absolute) toCanvasSpace(source, host);
3008
+ else toHostSpace(source, host);
3009
+ }
3010
+ }
3011
+ function withRelativeTransforms(entries, sources, host, absolute) {
3012
+ return entries.map((entry, index) => {
3013
+ const source = sources[index];
3014
+ if (absolute && entry.linked && source) {
3015
+ return { ...entry, rel: [...relativeMatrix(source, host)] };
3016
+ }
3017
+ if (!entry.rel) return entry;
3018
+ const rest = { ...entry };
3019
+ delete rest.rel;
3020
+ return rest;
3021
+ });
3022
+ }
3023
+ function installClip(host, entries, sources, box, absolute) {
3024
+ host.clipPath = composeMaskGroup(sources, entries, { box, absolute });
3025
+ host.dirty = true;
3026
+ host.setCoords();
3027
+ }
3028
+
3029
+ // src/masks/store.ts
3030
+ var CANVAS_MASK_TARGET = "canvas";
3031
+ var DEFAULT_ENTRY = {
3032
+ mode: "add",
3033
+ linked: true,
3034
+ visible: true,
3035
+ opacity: 1
3036
+ };
3037
+ var MaskStackStore = class {
3038
+ constructor(canvas, layers, history, events) {
3039
+ this.canvas = canvas;
3040
+ this.layers = layers;
3041
+ this.history = history;
3042
+ this.events = events;
3043
+ }
3044
+ canvas;
3045
+ layers;
3046
+ history;
3047
+ events;
3048
+ selected = null;
3049
+ pinning = false;
3050
+ list(target) {
3051
+ return this.hostLayer(target)?.meta.maskStack ?? [];
3052
+ }
3053
+ get(target, maskId) {
3054
+ return this.list(target).find((entry) => entry.id === maskId);
3055
+ }
3056
+ /** Every target that currently carries at least one mask. */
3057
+ targets() {
3058
+ return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
3059
+ }
3060
+ /** The box a mask is fitted to, in the space the stack is composed in. */
3061
+ hostBox(target, absolute = target === CANVAS_MASK_TARGET || needsAbsoluteSpace(this.list(target))) {
3062
+ return hostBoxOf(this.canvas, this.host(target), absolute);
3063
+ }
3064
+ /** The host layer of a target, optionally creating the design overlay. */
3065
+ hostLayer(target, create = false) {
3066
+ if (target !== CANVAS_MASK_TARGET) return this.layers.get(target);
3067
+ const existing = findCanvasHost(this.layers);
3068
+ if (existing || !create) return existing;
3069
+ return createCanvasHost(this.canvas, this.layers);
3070
+ }
3071
+ host(target, create = false) {
3072
+ return this.hostLayer(target, create)?.fabricObject ?? null;
3073
+ }
3074
+ /** Re-pin the design overlay, guarding the reorder that re-triggers this. */
3075
+ pin() {
3076
+ if (this.pinning) return;
3077
+ this.pinning = true;
3078
+ try {
3079
+ pinCanvasHost(this.layers);
3080
+ } finally {
3081
+ this.pinning = false;
3082
+ }
3083
+ }
3084
+ /**
3085
+ * Take the current geometry back out of the composed clip, entry-aligned.
3086
+ *
3087
+ * The entry list is the authority on how to read the clip: with no entries the
3088
+ * clip predates the stack (a mask preset, or a single `clipPath` an older host
3089
+ * installed) and is one mask whole — including when it happens to be a group,
3090
+ * which is why this cannot just unwrap anything group-shaped.
3091
+ */
3092
+ unwrap(target, host) {
3093
+ const clip = host.clipPath;
3094
+ if (!clip) return [];
3095
+ const entries = this.list(target);
3096
+ if (entries.length === 0 || !(clip instanceof Group4)) return [asObject(clip)];
3097
+ const children = unwrapGroup(clip);
3098
+ const extra = children.length - entries.length;
3099
+ return extra > 0 ? children.slice(extra) : children;
3100
+ }
3101
+ /**
3102
+ * Entries for a stack, adopting a pre-stack clip as the first one. Without
3103
+ * this, the first `add()` on an already-masked layer would compose a clip it
3104
+ * has no entry for and silently throw that mask away.
3105
+ */
3106
+ entriesFor(target, sources) {
3107
+ const existing = this.list(target);
3108
+ if (existing.length > 0 || sources.length !== 1) return [...existing];
3109
+ const layer = this.hostLayer(target);
3110
+ const preset = layer?.meta.maskPreset;
3111
+ if (layer) {
3112
+ delete layer.meta.maskPreset;
3113
+ }
3114
+ return [
3115
+ {
3116
+ ...DEFAULT_ENTRY,
3117
+ id: generateId(),
3118
+ name: typeof preset === "string" ? preset : "Mask",
3119
+ linked: target === CANVAS_MASK_TARGET ? false : !sources[0].absolutePositioned
3120
+ }
3121
+ ];
3122
+ }
3123
+ /**
3124
+ * Install a stack: convert geometry into the space the stack needs, compose the
3125
+ * clip, store the entries, and commit one history checkpoint for the lot.
3126
+ */
3127
+ commit(target, host, entries, sources, save = true, forceAbsolute = false) {
3128
+ const layer = this.hostLayer(target);
3129
+ if (!layer) return;
3130
+ const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
3131
+ convertSpace(host, sources, absolute);
3132
+ const next = withRelativeTransforms(entries, sources, host, absolute);
3133
+ installClip(host, next, sources, this.hostBox(target, absolute), absolute);
3134
+ if (next.length === 0) {
3135
+ delete layer.meta.maskStack;
3136
+ if (layer.meta.canvasMask) this.layers.remove(layer.id);
3137
+ } else {
3138
+ layer.meta.maskStack = next;
3139
+ }
3140
+ this.canvas.requestRenderAll();
3141
+ this.events.emit("masks:changed", { target });
3142
+ this.events.emit("layer:modified", { layerId: layer.id });
3143
+ if (save) this.history.save();
3144
+ }
3145
+ };
3146
+
3147
+ // src/masks/manager.ts
3148
+ var LayerMaskManager = class extends MaskStackStore {
3149
+ // Committing mid-drag would recompose the group the handle writes into and
3150
+ // leave it pointing at a discarded object; the geometry is settled on endEdit.
3151
+ edits = new MaskEditController(this.canvas, () => this.history.save());
3152
+ onLayersChanged = () => this.pin();
3153
+ // Selecting a layer means the user has moved on from the mask they had open;
3154
+ // leaving both selected would show mask controls for an unrelated layer.
3155
+ onLayerSelected = () => {
3156
+ if (this.selected) this.select(null, null);
3157
+ };
3158
+ onObjectModified = (event) => {
3159
+ const object = event.target;
3160
+ if (!object) return;
3161
+ const layer = this.layers.findByObject(object);
3162
+ if (layer) this.reflow(layer.id);
3163
+ };
3164
+ constructor(canvas, layers, history, events) {
3165
+ super(canvas, layers, history, events);
3166
+ this.events.on("layers:changed", this.onLayersChanged);
3167
+ this.events.on("layer:selected", this.onLayerSelected);
3168
+ this.canvas.on("object:modified", this.onObjectModified);
3169
+ }
3170
+ dispose() {
3171
+ this.edits.dispose();
3172
+ this.events.off("layers:changed", this.onLayersChanged);
3173
+ this.events.off("layer:selected", this.onLayerSelected);
3174
+ this.canvas.off("object:modified", this.onObjectModified);
3175
+ this.selected = null;
3176
+ }
3177
+ // ─── Geometry editing ────────────────────────────────
3178
+ /** The mask currently being dragged on the canvas, if any. */
3179
+ editing() {
3180
+ return this.edits.active();
3181
+ }
3182
+ /**
3183
+ * Put drag handles on one mask. The stack is composed in canvas space for the
3184
+ * duration — a linked mask would otherwise sit in the host's space, where the
3185
+ * handle's own canvas coordinates mean something else entirely. `endEdit`
3186
+ * returns it to whichever space its entries call for.
3187
+ */
3188
+ async beginEdit(target, maskId) {
3189
+ const host = this.host(target);
3190
+ if (!host) return false;
3191
+ const entries = this.list(target);
3192
+ const index = entries.findIndex((entry) => entry.id === maskId);
3193
+ if (index === -1) return false;
3194
+ this.endEdit(false);
3195
+ this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3196
+ const group = host.clipPath instanceof Group5 ? host.clipPath : null;
3197
+ if (!group) return false;
3198
+ const children = group.getObjects();
3199
+ const child = children[children.length - entries.length + index];
3200
+ if (!child) return false;
3201
+ this.select(target, maskId);
3202
+ return this.edits.begin({ target, maskId }, group, child);
3203
+ }
3204
+ /** Take the handles down and settle the stack back into its own space. */
3205
+ endEdit(save = true) {
3206
+ const editing = this.edits.active();
3207
+ this.edits.end();
3208
+ if (editing) this.rebuild(editing.target, save);
3209
+ }
3210
+ // ─── Selection ───────────────────────────────────────
3211
+ getSelected() {
3212
+ return this.selected;
3213
+ }
3214
+ select(target, maskId) {
3215
+ this.selected = target && maskId ? { target, maskId } : null;
3216
+ this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
3217
+ }
3218
+ // ─── Mutations ───────────────────────────────────────
3219
+ add(target, object, options = {}) {
3220
+ const host = this.host(target, true);
3221
+ if (!host) return null;
3222
+ const sources = this.unwrap(target, host);
3223
+ const entries = this.entriesFor(target, sources);
3224
+ const entry = {
3225
+ ...DEFAULT_ENTRY,
3226
+ id: generateId(),
3227
+ name: options.name ?? "Mask",
3228
+ ...options.mode ? { mode: options.mode } : {},
3229
+ ...options.linked === void 0 ? {} : { linked: options.linked },
3230
+ ...options.meta ? { meta: options.meta } : {}
3231
+ };
3232
+ if (target === CANVAS_MASK_TARGET) entry.linked = false;
3233
+ if (options.fit !== false) {
3234
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3235
+ }
3236
+ sources.push(object);
3237
+ entries.push(entry);
3238
+ this.commit(target, host, entries, sources);
3239
+ return entry;
3240
+ }
3241
+ /** Swap one mask's geometry, keeping its identity, mode and position in the stack. */
3242
+ replaceGeometry(target, maskId, object, options = {}) {
3243
+ const host = this.host(target);
3244
+ if (!host) return false;
3245
+ const entries = [...this.list(target)];
3246
+ const index = entries.findIndex((entry) => entry.id === maskId);
3247
+ if (index === -1) return false;
3248
+ const sources = this.unwrap(target, host);
3249
+ if (options.fit !== false) {
3250
+ fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
3251
+ }
3252
+ sources[index] = object;
3253
+ entries[index] = {
3254
+ ...entries[index],
3255
+ ...options.name ? { name: options.name } : {},
3256
+ ...options.meta ? { meta: options.meta } : {}
3257
+ };
3258
+ this.commit(target, host, entries, sources);
3259
+ return true;
3260
+ }
3261
+ /**
3262
+ * Re-fit one mask to its host's box (or `box`) at `fit` scale, keeping the
3263
+ * geometry it already has. This is what a zoom control drives: re-picking the
3264
+ * mask would cost another render of a generated alpha map just to resize it.
3265
+ */
3266
+ refit(target, maskId, options = {}) {
3267
+ const host = this.host(target);
3268
+ if (!host) return false;
3269
+ const entries = [...this.list(target)];
3270
+ const index = entries.findIndex((entry) => entry.id === maskId);
3271
+ if (index === -1) return false;
3272
+ const sources = this.unwrap(target, host);
3273
+ const source = sources[index];
3274
+ if (!source) return false;
3275
+ fitToBox(source, options.box ?? this.hostBox(target), options.fit ?? 1);
3276
+ this.commit(target, host, entries, sources, options.save ?? true);
3277
+ return true;
3278
+ }
3279
+ remove(target, maskId) {
3280
+ const host = this.host(target);
3281
+ if (!host) return false;
3282
+ const entries = [...this.list(target)];
3283
+ const index = entries.findIndex((entry) => entry.id === maskId);
3284
+ if (index === -1) return false;
3285
+ const sources = this.unwrap(target, host);
3286
+ entries.splice(index, 1);
3287
+ sources.splice(index, 1);
3288
+ if (this.selected?.maskId === maskId) this.select(null, null);
3289
+ this.commit(target, host, entries, sources);
3290
+ return true;
3291
+ }
3292
+ clear(target) {
3293
+ const host = this.host(target);
3294
+ if (!host) return false;
3295
+ if (this.list(target).length === 0) return false;
3296
+ if (this.selected?.target === target) this.select(null, null);
3297
+ this.commit(target, host, [], []);
3298
+ return true;
3299
+ }
3300
+ reorder(target, maskId, index) {
3301
+ const host = this.host(target);
3302
+ if (!host) return false;
3303
+ const entries = [...this.list(target)];
3304
+ const from = entries.findIndex((entry2) => entry2.id === maskId);
3305
+ if (from === -1) return false;
3306
+ const to = Math.max(0, Math.min(entries.length - 1, Math.round(index)));
3307
+ if (from === to) return false;
3308
+ const sources = this.unwrap(target, host);
3309
+ const [entry] = entries.splice(from, 1);
3310
+ const [source] = sources.splice(from, 1);
3311
+ entries.splice(to, 0, entry);
3312
+ sources.splice(to, 0, source);
3313
+ this.commit(target, host, entries, sources);
3314
+ return true;
3315
+ }
3316
+ /** Replace a mask's host metadata (which preset or generator produced it). */
3317
+ setMeta(target, maskId, meta) {
3318
+ return this.patch(target, maskId, () => ({ meta }));
3319
+ }
3320
+ setMode(target, maskId, mode) {
3321
+ return this.patch(target, maskId, (entry) => entry.mode === mode ? null : { mode });
3322
+ }
3323
+ setVisible(target, maskId, visible) {
3324
+ return this.patch(target, maskId, (entry) => entry.visible === visible ? null : { visible });
3325
+ }
3326
+ setOpacity(target, maskId, opacity) {
3327
+ if (!Number.isFinite(opacity)) return false;
3328
+ const next = Math.max(0, Math.min(1, opacity));
3329
+ return this.patch(
3330
+ target,
3331
+ maskId,
3332
+ (entry) => entry.opacity === next ? null : { opacity: next }
3333
+ );
3334
+ }
3335
+ setName(target, maskId, name) {
3336
+ const trimmed = name.trim();
3337
+ if (!trimmed) return false;
3338
+ return this.patch(
3339
+ target,
3340
+ maskId,
3341
+ (entry) => entry.name === trimmed ? null : { name: trimmed }
3342
+ );
3343
+ }
3344
+ /**
3345
+ * Link or unlink one mask. Unlinking pins it where it currently appears;
3346
+ * re-linking records its position relative to the host so later host moves
3347
+ * carry it along.
3348
+ */
3349
+ setLinked(target, maskId, linked) {
3350
+ if (target === CANVAS_MASK_TARGET) return false;
3351
+ const host = this.host(target);
3352
+ if (!host) return false;
3353
+ const entries = [...this.list(target)];
3354
+ const index = entries.findIndex((entry) => entry.id === maskId);
3355
+ if (index === -1 || entries[index].linked === linked) return false;
3356
+ const sources = this.unwrap(target, host);
3357
+ entries[index] = { ...entries[index], linked };
3358
+ this.commit(target, host, entries, sources);
3359
+ return true;
3360
+ }
3361
+ /**
3362
+ * Consume a layer, turning its artwork into a mask. Without an explicit target
3363
+ * it masks the layer directly beneath it, and the bottom layer masks the whole
3364
+ * design — there is nothing under it to clip.
3365
+ */
3366
+ convertLayer(layerId, target) {
3367
+ const layer = this.layers.get(layerId);
3368
+ if (!layer || layer.meta.canvasMask) return null;
3369
+ const ordered = this.layers.getAll();
3370
+ const index = ordered.findIndex((candidate) => candidate.id === layerId);
3371
+ const below = index > 0 ? ordered[index - 1] : void 0;
3372
+ const resolved = target ?? (below && !below.meta.canvasMask ? below.id : CANVAS_MASK_TARGET);
3373
+ if (resolved === layerId) return null;
3374
+ const object = layer.fabricObject;
3375
+ if (!object.fill && object.type !== "image") object.set({ fill: "#000000" });
3376
+ this.history.beginTransaction();
3377
+ try {
3378
+ if (this.canvas.getActiveObject() === object) this.canvas.discardActiveObject();
3379
+ this.canvas.remove(object);
3380
+ this.layers.remove(layerId);
3381
+ const entry = this.add(resolved, object, { name: layer.name, fit: false });
3382
+ return entry ? { target: resolved, entry } : null;
3383
+ } finally {
3384
+ this.history.endTransaction();
3385
+ }
3386
+ }
3387
+ // ─── Rebuild / restore ───────────────────────────────
3388
+ /** Recompose one stack's clip from the geometry it already holds. */
3389
+ rebuild(target, save = false) {
3390
+ const host = this.host(target);
3391
+ if (!host) return;
3392
+ const sources = this.unwrap(target, host);
3393
+ if (sources.length === 0) return;
3394
+ this.commit(target, host, this.entriesFor(target, sources), sources, save);
3395
+ }
3396
+ /** Re-derive linked masks after the host moved (canvas-space stacks only). */
3397
+ reflow(target) {
3398
+ const host = this.host(target);
3399
+ if (!host) return;
3400
+ const entries = this.list(target);
3401
+ if (entries.length === 0 || !needsAbsoluteSpace(entries)) return;
3402
+ if (!entries.some((entry) => entry.linked && entry.rel)) return;
3403
+ const sources = this.unwrap(target, host);
3404
+ entries.forEach((entry, index) => {
3405
+ const source = sources[index];
3406
+ const rel = toMatrix(entry.rel);
3407
+ if (!source || !entry.linked || !rel) return;
3408
+ applyRelativeMatrix(source, host, rel);
3409
+ });
3410
+ this.commit(target, host, [...entries], sources);
3411
+ }
3412
+ /**
3413
+ * Adopt a clip this manager did not install — a host's own single `clipPath` —
3414
+ * as a one-entry stack, so it shows up in the UI as a mask row before anything
3415
+ * is added to it. A mask preset is left alone unless asked for by name: it is
3416
+ * still owned by `MaskPresetManager` until a stack operation takes it over.
3417
+ */
3418
+ adopt(target, name) {
3419
+ const layer = this.hostLayer(target);
3420
+ const clip = layer?.fabricObject.clipPath;
3421
+ if (!layer || !clip || layer.meta.pattern) return null;
3422
+ if ((layer.meta.maskStack ?? []).length > 0) return null;
3423
+ const entry = {
3424
+ ...DEFAULT_ENTRY,
3425
+ id: generateId(),
3426
+ name: name ?? "Mask",
3427
+ linked: target === CANVAS_MASK_TARGET ? false : !clip.absolutePositioned
3428
+ };
3429
+ layer.meta.maskStack = [entry];
3430
+ delete layer.meta.maskPreset;
3431
+ this.rebuild(target);
3432
+ return entry;
3433
+ }
3434
+ /** After a state restore: re-pin the design overlay and recompose every stack. */
3435
+ refreshAll() {
3436
+ this.selected = null;
3437
+ this.pin();
3438
+ for (const layer of this.layers.getAll()) {
3439
+ if ((layer.meta.maskStack ?? []).length === 0) continue;
3440
+ this.rebuild(layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
3441
+ }
3442
+ }
3443
+ // ─── Internals ───────────────────────────────────────
3444
+ patch(target, maskId, change) {
3445
+ const host = this.host(target);
3446
+ if (!host) return false;
3447
+ const entries = [...this.list(target)];
3448
+ const index = entries.findIndex((entry) => entry.id === maskId);
3449
+ if (index === -1) return false;
3450
+ const patch = change(entries[index]);
3451
+ if (!patch) return false;
3452
+ const sources = this.unwrap(target, host);
3453
+ entries[index] = { ...entries[index], ...patch };
3454
+ this.commit(target, host, entries, sources);
3455
+ return true;
3456
+ }
3457
+ };
3458
+
2532
3459
  // src/editor.ts
2533
3460
  var MIN_ZOOM = 0.1;
2534
3461
  var MAX_ZOOM = 8;
@@ -2546,6 +3473,8 @@ var CanvasEditor = class {
2546
3473
  patterns;
2547
3474
  curves;
2548
3475
  maskPresets;
3476
+ /** Stacked boolean masks, per layer and for the design as a whole. */
3477
+ layerMasks;
2549
3478
  fonts;
2550
3479
  licensing;
2551
3480
  pages;
@@ -2594,6 +3523,7 @@ var CanvasEditor = class {
2594
3523
  this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2595
3524
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2596
3525
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
3526
+ this.layerMasks = new LayerMaskManager(this.canvas, this.layers, this.history, this.events);
2597
3527
  this.setupCanvasEvents();
2598
3528
  this.refreshSelectionStyle();
2599
3529
  this.history.saveImmediate();
@@ -2609,6 +3539,7 @@ var CanvasEditor = class {
2609
3539
  { originX: "left", originY: "top", ...options }
2610
3540
  );
2611
3541
  const layer = this.layers.add("image", img);
3542
+ this.layers.select(layer.id);
2612
3543
  this.history.save();
2613
3544
  return layer;
2614
3545
  } catch (error) {
@@ -2668,12 +3599,14 @@ var CanvasEditor = class {
2668
3599
  ...options
2669
3600
  });
2670
3601
  const layer = this.layers.add("text", textbox);
3602
+ this.layers.select(layer.id);
2671
3603
  this.history.save();
2672
3604
  return layer;
2673
3605
  }
2674
3606
  addShape(plugin, options) {
2675
3607
  const obj = plugin.create(options);
2676
3608
  const layer = this.layers.add("shape", obj, plugin.name);
3609
+ this.layers.select(layer.id);
2677
3610
  this.history.save();
2678
3611
  return layer;
2679
3612
  }
@@ -2699,7 +3632,7 @@ var CanvasEditor = class {
2699
3632
  const { objects, options } = await loadSVGFromString(resolved);
2700
3633
  const validObjects = objects.filter((object) => object !== null);
2701
3634
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
2702
- const group = util3.groupSVGElements(validObjects, options);
3635
+ const group = util5.groupSVGElements(validObjects, options);
2703
3636
  group.set({
2704
3637
  left: this.canvas.getWidth() / 2,
2705
3638
  top: this.canvas.getHeight() / 2,
@@ -2707,6 +3640,7 @@ var CanvasEditor = class {
2707
3640
  originY: "center"
2708
3641
  });
2709
3642
  const layer = this.layers.add("template", group, template.name);
3643
+ this.layers.select(layer.id);
2710
3644
  this.history.save();
2711
3645
  return layer;
2712
3646
  }
@@ -2758,7 +3692,7 @@ var CanvasEditor = class {
2758
3692
  opacity: layer.opacity
2759
3693
  });
2760
3694
  this.patterns.attachTo(copy);
2761
- this.canvas.setActiveObject(clone);
3695
+ this.layers.select(copy.id);
2762
3696
  this.canvas.requestRenderAll();
2763
3697
  this.history.save();
2764
3698
  return copy;
@@ -2791,7 +3725,7 @@ var CanvasEditor = class {
2791
3725
  const childData = children.map((layer) => structuredClone(layer.toData()));
2792
3726
  const objects = children.map((layer) => layer.fabricObject);
2793
3727
  for (const layer of children) this.layers.remove(layer.id);
2794
- const group = new Group(objects);
3728
+ const group = new Group6(objects);
2795
3729
  const grouped = this.layers.add("group", group, name);
2796
3730
  grouped.meta.groupChildren = childData;
2797
3731
  this.layers.select(grouped.id);
@@ -2806,11 +3740,9 @@ var CanvasEditor = class {
2806
3740
  if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
2807
3741
  const group = grouped.fabricObject;
2808
3742
  return this.history.transaction(() => {
2809
- const transform = group.calcTransformMatrix();
2810
3743
  const objects = group.removeAll();
2811
3744
  this.layers.remove(id);
2812
3745
  const restored = objects.map((object, index) => {
2813
- util3.addTransformToObject(object, transform);
2814
3746
  object.setCoords();
2815
3747
  const data = childData[index];
2816
3748
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -2838,6 +3770,7 @@ var CanvasEditor = class {
2838
3770
  try {
2839
3771
  await deserializeEditor(this, state);
2840
3772
  await this.patterns.rehydrateAll();
3773
+ this.layerMasks.refreshAll();
2841
3774
  } catch (error) {
2842
3775
  if (!managedByHistory) {
2843
3776
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -3410,6 +4343,7 @@ var CanvasEditor = class {
3410
4343
  // ─── Cleanup ────────────────────────────────────────
3411
4344
  dispose() {
3412
4345
  this.masks.dispose();
4346
+ this.layerMasks.dispose();
3413
4347
  this.snapping.dispose();
3414
4348
  this.crop.dispose();
3415
4349
  this.history.dispose();
@@ -3532,6 +4466,7 @@ var AnnotationOverlay = class {
3532
4466
  };
3533
4467
  export {
3534
4468
  AnnotationOverlay,
4469
+ CANVAS_MASK_TARGET,
3535
4470
  CANVAS_SIZE_PRESETS,
3536
4471
  CanvasEditor,
3537
4472
  CropController,
@@ -3544,6 +4479,7 @@ export {
3544
4479
  HistoryManager,
3545
4480
  Layer,
3546
4481
  LayerManager,
4482
+ LayerMaskManager,
3547
4483
  LicenseManager,
3548
4484
  MaskController,
3549
4485
  MaskPresetManager,
@@ -3565,6 +4501,7 @@ export {
3565
4501
  buildCurvePathData,
3566
4502
  clamp,
3567
4503
  clearTextureMaskCache,
4504
+ composeMaskGroup,
3568
4505
  computeCoverPlacement,
3569
4506
  computePrintAreaClip,
3570
4507
  computeTilePositions,
@@ -3577,11 +4514,13 @@ export {
3577
4514
  exportPNG,
3578
4515
  exportPrintArea,
3579
4516
  exportSVG,
4517
+ fitToBox,
3580
4518
  generateId,
3581
4519
  isCssColor,
3582
4520
  isMaskPresetId,
3583
4521
  isShapeMaskId,
3584
4522
  isTextureMaskId,
4523
+ needsAbsoluteSpace,
3585
4524
  readLayerShadow,
3586
4525
  renderTextureMask,
3587
4526
  resetTransform,
@@ -3589,6 +4528,9 @@ export {
3589
4528
  round2,
3590
4529
  sanitizeSvg,
3591
4530
  serializeEditor,
3592
- shapeMaskPathData
4531
+ shapeMaskPathData,
4532
+ toCanvasSpace,
4533
+ toHostSpace,
4534
+ unwrapGroup
3593
4535
  };
3594
4536
  //# sourceMappingURL=index.mjs.map