@overtone-art/canvas-editor-core 0.3.2 → 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 FabricImage4, 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 {
@@ -63,6 +63,12 @@ var Layer = class {
63
63
  /** Non-fabric data (e.g. pattern config) that must persist with the layer. */
64
64
  meta;
65
65
  fabricObject;
66
+ /**
67
+ * Stand-in object drawn in place of `fabricObject` (the tiled pattern). It is
68
+ * part of the canvas but never part of the document: it is not serialized, and
69
+ * the layer's real object stays the one every editor API talks to.
70
+ */
71
+ renderProxy;
66
72
  constructor(type, fabricObject, name, id) {
67
73
  this.id = id ?? generateId();
68
74
  this.type = type;
@@ -72,6 +78,7 @@ var Layer = class {
72
78
  this.opacity = 1;
73
79
  this.meta = {};
74
80
  this.fabricObject = fabricObject;
81
+ this.renderProxy = null;
75
82
  this.fabricObject._layerId = this.id;
76
83
  }
77
84
  hasMeta() {
@@ -120,6 +127,7 @@ var LayerManager = class {
120
127
  if (index === -1) return false;
121
128
  const layer = this.layers[index];
122
129
  this.canvas.remove(layer.fabricObject);
130
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
123
131
  this.layers.splice(index, 1);
124
132
  this.events.emit("layer:removed", { layerId: id });
125
133
  this.emitChanged();
@@ -143,6 +151,10 @@ var LayerManager = class {
143
151
  evented: !layer.locked
144
152
  });
145
153
  this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
154
+ if (layer.renderProxy) {
155
+ fabricObject.set({ opacity: 0 });
156
+ this.syncZOrder();
157
+ }
146
158
  if (wasActive) this.canvas.setActiveObject(fabricObject);
147
159
  this.canvas.requestRenderAll();
148
160
  this.events.emit("layer:modified", { layerId: id });
@@ -150,6 +162,42 @@ var LayerManager = class {
150
162
  this.onPropertyChanged?.();
151
163
  return true;
152
164
  }
165
+ /**
166
+ * Attach (or clear) the object drawn in place of a layer's own object. The
167
+ * proxy tracks the layer's stacking position, visibility and opacity, and is
168
+ * removed with the layer — it must never outlive or drift from its source.
169
+ */
170
+ setRenderProxy(id, proxy) {
171
+ const layer = this.get(id);
172
+ if (!layer || layer.renderProxy === proxy) return;
173
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
174
+ layer.renderProxy = proxy;
175
+ if (proxy) {
176
+ proxy._layerId = id;
177
+ proxy.set({
178
+ visible: layer.visible,
179
+ opacity: layer.opacity,
180
+ selectable: !layer.locked,
181
+ evented: !layer.locked
182
+ });
183
+ this.canvas.add(proxy);
184
+ this.syncZOrder();
185
+ }
186
+ this.canvas.requestRenderAll();
187
+ this.emitChanged();
188
+ }
189
+ /**
190
+ * Re-stack every canvas object to match layer order, keeping each proxy
191
+ * directly above the source it stands in for. Canvas indices can't be derived
192
+ * from layer indices once proxies are in the array, so the order is rebuilt
193
+ * front-to-back instead of computed.
194
+ */
195
+ syncZOrder() {
196
+ for (const layer of this.layers) {
197
+ this.canvas.bringObjectToFront(layer.fabricObject);
198
+ if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
199
+ }
200
+ }
153
201
  reorder(id, newIndex) {
154
202
  const oldIndex = this.layers.findIndex((l) => l.id === id);
155
203
  if (oldIndex === -1) return false;
@@ -158,9 +206,7 @@ var LayerManager = class {
158
206
  if (oldIndex === clamped) return false;
159
207
  const [layer] = this.layers.splice(oldIndex, 1);
160
208
  this.layers.splice(clamped, 0, layer);
161
- this.layers.forEach((l, i) => {
162
- this.canvas.moveObjectTo(l.fabricObject, i);
163
- });
209
+ this.syncZOrder();
164
210
  this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
165
211
  this.emitChanged();
166
212
  this.onPropertyChanged?.();
@@ -172,7 +218,7 @@ var LayerManager = class {
172
218
  } else {
173
219
  const layer = this.get(id);
174
220
  if (layer) {
175
- this.canvas.setActiveObject(layer.fabricObject);
221
+ this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
176
222
  }
177
223
  }
178
224
  this.canvas.requestRenderAll();
@@ -197,6 +243,7 @@ var LayerManager = class {
197
243
  if (layer.visible === visible) return;
198
244
  layer.visible = visible;
199
245
  layer.fabricObject.visible = visible;
246
+ if (layer.renderProxy) layer.renderProxy.visible = visible;
200
247
  this.canvas.requestRenderAll();
201
248
  this.emitChanged();
202
249
  this.onPropertyChanged?.();
@@ -206,8 +253,9 @@ var LayerManager = class {
206
253
  if (!layer) return;
207
254
  if (layer.locked === locked) return;
208
255
  layer.locked = locked;
209
- layer.fabricObject.selectable = !locked;
210
- layer.fabricObject.evented = !locked;
256
+ const target = layer.renderProxy ?? layer.fabricObject;
257
+ target.selectable = !locked;
258
+ target.evented = !locked;
211
259
  this.canvas.requestRenderAll();
212
260
  this.emitChanged();
213
261
  this.onPropertyChanged?.();
@@ -218,7 +266,8 @@ var LayerManager = class {
218
266
  const next = Math.max(0, Math.min(1, opacity));
219
267
  if (layer.opacity === next) return;
220
268
  layer.opacity = next;
221
- layer.fabricObject.opacity = next;
269
+ if (layer.renderProxy) layer.renderProxy.opacity = next;
270
+ else layer.fabricObject.opacity = next;
222
271
  this.canvas.requestRenderAll();
223
272
  this.emitChanged();
224
273
  this.onPropertyChanged?.();
@@ -266,6 +315,7 @@ var LayerManager = class {
266
315
  clear() {
267
316
  for (const layer of this.layers) {
268
317
  this.canvas.remove(layer.fabricObject);
318
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
269
319
  }
270
320
  this.layers = [];
271
321
  this.emitChanged();
@@ -799,199 +849,536 @@ var CropController = class {
799
849
  };
800
850
  var STROKE2 = "#22c55e";
801
851
 
802
- // src/pattern.ts
852
+ // src/pattern/pattern-manager.ts
803
853
  import { util } from "fabric";
854
+
855
+ // src/pattern/tiled-pattern-object.ts
856
+ import { FabricObject, Point } from "fabric";
857
+
858
+ // src/pattern/tile-geometry.ts
804
859
  var MAX_TILES_PER_AXIS = 200;
860
+ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
861
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
862
+ const radius = cornerRadius(anchor, targetW, targetH);
863
+ const span = radius * 2;
864
+ const minTile = Math.max(1, span / MAX_TILES_PER_AXIS);
865
+ const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
866
+ const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
867
+ const cols = Math.ceil(span / tileW) + 2;
868
+ const rows = Math.ceil(span / tileH) + 2;
869
+ const halfCols = Math.ceil(cols / 2);
870
+ const halfRows = Math.ceil(rows / 2);
871
+ const shiftX = tileW * (clampOffset(config.offsetX) / 100);
872
+ const shiftY = tileH * (clampOffset(config.offsetY) / 100);
873
+ const placements = [];
874
+ for (let j = -halfRows; j <= halfRows; j++) {
875
+ for (let i = -halfCols; i <= halfCols; i++) {
876
+ let x = i * tileW + shiftX;
877
+ let y = j * tileH + shiftY;
878
+ if (config.mode === "brick-horizontal" && mod2(j) === 1) {
879
+ x += tileW * (config.horizontalOffset / 100);
880
+ } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
881
+ y += tileH * (config.horizontalOffset / 100);
882
+ }
883
+ const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
884
+ placements.push({ x, y, rotation });
885
+ }
886
+ }
887
+ return placements;
888
+ }
889
+ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
890
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
891
+ ctx.save();
892
+ ctx.translate(anchor.x, anchor.y);
893
+ ctx.rotate(config.angle * Math.PI / 180);
894
+ for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH, anchor)) {
895
+ ctx.save();
896
+ ctx.translate(tile.x, tile.y);
897
+ ctx.rotate(tile.rotation);
898
+ ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
899
+ ctx.restore();
900
+ }
901
+ ctx.restore();
902
+ }
903
+ function cornerRadius(origin, w, h) {
904
+ const dx = Math.max(Math.abs(origin.x), Math.abs(w - origin.x));
905
+ const dy = Math.max(Math.abs(origin.y), Math.abs(h - origin.y));
906
+ return Math.sqrt(dx * dx + dy * dy);
907
+ }
908
+ function clampOffset(value) {
909
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
910
+ return Math.max(-100, Math.min(100, value));
911
+ }
912
+ function mod2(n) {
913
+ return (n % 2 + 2) % 2;
914
+ }
915
+
916
+ // src/pattern/tiled-pattern-object.ts
917
+ var MAX_SNAPSHOT_PIXELS = 16e6;
918
+ var MAX_SNAPSHOT_SCALE = 8;
919
+ var SNAPSHOT_SHRINK_FACTOR = 2;
920
+ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
921
+ static type = "TiledPattern";
922
+ /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
923
+ source;
924
+ config;
925
+ /** The print area this pattern fills, in canvas units. */
926
+ area;
927
+ snapshotEl = null;
928
+ snapshotScale = 0;
929
+ constructor(source, config, area) {
930
+ super({
931
+ // Centre origin: the box tracks a tile, and a tile is placed by its centre.
932
+ originX: "center",
933
+ originY: "center",
934
+ objectCaching: false,
935
+ // Side handles would imply a non-uniform tile scale; the config has one.
936
+ lockScalingFlip: true
937
+ });
938
+ this.source = source;
939
+ this.config = config;
940
+ this.area = area;
941
+ this.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false });
942
+ this.syncBox();
943
+ }
944
+ /** Swap in a new config and re-fit the box to the tile it now describes. */
945
+ setConfig(config) {
946
+ this.config = config;
947
+ this.syncBox();
948
+ }
949
+ /** Re-fit to a new print area (canvas resize). */
950
+ setArea(area) {
951
+ this.area = area;
952
+ this.invalidate();
953
+ this.syncBox();
954
+ }
955
+ /** Drop the cached source snapshot (the source was edited). */
956
+ invalidate() {
957
+ this.snapshotEl = null;
958
+ this.snapshotScale = 0;
959
+ this.dirty = true;
960
+ }
961
+ /** Free the offscreen snapshot. */
962
+ dispose() {
963
+ this.snapshotEl = null;
964
+ this.snapshotScale = 0;
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
+ }
1005
+ _render(ctx) {
1006
+ const { width, height } = this.area;
1007
+ if (width <= 0 || height <= 0) return;
1008
+ const { tileW, tileH } = this.liveTile();
1009
+ const snapshot = this.ensureSnapshot(contextScale(ctx) * (tileW / Math.max(1, this.baseW())));
1010
+ if (!snapshot) return;
1011
+ const centre = this.getCenterPoint();
1012
+ const angle = this.liveAngle();
1013
+ ctx.save();
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);
1021
+ ctx.restore();
1022
+ }
1023
+ /** Raster fallback for SVG export — one `<image>` covering the print area. */
1024
+ _toSVG() {
1025
+ const { width, height } = this.area;
1026
+ if (width <= 0 || height <= 0) return [];
1027
+ const el = document.createElement("canvas");
1028
+ el.width = Math.max(1, Math.round(width));
1029
+ el.height = Math.max(1, Math.round(height));
1030
+ const ctx = el.getContext("2d");
1031
+ if (!ctx) return [];
1032
+ const { tileW, tileH } = this.liveTile();
1033
+ const snapshot = this.ensureSnapshot(tileW / Math.max(1, this.baseW()));
1034
+ if (!snapshot) return [];
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
+ );
1048
+ return [
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>
1052
+ `
1053
+ ];
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
+ }
1065
+ /**
1066
+ * Fabric's generic clone round-trips through `toObject()` + the class
1067
+ * registry, which cannot carry a live source reference. Export paths clone
1068
+ * every canvas object, so without this a print export would silently lose the
1069
+ * tiling. The copy shares the source (it only ever reads from it).
1070
+ */
1071
+ clone() {
1072
+ const copy = new _TiledPatternObject(this.source, this.config, this.area);
1073
+ copy.set({
1074
+ left: this.left,
1075
+ top: this.top,
1076
+ angle: this.angle,
1077
+ scaleX: this.scaleX,
1078
+ scaleY: this.scaleY,
1079
+ width: this.width,
1080
+ height: this.height,
1081
+ visible: this.visible,
1082
+ opacity: this.opacity
1083
+ });
1084
+ return Promise.resolve(copy);
1085
+ }
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
+ };
1099
+ }
1100
+ /** Tile size as drawn right now, including a live scale gesture. */
1101
+ liveTile() {
1102
+ const base = this.baseTile();
1103
+ const floor = this.tileFloor();
1104
+ return {
1105
+ tileW: Math.max(floor, base.tileW * Math.abs(this.scaleX ?? 1)),
1106
+ tileH: Math.max(floor, base.tileH * Math.abs(this.scaleY ?? 1))
1107
+ };
1108
+ }
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);
1126
+ }
1127
+ /**
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.
1130
+ */
1131
+ ensureSnapshot(scale) {
1132
+ const wanted = this.clampScale(scale);
1133
+ const stale = this.source.dirty || !this.snapshotEl || this.snapshotScale < wanted || this.snapshotScale > wanted * SNAPSHOT_SHRINK_FACTOR;
1134
+ if (!stale) return this.snapshotEl;
1135
+ const source = this.source;
1136
+ const opacity = source.opacity;
1137
+ source.opacity = 1;
1138
+ try {
1139
+ const el = source.toCanvasElement({ multiplier: wanted, enableRetinaScaling: false });
1140
+ if (!el.width || !el.height) return null;
1141
+ this.snapshotEl = el;
1142
+ this.snapshotScale = wanted;
1143
+ } catch {
1144
+ return this.snapshotEl;
1145
+ } finally {
1146
+ source.opacity = opacity;
1147
+ source.dirty = false;
1148
+ }
1149
+ return this.snapshotEl;
1150
+ }
1151
+ /** Bound the snapshot by both a linear scale and a total pixel budget. */
1152
+ clampScale(scale) {
1153
+ const requested = Math.min(MAX_SNAPSHOT_SCALE, Math.max(0.05, scale));
1154
+ const rect = this.source.getBoundingRect();
1155
+ const area = Math.max(1, rect.width * rect.height);
1156
+ const budgeted = Math.sqrt(MAX_SNAPSHOT_PIXELS / area);
1157
+ return Math.max(0.05, Math.min(requested, budgeted));
1158
+ }
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
+ }
1164
+ function contextScale(ctx) {
1165
+ if (typeof ctx.getTransform !== "function") return 1;
1166
+ try {
1167
+ const t = ctx.getTransform();
1168
+ return Math.max(Math.hypot(t.a, t.b), Math.hypot(t.c, t.d), 0.05);
1169
+ } catch {
1170
+ return 1;
1171
+ }
1172
+ }
1173
+
1174
+ // src/pattern/pattern-manager.ts
1175
+ var MIN_TILE_SCALE = 10;
1176
+ var MAX_TILE_SCALE = 300;
805
1177
  var PatternManager = class {
806
- constructor(canvas, layers, history, events, sourceResolver) {
1178
+ constructor(canvas, layers, history, events) {
807
1179
  this.canvas = canvas;
808
1180
  this.layers = layers;
809
1181
  this.history = history;
810
1182
  this.events = events;
811
- this.sourceResolver = sourceResolver;
1183
+ this.canvas.on("object:modified", this.onObjectModified);
1184
+ this.canvas.on("text:changed", this.onSourceModified);
1185
+ this.events.on("layer:removed", this.onLayerRemoved);
812
1186
  }
813
1187
  canvas;
814
1188
  layers;
815
1189
  history;
816
1190
  events;
817
- sourceResolver;
818
- // Per-layer task chain. apply()/disable() both await an async setSrc on the
819
- // same fabric image; running two concurrently lets their setSrc resolutions
820
- // interleave (wrong image installed, original lost). Serialising per layer
821
- // guarantees the last-requested operation wins and state stays consistent.
822
- chains = /* @__PURE__ */ new Map();
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
+ };
1197
+ onSourceModified = (event) => {
1198
+ this.invalidateFor(event.target);
1199
+ };
1200
+ onLayerRemoved = ({ layerId }) => {
1201
+ const proxy = this.proxies.get(layerId);
1202
+ if (!proxy) return;
1203
+ proxy.dispose();
1204
+ this.proxies.delete(layerId);
1205
+ };
823
1206
  isPattern(layerId) {
824
1207
  return !!this.layers.get(layerId)?.meta.pattern;
825
1208
  }
826
1209
  getConfig(layerId) {
827
1210
  return this.layers.get(layerId)?.meta.pattern?.config ?? null;
828
1211
  }
829
- /** Turn a plain image layer into a pattern, or update an existing one. */
830
- apply(layerId, config) {
831
- return this.enqueue(layerId, async () => {
832
- const layer = this.layers.get(layerId);
833
- if (!layer || layer.type !== "image") return;
834
- const image = layer.fabricObject;
835
- const firstEnable = !layer.meta.pattern;
836
- if (!layer.meta.pattern) {
837
- const clip = image.clipPath;
838
- layer.meta.pattern = {
839
- config,
840
- originalSrc: elementToDataURL(image) ?? image.getSrc(),
841
- originalClip: clip ? clip.toObject() : null,
842
- originalLocks: captureLocks(image),
843
- original: {
844
- left: image.left ?? 0,
845
- top: image.top ?? 0,
846
- scaleX: image.scaleX ?? 1,
847
- scaleY: image.scaleY ?? 1,
848
- width: image.width ?? 0,
849
- height: image.height ?? 0,
850
- angle: image.angle ?? 0,
851
- cropX: image.cropX ?? 0,
852
- cropY: image.cropY ?? 0
853
- }
854
- };
1212
+ /** Turn a layer into a repeating pattern, or update an existing one. */
1213
+ async apply(layerId, config) {
1214
+ const layer = this.layers.get(layerId);
1215
+ if (!layer) return;
1216
+ try {
1217
+ this.layers.setMeta(layerId, { pattern: { config } });
1218
+ const proxy = this.proxies.get(layerId);
1219
+ if (proxy) {
1220
+ proxy.setConfig(config);
1221
+ this.canvas.requestRenderAll();
855
1222
  } else {
856
- layer.meta.pattern.config = config;
857
- }
858
- try {
859
- await this.renderLayer(layer);
860
- } catch (err) {
861
- if (firstEnable) delete layer.meta.pattern;
862
- throw err;
1223
+ this.attach(layer, config);
863
1224
  }
864
1225
  this.history.save();
865
- }).catch((error) => {
866
- this.events.emit("error", { message: "Failed to apply image pattern", error });
1226
+ } catch (error) {
1227
+ this.events.emit("error", { message: "Failed to apply pattern", error });
867
1228
  throw error;
868
- });
869
- }
870
- /**
871
- * Stretch every restored pattern layer back over the full print area and
872
- * re-freeze it, without re-rasterising: the baked bitmap keeps whatever size
873
- * it was saved at, so it is scaled (not re-tiled) to the current canvas. Used
874
- * after a state restore, where the canvas may be a different display size
875
- * than when the pattern was baked — and to repair states saved while a
876
- * pattern could still be dragged out of the print area.
877
- */
878
- repinAll() {
879
- const cw = this.canvas.getWidth();
880
- const ch = this.canvas.getHeight();
881
- let changed = false;
882
- for (const layer of this.layers.getAll()) {
883
- if (!layer.meta.pattern || layer.type !== "image") continue;
884
- const image = layer.fabricObject;
885
- image.set({
886
- left: 0,
887
- top: 0,
888
- angle: 0,
889
- scaleX: cw / (image.width || cw),
890
- scaleY: ch / (image.height || ch)
891
- });
892
- applyPatternLocks(image);
893
- image.setCoords();
894
- changed = true;
895
1229
  }
896
- if (changed) this.canvas.requestRenderAll();
897
1230
  }
898
- /** Restore the original image and drop the pattern. */
899
- disable(layerId) {
900
- return this.enqueue(layerId, async () => {
901
- const layer = this.layers.get(layerId);
902
- const state = layer?.meta.pattern;
903
- if (!layer || !state) return;
904
- const image = layer.fabricObject;
905
- await image.setSrc(state.originalSrc);
906
- image.set({
907
- left: state.original.left,
908
- top: state.original.top,
909
- scaleX: state.original.scaleX,
910
- scaleY: state.original.scaleY,
911
- width: state.original.width,
912
- height: state.original.height,
913
- cropX: state.original.cropX,
914
- cropY: state.original.cropY,
915
- angle: state.original.angle
1231
+ /** Drop the tiling and show the source again. */
1232
+ async disable(layerId) {
1233
+ const layer = this.layers.get(layerId);
1234
+ if (!layer?.meta.pattern) return;
1235
+ try {
1236
+ this.detach(layerId);
1237
+ restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1238
+ layer.fabricObject.set({
1239
+ opacity: layer.opacity,
1240
+ selectable: !layer.locked,
1241
+ evented: !layer.locked
916
1242
  });
917
- image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
918
- restoreLocks(image, state.originalLocks);
919
- image.setCoords();
920
- delete layer.meta.pattern;
1243
+ this.layers.setMeta(layerId, { pattern: void 0 });
921
1244
  this.canvas.requestRenderAll();
922
1245
  this.history.save();
923
- }).catch((error) => {
924
- this.events.emit("error", { message: "Failed to clear image pattern", error });
1246
+ } catch (error) {
1247
+ this.events.emit("error", { message: "Failed to clear pattern", error });
925
1248
  throw error;
926
- });
1249
+ }
927
1250
  }
928
- /** Run `task` after any in-flight work for this layer, regardless of outcome. */
929
- enqueue(layerId, task) {
930
- const prev = this.chains.get(layerId) ?? Promise.resolve();
931
- const next = prev.then(task, task);
932
- this.chains.set(
933
- layerId,
934
- next.catch(() => void 0)
935
- );
936
- return next;
1251
+ /**
1252
+ * Rebuild every proxy after a state restore, migrating any layer that was
1253
+ * saved by the old bake-into-the-layer engine.
1254
+ */
1255
+ async rehydrateAll() {
1256
+ this.clearProxies();
1257
+ for (const layer of this.layers.getAll()) {
1258
+ const state = layer.meta.pattern;
1259
+ if (!state) continue;
1260
+ try {
1261
+ if (isLegacyState(state)) {
1262
+ await unbakeLegacyLayer(layer, state);
1263
+ this.layers.setMeta(layer.id, { pattern: { config: state.config } });
1264
+ }
1265
+ this.attach(layer, state.config);
1266
+ } catch (error) {
1267
+ this.events.emit("error", { message: "Failed to restore pattern layer", error });
1268
+ }
1269
+ }
1270
+ this.canvas.requestRenderAll();
937
1271
  }
938
- async renderLayer(layer) {
1272
+ /** Re-fit every proxy to the print area (canvas resize). */
1273
+ syncArea() {
1274
+ const area = this.area();
1275
+ for (const proxy of this.proxies.values()) proxy.setArea(area);
1276
+ if (this.proxies.size > 0) this.canvas.requestRenderAll();
1277
+ }
1278
+ /** Drop a layer's cached source snapshot (its content changed). */
1279
+ invalidate(layerId) {
1280
+ const proxy = this.proxies.get(layerId);
1281
+ if (!proxy) return;
1282
+ proxy.invalidate();
1283
+ proxy.syncBox();
1284
+ this.canvas.requestRenderAll();
1285
+ }
1286
+ /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
1287
+ attachTo(layer) {
939
1288
  const state = layer.meta.pattern;
940
- if (!state) return;
941
- const image = layer.fabricObject;
942
- const cw = this.canvas.getWidth();
943
- const ch = this.canvas.getHeight();
944
- const scale = Math.max(1, state.config.scale ?? 100) / 100;
945
- const diag = Math.sqrt(cw * cw + ch * ch);
946
- const minTile = Math.max(2, diag / MAX_TILES_PER_AXIS);
947
- const tileW = Math.max(minTile, state.original.width * state.original.scaleX * scale);
948
- const tileH = Math.max(minTile, state.original.height * state.original.scaleY * scale);
949
- const dataUrl = await buildPatternDataURL(
950
- state.originalSrc,
951
- state.config,
952
- cw,
953
- ch,
954
- tileW,
955
- tileH,
956
- this.sourceResolver
957
- );
958
- await image.setSrc(dataUrl);
959
- image.set({
960
- left: 0,
961
- top: 0,
962
- scaleX: 1,
963
- scaleY: 1,
964
- width: cw,
965
- height: ch,
966
- cropX: 0,
967
- cropY: 0,
968
- angle: 0
969
- });
970
- image.clipPath = void 0;
971
- applyPatternLocks(image);
972
- image.setCoords();
1289
+ if (!state || this.proxies.has(layer.id)) return;
1290
+ this.attach(layer, state.config);
1291
+ }
1292
+ dispose() {
1293
+ this.canvas.off("object:modified", this.onObjectModified);
1294
+ this.canvas.off("text:changed", this.onSourceModified);
1295
+ this.events.off("layer:removed", this.onLayerRemoved);
1296
+ this.clearProxies();
1297
+ }
1298
+ /** Release every proxy and the offscreen snapshot it holds. */
1299
+ clearProxies() {
1300
+ for (const proxy of this.proxies.values()) proxy.dispose();
1301
+ this.proxies.clear();
1302
+ }
1303
+ attach(layer, config) {
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 });
1307
+ this.proxies.set(layer.id, proxy);
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);
973
1330
  this.canvas.requestRenderAll();
1331
+ this.history.save();
1332
+ }
1333
+ area() {
1334
+ return { width: this.canvas.getWidth(), height: this.canvas.getHeight() };
1335
+ }
1336
+ detach(layerId) {
1337
+ const proxy = this.proxies.get(layerId);
1338
+ if (!proxy) {
1339
+ this.layers.setRenderProxy(layerId, null);
1340
+ return;
1341
+ }
1342
+ this.layers.setRenderProxy(layerId, null);
1343
+ proxy.dispose();
1344
+ this.proxies.delete(layerId);
1345
+ }
1346
+ invalidateFor(target) {
1347
+ if (!target) return;
1348
+ const layer = this.layers.findByObject(target);
1349
+ if (layer) this.invalidate(layer.id);
974
1350
  }
975
1351
  };
976
- function captureLocks(obj) {
977
- return {
978
- lockMovementX: obj.lockMovementX ?? false,
979
- lockMovementY: obj.lockMovementY ?? false,
980
- lockScalingX: obj.lockScalingX ?? false,
981
- lockScalingY: obj.lockScalingY ?? false,
982
- lockRotation: obj.lockRotation ?? false,
983
- hasControls: obj.hasControls ?? true
984
- };
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
+ }
1361
+ function isLegacyState(state) {
1362
+ return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
985
1363
  }
986
- function applyPatternLocks(obj) {
987
- obj.set({
988
- lockMovementX: true,
989
- lockMovementY: true,
990
- lockScalingX: true,
991
- lockScalingY: true,
992
- lockRotation: true,
993
- hasControls: false
1364
+ async function unbakeLegacyLayer(layer, state) {
1365
+ const image = layer.fabricObject;
1366
+ if (typeof image.setSrc !== "function" || !state.original) return;
1367
+ await image.setSrc(state.originalSrc);
1368
+ image.set({
1369
+ left: state.original.left,
1370
+ top: state.original.top,
1371
+ scaleX: state.original.scaleX,
1372
+ scaleY: state.original.scaleY,
1373
+ width: state.original.width,
1374
+ height: state.original.height,
1375
+ cropX: state.original.cropX,
1376
+ cropY: state.original.cropY,
1377
+ angle: state.original.angle
994
1378
  });
1379
+ image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
1380
+ restoreLocks(image, state.originalLocks);
1381
+ image.setCoords();
995
1382
  }
996
1383
  function restoreLocks(obj, locks) {
997
1384
  obj.set(
@@ -1005,121 +1392,6 @@ function restoreLocks(obj, locks) {
1005
1392
  }
1006
1393
  );
1007
1394
  }
1008
- function elementToDataURL(image) {
1009
- try {
1010
- const el = image.getElement();
1011
- const w = el.naturalWidth || el.width;
1012
- const h = el.naturalHeight || el.height;
1013
- if (!w || !h) return null;
1014
- const off = document.createElement("canvas");
1015
- off.width = w;
1016
- off.height = h;
1017
- const ctx = off.getContext("2d");
1018
- if (!ctx) return null;
1019
- ctx.drawImage(el, 0, 0);
1020
- return off.toDataURL("image/png");
1021
- } catch {
1022
- return null;
1023
- }
1024
- }
1025
- var IMAGE_CACHE_MAX = 16;
1026
- var imageCache = /* @__PURE__ */ new Map();
1027
- function loadPatternImage(src, resolver) {
1028
- const cached = imageCache.get(src);
1029
- if (cached) {
1030
- imageCache.delete(src);
1031
- imageCache.set(src, cached);
1032
- return cached;
1033
- }
1034
- const promise = decodeImage(src).catch(async (originalError) => {
1035
- if (!resolver) throw originalError;
1036
- const resolved = await resolver(src);
1037
- if (!resolved || resolved === src) {
1038
- throw new Error("Pattern source resolver did not return a usable alternate URL", {
1039
- cause: originalError
1040
- });
1041
- }
1042
- return decodeImage(resolved);
1043
- });
1044
- promise.catch(() => {
1045
- if (imageCache.get(src) === promise) imageCache.delete(src);
1046
- });
1047
- imageCache.set(src, promise);
1048
- if (imageCache.size > IMAGE_CACHE_MAX) {
1049
- const oldest = imageCache.keys().next().value;
1050
- if (oldest !== void 0) imageCache.delete(oldest);
1051
- }
1052
- return promise;
1053
- }
1054
- function decodeImage(src) {
1055
- return new Promise((resolve, reject) => {
1056
- const img = new Image();
1057
- img.crossOrigin = "anonymous";
1058
- img.onload = () => resolve(img);
1059
- img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
1060
- img.src = src;
1061
- });
1062
- }
1063
- function clearPatternImageCache() {
1064
- imageCache.clear();
1065
- }
1066
- async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
1067
- const img = await loadPatternImage(src, sourceResolver);
1068
- const off = document.createElement("canvas");
1069
- off.width = Math.max(1, Math.round(targetW));
1070
- off.height = Math.max(1, Math.round(targetH));
1071
- const ctx = off.getContext("2d");
1072
- if (!ctx) return off.toDataURL("image/png");
1073
- drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
1074
- return off.toDataURL("image/png");
1075
- }
1076
- function computeTilePositions(config, targetW, targetH, baseW, baseH) {
1077
- const diag = Math.sqrt(targetW * targetW + targetH * targetH);
1078
- const minTile = Math.max(1, diag / MAX_TILES_PER_AXIS);
1079
- const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
1080
- const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
1081
- const cols = Math.ceil(diag / tileW) + 2;
1082
- const rows = Math.ceil(diag / tileH) + 2;
1083
- const halfCols = Math.ceil(cols / 2);
1084
- const halfRows = Math.ceil(rows / 2);
1085
- const shiftX = tileW * (clampOffset(config.offsetX) / 100);
1086
- const shiftY = tileH * (clampOffset(config.offsetY) / 100);
1087
- const placements = [];
1088
- for (let j = -halfRows; j <= halfRows; j++) {
1089
- for (let i = -halfCols; i <= halfCols; i++) {
1090
- let x = i * tileW + shiftX;
1091
- let y = j * tileH + shiftY;
1092
- if (config.mode === "brick-horizontal" && mod2(j) === 1) {
1093
- x += tileW * (config.horizontalOffset / 100);
1094
- } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
1095
- y += tileH * (config.horizontalOffset / 100);
1096
- }
1097
- const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
1098
- placements.push({ x, y, rotation });
1099
- }
1100
- }
1101
- return placements;
1102
- }
1103
- function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
1104
- ctx.save();
1105
- ctx.translate(targetW / 2, targetH / 2);
1106
- ctx.rotate(config.angle * Math.PI / 180);
1107
- for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
1108
- ctx.save();
1109
- ctx.translate(tile.x, tile.y);
1110
- ctx.rotate(tile.rotation);
1111
- ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
1112
- ctx.restore();
1113
- }
1114
- ctx.restore();
1115
- }
1116
- function clampOffset(value) {
1117
- if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1118
- return Math.max(-100, Math.min(100, value));
1119
- }
1120
- function mod2(n) {
1121
- return (n % 2 + 2) % 2;
1122
- }
1123
1395
 
1124
1396
  // src/text-curve.ts
1125
1397
  import { Path } from "fabric";
@@ -1287,7 +1559,7 @@ var TextCurveManager = class {
1287
1559
  };
1288
1560
 
1289
1561
  // src/mask-presets/manager.ts
1290
- import { FabricImage as FabricImage2, Path as Path2 } from "fabric";
1562
+ import { FabricImage, Path as Path2 } from "fabric";
1291
1563
 
1292
1564
  // src/mask-presets/shapes.ts
1293
1565
  var SHAPE_MASK_IDS = [
@@ -1560,7 +1832,7 @@ var MaskPresetManager = class {
1560
1832
  scaleY: height / SHAPE_MASK_BOX
1561
1833
  });
1562
1834
  }
1563
- return new FabricImage2(renderTextureMask(id), {
1835
+ return new FabricImage(renderTextureMask(id), {
1564
1836
  ...shared,
1565
1837
  scaleX: width / TEXTURE_MASK_SIZE,
1566
1838
  scaleY: height / TEXTURE_MASK_SIZE
@@ -2128,7 +2400,7 @@ var ProjectManager = class {
2128
2400
  };
2129
2401
 
2130
2402
  // src/mask.ts
2131
- import { FabricImage as FabricImage3 } from "fabric";
2403
+ import { FabricImage as FabricImage2 } from "fabric";
2132
2404
  var MaskRefinementError = class extends Error {
2133
2405
  constructor(code, message, cause) {
2134
2406
  super(message);
@@ -2160,7 +2432,7 @@ var MaskController = class {
2160
2432
  throw new Error("Mask dimensions must be positive integers");
2161
2433
  }
2162
2434
  const backing = this.makeCanvas(width, height);
2163
- const image = new FabricImage3(backing, {
2435
+ const image = new FabricImage2(backing, {
2164
2436
  left: 0,
2165
2437
  top: 0,
2166
2438
  originX: "left",
@@ -2385,6 +2657,737 @@ var MaskController = class {
2385
2657
  }
2386
2658
  };
2387
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
+
2388
3391
  // src/editor.ts
2389
3392
  var MIN_ZOOM = 0.1;
2390
3393
  var MAX_ZOOM = 8;
@@ -2402,6 +3405,8 @@ var CanvasEditor = class {
2402
3405
  patterns;
2403
3406
  curves;
2404
3407
  maskPresets;
3408
+ /** Stacked boolean masks, per layer and for the design as a whole. */
3409
+ layerMasks;
2405
3410
  fonts;
2406
3411
  licensing;
2407
3412
  pages;
@@ -2447,15 +3452,10 @@ var CanvasEditor = class {
2447
3452
  this.layers.setHistoryCallback(() => this.history.save());
2448
3453
  this.snapping = new SnapManager(this.canvas, this.events);
2449
3454
  this.crop = new CropController(this.canvas, this.history, this.events);
2450
- this.patterns = new PatternManager(
2451
- this.canvas,
2452
- this.layers,
2453
- this.history,
2454
- this.events,
2455
- config.patternSourceResolver
2456
- );
3455
+ this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2457
3456
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2458
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);
2459
3459
  this.setupCanvasEvents();
2460
3460
  this.refreshSelectionStyle();
2461
3461
  this.history.saveImmediate();
@@ -2465,12 +3465,13 @@ var CanvasEditor = class {
2465
3465
  // ─── Layer Operations ────────────────────────────────
2466
3466
  async addImage(url, options) {
2467
3467
  try {
2468
- const img = await FabricImage4.fromURL(
3468
+ const img = await FabricImage3.fromURL(
2469
3469
  url,
2470
3470
  {},
2471
3471
  { originX: "left", originY: "top", ...options }
2472
3472
  );
2473
3473
  const layer = this.layers.add("image", img);
3474
+ this.layers.select(layer.id);
2474
3475
  this.history.save();
2475
3476
  return layer;
2476
3477
  } catch (error) {
@@ -2490,7 +3491,7 @@ var CanvasEditor = class {
2490
3491
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
2491
3492
  const previous = layer.fabricObject;
2492
3493
  try {
2493
- const replacement = await FabricImage4.fromURL(url, {}, { originX: "left", originY: "top" });
3494
+ const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
2494
3495
  replacement.set({
2495
3496
  left: previous.left,
2496
3497
  top: previous.top,
@@ -2530,12 +3531,14 @@ var CanvasEditor = class {
2530
3531
  ...options
2531
3532
  });
2532
3533
  const layer = this.layers.add("text", textbox);
3534
+ this.layers.select(layer.id);
2533
3535
  this.history.save();
2534
3536
  return layer;
2535
3537
  }
2536
3538
  addShape(plugin, options) {
2537
3539
  const obj = plugin.create(options);
2538
3540
  const layer = this.layers.add("shape", obj, plugin.name);
3541
+ this.layers.select(layer.id);
2539
3542
  this.history.save();
2540
3543
  return layer;
2541
3544
  }
@@ -2561,7 +3564,7 @@ var CanvasEditor = class {
2561
3564
  const { objects, options } = await loadSVGFromString(resolved);
2562
3565
  const validObjects = objects.filter((object) => object !== null);
2563
3566
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
2564
- const group = util3.groupSVGElements(validObjects, options);
3567
+ const group = util5.groupSVGElements(validObjects, options);
2565
3568
  group.set({
2566
3569
  left: this.canvas.getWidth() / 2,
2567
3570
  top: this.canvas.getHeight() / 2,
@@ -2569,6 +3572,7 @@ var CanvasEditor = class {
2569
3572
  originY: "center"
2570
3573
  });
2571
3574
  const layer = this.layers.add("template", group, template.name);
3575
+ this.layers.select(layer.id);
2572
3576
  this.history.save();
2573
3577
  return layer;
2574
3578
  }
@@ -2606,9 +3610,7 @@ var CanvasEditor = class {
2606
3610
  const layer = this.layers.get(id);
2607
3611
  if (!layer) return null;
2608
3612
  const clone = await layer.fabricObject.clone();
2609
- if (!layer.meta.pattern) {
2610
- clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2611
- }
3613
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2612
3614
  clone.setCoords();
2613
3615
  const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
2614
3616
  copy.meta = structuredClone(layer.meta);
@@ -2621,7 +3623,8 @@ var CanvasEditor = class {
2621
3623
  evented: !layer.locked,
2622
3624
  opacity: layer.opacity
2623
3625
  });
2624
- this.canvas.setActiveObject(clone);
3626
+ this.patterns.attachTo(copy);
3627
+ this.layers.select(copy.id);
2625
3628
  this.canvas.requestRenderAll();
2626
3629
  this.history.save();
2627
3630
  return copy;
@@ -2654,7 +3657,7 @@ var CanvasEditor = class {
2654
3657
  const childData = children.map((layer) => structuredClone(layer.toData()));
2655
3658
  const objects = children.map((layer) => layer.fabricObject);
2656
3659
  for (const layer of children) this.layers.remove(layer.id);
2657
- const group = new Group(objects);
3660
+ const group = new Group6(objects);
2658
3661
  const grouped = this.layers.add("group", group, name);
2659
3662
  grouped.meta.groupChildren = childData;
2660
3663
  this.layers.select(grouped.id);
@@ -2669,11 +3672,9 @@ var CanvasEditor = class {
2669
3672
  if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
2670
3673
  const group = grouped.fabricObject;
2671
3674
  return this.history.transaction(() => {
2672
- const transform = group.calcTransformMatrix();
2673
3675
  const objects = group.removeAll();
2674
3676
  this.layers.remove(id);
2675
3677
  const restored = objects.map((object, index) => {
2676
- util3.addTransformToObject(object, transform);
2677
3678
  object.setCoords();
2678
3679
  const data = childData[index];
2679
3680
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -2700,7 +3701,8 @@ var CanvasEditor = class {
2700
3701
  }
2701
3702
  try {
2702
3703
  await deserializeEditor(this, state);
2703
- this.patterns.repinAll();
3704
+ await this.patterns.rehydrateAll();
3705
+ this.layerMasks.refreshAll();
2704
3706
  } catch (error) {
2705
3707
  if (!managedByHistory) {
2706
3708
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -2729,7 +3731,7 @@ var CanvasEditor = class {
2729
3731
  const layer = this.layers.get(id);
2730
3732
  if (!layer) throw new Error(`Layer not found: ${id}`);
2731
3733
  try {
2732
- if (options.resolution === "source" && layer.fabricObject instanceof FabricImage4) {
3734
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
2733
3735
  const image = await layer.fabricObject.clone();
2734
3736
  image.set({
2735
3737
  left: 0,
@@ -2749,7 +3751,11 @@ var CanvasEditor = class {
2749
3751
  cloneObjects: false
2750
3752
  });
2751
3753
  }
2752
- return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
3754
+ return await exportIsolatedPNG(
3755
+ this.canvas,
3756
+ [layer.renderProxy ?? layer.fabricObject],
3757
+ options
3758
+ );
2753
3759
  } catch (error) {
2754
3760
  this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
2755
3761
  throw error;
@@ -3001,7 +4007,7 @@ var CanvasEditor = class {
3001
4007
  return;
3002
4008
  }
3003
4009
  try {
3004
- const image = await FabricImage4.fromURL(
4010
+ const image = await FabricImage3.fromURL(
3005
4011
  url,
3006
4012
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
3007
4013
  { originX: "left", originY: "top" }
@@ -3054,7 +4060,6 @@ var CanvasEditor = class {
3054
4060
  const sx = widthPx / oldWidth;
3055
4061
  const sy = heightPx / oldHeight;
3056
4062
  for (const layer of this.layers.getAll()) {
3057
- if (layer.meta.pattern) continue;
3058
4063
  const object = layer.fabricObject;
3059
4064
  object.set({
3060
4065
  left: (object.left ?? 0) * sx,
@@ -3075,7 +4080,7 @@ var CanvasEditor = class {
3075
4080
  }
3076
4081
  }
3077
4082
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
3078
- this.patterns.repinAll();
4083
+ this.patterns.syncArea();
3079
4084
  this.canvas.requestRenderAll();
3080
4085
  this.history.save();
3081
4086
  this.events.emit("canvas:modified", {});
@@ -3270,10 +4275,11 @@ var CanvasEditor = class {
3270
4275
  // ─── Cleanup ────────────────────────────────────────
3271
4276
  dispose() {
3272
4277
  this.masks.dispose();
4278
+ this.layerMasks.dispose();
3273
4279
  this.snapping.dispose();
3274
4280
  this.crop.dispose();
3275
4281
  this.history.dispose();
3276
- clearPatternImageCache();
4282
+ this.patterns.dispose();
3277
4283
  this.events.removeAllListeners();
3278
4284
  this.canvas.dispose();
3279
4285
  }
@@ -3392,6 +4398,7 @@ var AnnotationOverlay = class {
3392
4398
  };
3393
4399
  export {
3394
4400
  AnnotationOverlay,
4401
+ CANVAS_MASK_TARGET,
3395
4402
  CANVAS_SIZE_PRESETS,
3396
4403
  CanvasEditor,
3397
4404
  CropController,
@@ -3404,6 +4411,7 @@ export {
3404
4411
  HistoryManager,
3405
4412
  Layer,
3406
4413
  LayerManager,
4414
+ LayerMaskManager,
3407
4415
  LicenseManager,
3408
4416
  MaskController,
3409
4417
  MaskPresetManager,
@@ -3416,18 +4424,16 @@ export {
3416
4424
  TEXTURE_MASK_IDS,
3417
4425
  TEXTURE_MASK_SIZE,
3418
4426
  TextCurveManager,
4427
+ TiledPatternObject,
3419
4428
  UnitConverter,
3420
4429
  applyAspectLock,
3421
4430
  applyLayerShadow,
3422
4431
  applyObjectSelectionStyle,
3423
- applyPatternLocks,
3424
4432
  applySelectionStyle,
3425
4433
  buildCurvePathData,
3426
- buildPatternDataURL,
3427
- captureLocks,
3428
4434
  clamp,
3429
- clearPatternImageCache,
3430
4435
  clearTextureMaskCache,
4436
+ composeMaskGroup,
3431
4437
  computeCoverPlacement,
3432
4438
  computePrintAreaClip,
3433
4439
  computeTilePositions,
@@ -3440,12 +4446,13 @@ export {
3440
4446
  exportPNG,
3441
4447
  exportPrintArea,
3442
4448
  exportSVG,
4449
+ fitToBox,
3443
4450
  generateId,
3444
4451
  isCssColor,
3445
4452
  isMaskPresetId,
3446
4453
  isShapeMaskId,
3447
4454
  isTextureMaskId,
3448
- loadPatternImage,
4455
+ needsAbsoluteSpace,
3449
4456
  readLayerShadow,
3450
4457
  renderTextureMask,
3451
4458
  resetTransform,
@@ -3453,6 +4460,9 @@ export {
3453
4460
  round2,
3454
4461
  sanitizeSvg,
3455
4462
  serializeEditor,
3456
- shapeMaskPathData
4463
+ shapeMaskPathData,
4464
+ toCanvasSpace,
4465
+ toHostSpace,
4466
+ unwrapGroup
3457
4467
  };
3458
4468
  //# sourceMappingURL=index.mjs.map