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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  CropController: () => CropController,
27
27
  DEFAULT_LAYER_SHADOW: () => DEFAULT_LAYER_SHADOW,
28
28
  DEFAULT_PATTERN_CONFIG: () => DEFAULT_PATTERN_CONFIG,
29
+ DEFAULT_SELECTION_STYLE: () => DEFAULT_SELECTION_STYLE,
29
30
  DEFAULT_TEXT_CURVE: () => DEFAULT_TEXT_CURVE,
30
31
  EventEmitter: () => EventEmitter,
31
32
  FontRegistry: () => FontRegistry,
@@ -44,15 +45,14 @@ __export(index_exports, {
44
45
  TEXTURE_MASK_IDS: () => TEXTURE_MASK_IDS,
45
46
  TEXTURE_MASK_SIZE: () => TEXTURE_MASK_SIZE,
46
47
  TextCurveManager: () => TextCurveManager,
48
+ TiledPatternObject: () => TiledPatternObject,
47
49
  UnitConverter: () => UnitConverter,
48
50
  applyAspectLock: () => applyAspectLock,
49
51
  applyLayerShadow: () => applyLayerShadow,
50
- applyPatternLocks: () => applyPatternLocks,
52
+ applyObjectSelectionStyle: () => applyObjectSelectionStyle,
53
+ applySelectionStyle: () => applySelectionStyle,
51
54
  buildCurvePathData: () => buildCurvePathData,
52
- buildPatternDataURL: () => buildPatternDataURL,
53
- captureLocks: () => captureLocks,
54
55
  clamp: () => clamp,
55
- clearPatternImageCache: () => clearPatternImageCache,
56
56
  clearTextureMaskCache: () => clearTextureMaskCache,
57
57
  computeCoverPlacement: () => computeCoverPlacement,
58
58
  computePrintAreaClip: () => computePrintAreaClip,
@@ -71,7 +71,6 @@ __export(index_exports, {
71
71
  isMaskPresetId: () => isMaskPresetId,
72
72
  isShapeMaskId: () => isShapeMaskId,
73
73
  isTextureMaskId: () => isTextureMaskId,
74
- loadPatternImage: () => loadPatternImage,
75
74
  readLayerShadow: () => readLayerShadow,
76
75
  renderTextureMask: () => renderTextureMask,
77
76
  resetTransform: () => resetTransform,
@@ -84,7 +83,7 @@ __export(index_exports, {
84
83
  module.exports = __toCommonJS(index_exports);
85
84
 
86
85
  // src/editor.ts
87
- var import_fabric9 = require("fabric");
86
+ var import_fabric10 = require("fabric");
88
87
 
89
88
  // src/events.ts
90
89
  var EventEmitter = class {
@@ -136,6 +135,12 @@ var Layer = class {
136
135
  /** Non-fabric data (e.g. pattern config) that must persist with the layer. */
137
136
  meta;
138
137
  fabricObject;
138
+ /**
139
+ * Stand-in object drawn in place of `fabricObject` (the tiled pattern). It is
140
+ * part of the canvas but never part of the document: it is not serialized, and
141
+ * the layer's real object stays the one every editor API talks to.
142
+ */
143
+ renderProxy;
139
144
  constructor(type, fabricObject, name, id) {
140
145
  this.id = id ?? generateId();
141
146
  this.type = type;
@@ -145,6 +150,7 @@ var Layer = class {
145
150
  this.opacity = 1;
146
151
  this.meta = {};
147
152
  this.fabricObject = fabricObject;
153
+ this.renderProxy = null;
148
154
  this.fabricObject._layerId = this.id;
149
155
  }
150
156
  hasMeta() {
@@ -193,6 +199,7 @@ var LayerManager = class {
193
199
  if (index === -1) return false;
194
200
  const layer = this.layers[index];
195
201
  this.canvas.remove(layer.fabricObject);
202
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
196
203
  this.layers.splice(index, 1);
197
204
  this.events.emit("layer:removed", { layerId: id });
198
205
  this.emitChanged();
@@ -216,6 +223,10 @@ var LayerManager = class {
216
223
  evented: !layer.locked
217
224
  });
218
225
  this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
226
+ if (layer.renderProxy) {
227
+ fabricObject.set({ opacity: 0 });
228
+ this.syncZOrder();
229
+ }
219
230
  if (wasActive) this.canvas.setActiveObject(fabricObject);
220
231
  this.canvas.requestRenderAll();
221
232
  this.events.emit("layer:modified", { layerId: id });
@@ -223,6 +234,36 @@ var LayerManager = class {
223
234
  this.onPropertyChanged?.();
224
235
  return true;
225
236
  }
237
+ /**
238
+ * Attach (or clear) the object drawn in place of a layer's own object. The
239
+ * proxy tracks the layer's stacking position, visibility and opacity, and is
240
+ * removed with the layer — it must never outlive or drift from its source.
241
+ */
242
+ setRenderProxy(id, proxy) {
243
+ const layer = this.get(id);
244
+ if (!layer || layer.renderProxy === proxy) return;
245
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
246
+ layer.renderProxy = proxy;
247
+ if (proxy) {
248
+ proxy.set({ visible: layer.visible, opacity: layer.opacity });
249
+ this.canvas.add(proxy);
250
+ this.syncZOrder();
251
+ }
252
+ this.canvas.requestRenderAll();
253
+ this.emitChanged();
254
+ }
255
+ /**
256
+ * Re-stack every canvas object to match layer order, keeping each proxy
257
+ * directly above the source it stands in for. Canvas indices can't be derived
258
+ * from layer indices once proxies are in the array, so the order is rebuilt
259
+ * front-to-back instead of computed.
260
+ */
261
+ syncZOrder() {
262
+ for (const layer of this.layers) {
263
+ this.canvas.bringObjectToFront(layer.fabricObject);
264
+ if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
265
+ }
266
+ }
226
267
  reorder(id, newIndex) {
227
268
  const oldIndex = this.layers.findIndex((l) => l.id === id);
228
269
  if (oldIndex === -1) return false;
@@ -231,9 +272,7 @@ var LayerManager = class {
231
272
  if (oldIndex === clamped) return false;
232
273
  const [layer] = this.layers.splice(oldIndex, 1);
233
274
  this.layers.splice(clamped, 0, layer);
234
- this.layers.forEach((l, i) => {
235
- this.canvas.moveObjectTo(l.fabricObject, i);
236
- });
275
+ this.syncZOrder();
237
276
  this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
238
277
  this.emitChanged();
239
278
  this.onPropertyChanged?.();
@@ -270,6 +309,7 @@ var LayerManager = class {
270
309
  if (layer.visible === visible) return;
271
310
  layer.visible = visible;
272
311
  layer.fabricObject.visible = visible;
312
+ if (layer.renderProxy) layer.renderProxy.visible = visible;
273
313
  this.canvas.requestRenderAll();
274
314
  this.emitChanged();
275
315
  this.onPropertyChanged?.();
@@ -291,7 +331,8 @@ var LayerManager = class {
291
331
  const next = Math.max(0, Math.min(1, opacity));
292
332
  if (layer.opacity === next) return;
293
333
  layer.opacity = next;
294
- layer.fabricObject.opacity = next;
334
+ if (layer.renderProxy) layer.renderProxy.opacity = next;
335
+ else layer.fabricObject.opacity = next;
295
336
  this.canvas.requestRenderAll();
296
337
  this.emitChanged();
297
338
  this.onPropertyChanged?.();
@@ -339,6 +380,7 @@ var LayerManager = class {
339
380
  clear() {
340
381
  for (const layer of this.layers) {
341
382
  this.canvas.remove(layer.fabricObject);
383
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
342
384
  }
343
385
  this.layers = [];
344
386
  this.emitChanged();
@@ -872,199 +914,415 @@ var CropController = class {
872
914
  };
873
915
  var STROKE2 = "#22c55e";
874
916
 
875
- // src/pattern.ts
917
+ // src/pattern/pattern-manager.ts
918
+ var import_fabric3 = require("fabric");
919
+
920
+ // src/pattern/tiled-pattern-object.ts
876
921
  var import_fabric2 = require("fabric");
922
+
923
+ // src/pattern/tile-geometry.ts
877
924
  var MAX_TILES_PER_AXIS = 200;
925
+ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
926
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
927
+ const radius = cornerRadius(anchor, targetW, targetH);
928
+ const span = radius * 2;
929
+ const minTile = Math.max(1, span / MAX_TILES_PER_AXIS);
930
+ const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
931
+ const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
932
+ const cols = Math.ceil(span / tileW) + 2;
933
+ const rows = Math.ceil(span / tileH) + 2;
934
+ const halfCols = Math.ceil(cols / 2);
935
+ const halfRows = Math.ceil(rows / 2);
936
+ const shiftX = tileW * (clampOffset(config.offsetX) / 100);
937
+ const shiftY = tileH * (clampOffset(config.offsetY) / 100);
938
+ const placements = [];
939
+ for (let j = -halfRows; j <= halfRows; j++) {
940
+ for (let i = -halfCols; i <= halfCols; i++) {
941
+ let x = i * tileW + shiftX;
942
+ let y = j * tileH + shiftY;
943
+ if (config.mode === "brick-horizontal" && mod2(j) === 1) {
944
+ x += tileW * (config.horizontalOffset / 100);
945
+ } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
946
+ y += tileH * (config.horizontalOffset / 100);
947
+ }
948
+ const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
949
+ placements.push({ x, y, rotation });
950
+ }
951
+ }
952
+ return placements;
953
+ }
954
+ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
955
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
956
+ ctx.save();
957
+ ctx.translate(anchor.x, anchor.y);
958
+ ctx.rotate(config.angle * Math.PI / 180);
959
+ for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH, anchor)) {
960
+ ctx.save();
961
+ ctx.translate(tile.x, tile.y);
962
+ ctx.rotate(tile.rotation);
963
+ ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
964
+ ctx.restore();
965
+ }
966
+ ctx.restore();
967
+ }
968
+ function cornerRadius(origin, w, h) {
969
+ const dx = Math.max(Math.abs(origin.x), Math.abs(w - origin.x));
970
+ const dy = Math.max(Math.abs(origin.y), Math.abs(h - origin.y));
971
+ return Math.sqrt(dx * dx + dy * dy);
972
+ }
973
+ function clampOffset(value) {
974
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
975
+ return Math.max(-100, Math.min(100, value));
976
+ }
977
+ function mod2(n) {
978
+ return (n % 2 + 2) % 2;
979
+ }
980
+
981
+ // src/pattern/tiled-pattern-object.ts
982
+ var MAX_SNAPSHOT_PIXELS = 16e6;
983
+ var MAX_SNAPSHOT_SCALE = 8;
984
+ var SNAPSHOT_SHRINK_FACTOR = 2;
985
+ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.FabricObject {
986
+ static type = "TiledPattern";
987
+ /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
988
+ source;
989
+ config;
990
+ snapshotEl = null;
991
+ snapshotScale = 0;
992
+ constructor(source, config, width, height) {
993
+ super({
994
+ // Explicit origin: fabric v7 defaults to `center`, and a centre-origin
995
+ // object placed at 0,0 covers a quarter of the canvas. That exact bug is
996
+ // why the old bake-into-the-layer pattern landed in the top-left corner.
997
+ originX: "left",
998
+ originY: "top",
999
+ left: 0,
1000
+ top: 0,
1001
+ width,
1002
+ height,
1003
+ // Pointer events belong to the source underneath, and fabric's object
1004
+ // cache would freeze the tiling at screen resolution.
1005
+ selectable: false,
1006
+ evented: false,
1007
+ objectCaching: false,
1008
+ hasControls: false,
1009
+ hasBorders: false
1010
+ });
1011
+ this.source = source;
1012
+ this.config = config;
1013
+ }
1014
+ /** Swap in a new config; the next render picks it up. */
1015
+ setConfig(config) {
1016
+ this.config = config;
1017
+ this.dirty = true;
1018
+ }
1019
+ /** Resize to a new print area. */
1020
+ setArea(width, height) {
1021
+ this.set({ width, height });
1022
+ this.setCoords();
1023
+ }
1024
+ /** Drop the cached source snapshot (e.g. the source was edited). */
1025
+ invalidate() {
1026
+ this.snapshotEl = null;
1027
+ this.snapshotScale = 0;
1028
+ this.dirty = true;
1029
+ }
1030
+ /** Free the offscreen snapshot. */
1031
+ dispose() {
1032
+ this.snapshotEl = null;
1033
+ this.snapshotScale = 0;
1034
+ }
1035
+ _render(ctx) {
1036
+ const width = this.width ?? 0;
1037
+ const height = this.height ?? 0;
1038
+ if (width <= 0 || height <= 0) return;
1039
+ const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
1040
+ const snapshot = this.ensureSnapshot(contextScale(ctx) * tileScale);
1041
+ if (!snapshot) return;
1042
+ const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1043
+ ctx.save();
1044
+ ctx.translate(-width / 2, -height / 2);
1045
+ drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1046
+ ctx.restore();
1047
+ }
1048
+ /** Raster fallback for SVG export — one `<image>` covering the print area. */
1049
+ _toSVG() {
1050
+ const width = this.width ?? 0;
1051
+ const height = this.height ?? 0;
1052
+ if (width <= 0 || height <= 0) return [];
1053
+ const el = document.createElement("canvas");
1054
+ el.width = Math.max(1, Math.round(width));
1055
+ el.height = Math.max(1, Math.round(height));
1056
+ const ctx = el.getContext("2d");
1057
+ if (!ctx) return [];
1058
+ const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
1059
+ const snapshot = this.ensureSnapshot(tileScale);
1060
+ if (!snapshot) return [];
1061
+ const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
1062
+ drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
1063
+ return [
1064
+ `<image x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" `,
1065
+ `xlink:href="${el.toDataURL("image/png")}"></image>
1066
+ `
1067
+ ];
1068
+ }
1069
+ /**
1070
+ * Fabric's generic clone round-trips through `toObject()` + the class
1071
+ * registry, which cannot carry a live source reference. Export paths clone
1072
+ * every canvas object, so without this a print export would silently lose the
1073
+ * tiling. The copy shares the source (it only ever reads from it).
1074
+ */
1075
+ clone() {
1076
+ const copy = new _TiledPatternObject(
1077
+ this.source,
1078
+ this.config,
1079
+ this.width ?? 0,
1080
+ this.height ?? 0
1081
+ );
1082
+ copy.set({
1083
+ left: this.left,
1084
+ top: this.top,
1085
+ visible: this.visible,
1086
+ opacity: this.opacity
1087
+ });
1088
+ return Promise.resolve(copy);
1089
+ }
1090
+ /**
1091
+ * The proxy holds a live reference to its source, which would make a
1092
+ * serialized canvas circular. Nothing persists this object (only layers are
1093
+ * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1094
+ */
1095
+ toObject() {
1096
+ const plain = super.toObject();
1097
+ delete plain.source;
1098
+ return plain;
1099
+ }
1100
+ /** Tile size in canvas units, floored so a tiny tile can't spawn a huge loop. */
1101
+ tileSize(snapshot, tileScale, width, height) {
1102
+ const floor = Math.max(2, Math.sqrt(width * width + height * height) / 200);
1103
+ return {
1104
+ tileW: Math.max(floor, snapshot.width / this.snapshotScale * tileScale),
1105
+ tileH: Math.max(floor, snapshot.height / this.snapshotScale * tileScale)
1106
+ };
1107
+ }
1108
+ /** Grid anchor: the source's centre, in this object's coordinates. */
1109
+ gridOrigin() {
1110
+ const centre = this.source.getCenterPoint();
1111
+ return { x: centre.x - (this.left ?? 0), y: centre.y - (this.top ?? 0) };
1112
+ }
1113
+ /**
1114
+ * Snapshot the source at (at least) `scale`, reusing the cached one when it is
1115
+ * still sharp enough and the source has not changed.
1116
+ */
1117
+ ensureSnapshot(scale) {
1118
+ const wanted = this.clampScale(scale);
1119
+ const stale = this.source.dirty || !this.snapshotEl || this.snapshotScale < wanted || this.snapshotScale > wanted * SNAPSHOT_SHRINK_FACTOR;
1120
+ if (!stale) return this.snapshotEl;
1121
+ const source = this.source;
1122
+ const opacity = source.opacity;
1123
+ source.opacity = 1;
1124
+ try {
1125
+ const el = source.toCanvasElement({ multiplier: wanted, enableRetinaScaling: false });
1126
+ if (!el.width || !el.height) return null;
1127
+ this.snapshotEl = el;
1128
+ this.snapshotScale = wanted;
1129
+ } catch {
1130
+ return this.snapshotEl;
1131
+ } finally {
1132
+ source.opacity = opacity;
1133
+ source.dirty = false;
1134
+ }
1135
+ return this.snapshotEl;
1136
+ }
1137
+ /** Bound the snapshot by both a linear scale and a total pixel budget. */
1138
+ clampScale(scale) {
1139
+ const requested = Math.min(MAX_SNAPSHOT_SCALE, Math.max(0.05, scale));
1140
+ const rect = this.source.getBoundingRect();
1141
+ const area = Math.max(1, rect.width * rect.height);
1142
+ const budgeted = Math.sqrt(MAX_SNAPSHOT_PIXELS / area);
1143
+ return Math.max(0.05, Math.min(requested, budgeted));
1144
+ }
1145
+ };
1146
+ function contextScale(ctx) {
1147
+ if (typeof ctx.getTransform !== "function") return 1;
1148
+ try {
1149
+ const t = ctx.getTransform();
1150
+ return Math.max(Math.hypot(t.a, t.b), Math.hypot(t.c, t.d), 0.05);
1151
+ } catch {
1152
+ return 1;
1153
+ }
1154
+ }
1155
+
1156
+ // src/pattern/pattern-manager.ts
878
1157
  var PatternManager = class {
879
- constructor(canvas, layers, history, events, sourceResolver) {
1158
+ constructor(canvas, layers, history, events) {
880
1159
  this.canvas = canvas;
881
1160
  this.layers = layers;
882
1161
  this.history = history;
883
1162
  this.events = events;
884
- this.sourceResolver = sourceResolver;
1163
+ this.canvas.on("object:modified", this.onSourceModified);
1164
+ this.canvas.on("text:changed", this.onSourceModified);
1165
+ this.events.on("layer:removed", this.onLayerRemoved);
885
1166
  }
886
1167
  canvas;
887
1168
  layers;
888
1169
  history;
889
1170
  events;
890
- sourceResolver;
891
- // Per-layer task chain. apply()/disable() both await an async setSrc on the
892
- // same fabric image; running two concurrently lets their setSrc resolutions
893
- // interleave (wrong image installed, original lost). Serialising per layer
894
- // guarantees the last-requested operation wins and state stays consistent.
895
- chains = /* @__PURE__ */ new Map();
1171
+ proxies = /* @__PURE__ */ new Map();
1172
+ onSourceModified = (event) => {
1173
+ this.invalidateFor(event.target);
1174
+ };
1175
+ onLayerRemoved = ({ layerId }) => {
1176
+ const proxy = this.proxies.get(layerId);
1177
+ if (!proxy) return;
1178
+ proxy.dispose();
1179
+ this.proxies.delete(layerId);
1180
+ };
896
1181
  isPattern(layerId) {
897
1182
  return !!this.layers.get(layerId)?.meta.pattern;
898
1183
  }
899
1184
  getConfig(layerId) {
900
1185
  return this.layers.get(layerId)?.meta.pattern?.config ?? null;
901
1186
  }
902
- /** Turn a plain image layer into a pattern, or update an existing one. */
903
- apply(layerId, config) {
904
- return this.enqueue(layerId, async () => {
905
- const layer = this.layers.get(layerId);
906
- if (!layer || layer.type !== "image") return;
907
- const image = layer.fabricObject;
908
- const firstEnable = !layer.meta.pattern;
909
- if (!layer.meta.pattern) {
910
- const clip = image.clipPath;
911
- layer.meta.pattern = {
912
- config,
913
- originalSrc: elementToDataURL(image) ?? image.getSrc(),
914
- originalClip: clip ? clip.toObject() : null,
915
- originalLocks: captureLocks(image),
916
- original: {
917
- left: image.left ?? 0,
918
- top: image.top ?? 0,
919
- scaleX: image.scaleX ?? 1,
920
- scaleY: image.scaleY ?? 1,
921
- width: image.width ?? 0,
922
- height: image.height ?? 0,
923
- angle: image.angle ?? 0,
924
- cropX: image.cropX ?? 0,
925
- cropY: image.cropY ?? 0
926
- }
927
- };
1187
+ /** Turn a layer into a repeating pattern, or update an existing one. */
1188
+ async apply(layerId, config) {
1189
+ const layer = this.layers.get(layerId);
1190
+ if (!layer) return;
1191
+ try {
1192
+ this.layers.setMeta(layerId, { pattern: { config } });
1193
+ const proxy = this.proxies.get(layerId);
1194
+ if (proxy) {
1195
+ proxy.setConfig(config);
1196
+ this.canvas.requestRenderAll();
928
1197
  } else {
929
- layer.meta.pattern.config = config;
930
- }
931
- try {
932
- await this.renderLayer(layer);
933
- } catch (err) {
934
- if (firstEnable) delete layer.meta.pattern;
935
- throw err;
1198
+ this.attach(layer, config);
936
1199
  }
937
1200
  this.history.save();
938
- }).catch((error) => {
939
- this.events.emit("error", { message: "Failed to apply image pattern", error });
1201
+ } catch (error) {
1202
+ this.events.emit("error", { message: "Failed to apply pattern", error });
940
1203
  throw error;
941
- });
942
- }
943
- /**
944
- * Stretch every restored pattern layer back over the full print area and
945
- * re-freeze it, without re-rasterising: the baked bitmap keeps whatever size
946
- * it was saved at, so it is scaled (not re-tiled) to the current canvas. Used
947
- * after a state restore, where the canvas may be a different display size
948
- * than when the pattern was baked — and to repair states saved while a
949
- * pattern could still be dragged out of the print area.
950
- */
951
- repinAll() {
952
- const cw = this.canvas.getWidth();
953
- const ch = this.canvas.getHeight();
954
- let changed = false;
955
- for (const layer of this.layers.getAll()) {
956
- if (!layer.meta.pattern || layer.type !== "image") continue;
957
- const image = layer.fabricObject;
958
- image.set({
959
- left: 0,
960
- top: 0,
961
- angle: 0,
962
- scaleX: cw / (image.width || cw),
963
- scaleY: ch / (image.height || ch)
964
- });
965
- applyPatternLocks(image);
966
- image.setCoords();
967
- changed = true;
968
1204
  }
969
- if (changed) this.canvas.requestRenderAll();
970
1205
  }
971
- /** Restore the original image and drop the pattern. */
972
- disable(layerId) {
973
- return this.enqueue(layerId, async () => {
974
- const layer = this.layers.get(layerId);
975
- const state = layer?.meta.pattern;
976
- if (!layer || !state) return;
977
- const image = layer.fabricObject;
978
- await image.setSrc(state.originalSrc);
979
- image.set({
980
- left: state.original.left,
981
- top: state.original.top,
982
- scaleX: state.original.scaleX,
983
- scaleY: state.original.scaleY,
984
- width: state.original.width,
985
- height: state.original.height,
986
- cropX: state.original.cropX,
987
- cropY: state.original.cropY,
988
- angle: state.original.angle
989
- });
990
- image.clipPath = state.originalClip ? (await import_fabric2.util.enlivenObjects([state.originalClip]))[0] : void 0;
991
- restoreLocks(image, state.originalLocks);
992
- image.setCoords();
993
- delete layer.meta.pattern;
1206
+ /** Drop the tiling and show the source again. */
1207
+ async disable(layerId) {
1208
+ const layer = this.layers.get(layerId);
1209
+ if (!layer?.meta.pattern) return;
1210
+ try {
1211
+ this.detach(layerId);
1212
+ restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1213
+ layer.fabricObject.set({ opacity: layer.opacity });
1214
+ this.layers.setMeta(layerId, { pattern: void 0 });
994
1215
  this.canvas.requestRenderAll();
995
1216
  this.history.save();
996
- }).catch((error) => {
997
- this.events.emit("error", { message: "Failed to clear image pattern", error });
1217
+ } catch (error) {
1218
+ this.events.emit("error", { message: "Failed to clear pattern", error });
998
1219
  throw error;
999
- });
1220
+ }
1000
1221
  }
1001
- /** Run `task` after any in-flight work for this layer, regardless of outcome. */
1002
- enqueue(layerId, task) {
1003
- const prev = this.chains.get(layerId) ?? Promise.resolve();
1004
- const next = prev.then(task, task);
1005
- this.chains.set(
1006
- layerId,
1007
- next.catch(() => void 0)
1008
- );
1009
- return next;
1222
+ /**
1223
+ * Rebuild every proxy after a state restore, migrating any layer that was
1224
+ * saved by the old bake-into-the-layer engine.
1225
+ */
1226
+ async rehydrateAll() {
1227
+ this.clearProxies();
1228
+ for (const layer of this.layers.getAll()) {
1229
+ const state = layer.meta.pattern;
1230
+ if (!state) continue;
1231
+ try {
1232
+ if (isLegacyState(state)) {
1233
+ await unbakeLegacyLayer(layer, state);
1234
+ this.layers.setMeta(layer.id, { pattern: { config: state.config } });
1235
+ }
1236
+ this.attach(layer, state.config);
1237
+ } catch (error) {
1238
+ this.events.emit("error", { message: "Failed to restore pattern layer", error });
1239
+ }
1240
+ }
1241
+ this.canvas.requestRenderAll();
1010
1242
  }
1011
- async renderLayer(layer) {
1243
+ /** Re-fit every proxy to the print area (canvas resize). */
1244
+ syncArea() {
1245
+ const width = this.canvas.getWidth();
1246
+ const height = this.canvas.getHeight();
1247
+ for (const proxy of this.proxies.values()) {
1248
+ proxy.setArea(width, height);
1249
+ proxy.invalidate();
1250
+ }
1251
+ if (this.proxies.size > 0) this.canvas.requestRenderAll();
1252
+ }
1253
+ /** Drop a layer's cached source snapshot (its content changed). */
1254
+ invalidate(layerId) {
1255
+ const proxy = this.proxies.get(layerId);
1256
+ if (!proxy) return;
1257
+ proxy.invalidate();
1258
+ this.canvas.requestRenderAll();
1259
+ }
1260
+ /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
1261
+ attachTo(layer) {
1012
1262
  const state = layer.meta.pattern;
1013
- if (!state) return;
1014
- const image = layer.fabricObject;
1015
- const cw = this.canvas.getWidth();
1016
- const ch = this.canvas.getHeight();
1017
- const scale = Math.max(1, state.config.scale ?? 100) / 100;
1018
- const diag = Math.sqrt(cw * cw + ch * ch);
1019
- const minTile = Math.max(2, diag / MAX_TILES_PER_AXIS);
1020
- const tileW = Math.max(minTile, state.original.width * state.original.scaleX * scale);
1021
- const tileH = Math.max(minTile, state.original.height * state.original.scaleY * scale);
1022
- const dataUrl = await buildPatternDataURL(
1023
- state.originalSrc,
1024
- state.config,
1025
- cw,
1026
- ch,
1027
- tileW,
1028
- tileH,
1029
- this.sourceResolver
1263
+ if (!state || this.proxies.has(layer.id)) return;
1264
+ this.attach(layer, state.config);
1265
+ }
1266
+ dispose() {
1267
+ this.canvas.off("object:modified", this.onSourceModified);
1268
+ this.canvas.off("text:changed", this.onSourceModified);
1269
+ this.events.off("layer:removed", this.onLayerRemoved);
1270
+ this.clearProxies();
1271
+ }
1272
+ /** Release every proxy and the offscreen snapshot it holds. */
1273
+ clearProxies() {
1274
+ for (const proxy of this.proxies.values()) proxy.dispose();
1275
+ this.proxies.clear();
1276
+ }
1277
+ attach(layer, config) {
1278
+ const proxy = new TiledPatternObject(
1279
+ layer.fabricObject,
1280
+ config,
1281
+ this.canvas.getWidth(),
1282
+ this.canvas.getHeight()
1030
1283
  );
1031
- await image.setSrc(dataUrl);
1032
- image.set({
1033
- left: 0,
1034
- top: 0,
1035
- scaleX: 1,
1036
- scaleY: 1,
1037
- width: cw,
1038
- height: ch,
1039
- cropX: 0,
1040
- cropY: 0,
1041
- angle: 0
1042
- });
1043
- image.clipPath = void 0;
1044
- applyPatternLocks(image);
1045
- image.setCoords();
1284
+ layer.fabricObject.set({ opacity: 0 });
1285
+ this.proxies.set(layer.id, proxy);
1286
+ this.layers.setRenderProxy(layer.id, proxy);
1046
1287
  this.canvas.requestRenderAll();
1047
1288
  }
1289
+ detach(layerId) {
1290
+ const proxy = this.proxies.get(layerId);
1291
+ if (!proxy) {
1292
+ this.layers.setRenderProxy(layerId, null);
1293
+ return;
1294
+ }
1295
+ this.layers.setRenderProxy(layerId, null);
1296
+ proxy.dispose();
1297
+ this.proxies.delete(layerId);
1298
+ }
1299
+ invalidateFor(target) {
1300
+ if (!target) return;
1301
+ const layer = this.layers.findByObject(target);
1302
+ if (layer) this.invalidate(layer.id);
1303
+ }
1048
1304
  };
1049
- function captureLocks(obj) {
1050
- return {
1051
- lockMovementX: obj.lockMovementX ?? false,
1052
- lockMovementY: obj.lockMovementY ?? false,
1053
- lockScalingX: obj.lockScalingX ?? false,
1054
- lockScalingY: obj.lockScalingY ?? false,
1055
- lockRotation: obj.lockRotation ?? false,
1056
- hasControls: obj.hasControls ?? true
1057
- };
1058
- }
1059
- function applyPatternLocks(obj) {
1060
- obj.set({
1061
- lockMovementX: true,
1062
- lockMovementY: true,
1063
- lockScalingX: true,
1064
- lockScalingY: true,
1065
- lockRotation: true,
1066
- hasControls: false
1305
+ function isLegacyState(state) {
1306
+ return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
1307
+ }
1308
+ async function unbakeLegacyLayer(layer, state) {
1309
+ const image = layer.fabricObject;
1310
+ if (typeof image.setSrc !== "function" || !state.original) return;
1311
+ await image.setSrc(state.originalSrc);
1312
+ image.set({
1313
+ left: state.original.left,
1314
+ top: state.original.top,
1315
+ scaleX: state.original.scaleX,
1316
+ scaleY: state.original.scaleY,
1317
+ width: state.original.width,
1318
+ height: state.original.height,
1319
+ cropX: state.original.cropX,
1320
+ cropY: state.original.cropY,
1321
+ angle: state.original.angle
1067
1322
  });
1323
+ image.clipPath = state.originalClip ? (await import_fabric3.util.enlivenObjects([state.originalClip]))[0] : void 0;
1324
+ restoreLocks(image, state.originalLocks);
1325
+ image.setCoords();
1068
1326
  }
1069
1327
  function restoreLocks(obj, locks) {
1070
1328
  obj.set(
@@ -1078,124 +1336,9 @@ function restoreLocks(obj, locks) {
1078
1336
  }
1079
1337
  );
1080
1338
  }
1081
- function elementToDataURL(image) {
1082
- try {
1083
- const el = image.getElement();
1084
- const w = el.naturalWidth || el.width;
1085
- const h = el.naturalHeight || el.height;
1086
- if (!w || !h) return null;
1087
- const off = document.createElement("canvas");
1088
- off.width = w;
1089
- off.height = h;
1090
- const ctx = off.getContext("2d");
1091
- if (!ctx) return null;
1092
- ctx.drawImage(el, 0, 0);
1093
- return off.toDataURL("image/png");
1094
- } catch {
1095
- return null;
1096
- }
1097
- }
1098
- var IMAGE_CACHE_MAX = 16;
1099
- var imageCache = /* @__PURE__ */ new Map();
1100
- function loadPatternImage(src, resolver) {
1101
- const cached = imageCache.get(src);
1102
- if (cached) {
1103
- imageCache.delete(src);
1104
- imageCache.set(src, cached);
1105
- return cached;
1106
- }
1107
- const promise = decodeImage(src).catch(async (originalError) => {
1108
- if (!resolver) throw originalError;
1109
- const resolved = await resolver(src);
1110
- if (!resolved || resolved === src) {
1111
- throw new Error("Pattern source resolver did not return a usable alternate URL", {
1112
- cause: originalError
1113
- });
1114
- }
1115
- return decodeImage(resolved);
1116
- });
1117
- promise.catch(() => {
1118
- if (imageCache.get(src) === promise) imageCache.delete(src);
1119
- });
1120
- imageCache.set(src, promise);
1121
- if (imageCache.size > IMAGE_CACHE_MAX) {
1122
- const oldest = imageCache.keys().next().value;
1123
- if (oldest !== void 0) imageCache.delete(oldest);
1124
- }
1125
- return promise;
1126
- }
1127
- function decodeImage(src) {
1128
- return new Promise((resolve, reject) => {
1129
- const img = new Image();
1130
- img.crossOrigin = "anonymous";
1131
- img.onload = () => resolve(img);
1132
- img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
1133
- img.src = src;
1134
- });
1135
- }
1136
- function clearPatternImageCache() {
1137
- imageCache.clear();
1138
- }
1139
- async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
1140
- const img = await loadPatternImage(src, sourceResolver);
1141
- const off = document.createElement("canvas");
1142
- off.width = Math.max(1, Math.round(targetW));
1143
- off.height = Math.max(1, Math.round(targetH));
1144
- const ctx = off.getContext("2d");
1145
- if (!ctx) return off.toDataURL("image/png");
1146
- drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
1147
- return off.toDataURL("image/png");
1148
- }
1149
- function computeTilePositions(config, targetW, targetH, baseW, baseH) {
1150
- const diag = Math.sqrt(targetW * targetW + targetH * targetH);
1151
- const minTile = Math.max(1, diag / MAX_TILES_PER_AXIS);
1152
- const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
1153
- const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
1154
- const cols = Math.ceil(diag / tileW) + 2;
1155
- const rows = Math.ceil(diag / tileH) + 2;
1156
- const halfCols = Math.ceil(cols / 2);
1157
- const halfRows = Math.ceil(rows / 2);
1158
- const shiftX = tileW * (clampOffset(config.offsetX) / 100);
1159
- const shiftY = tileH * (clampOffset(config.offsetY) / 100);
1160
- const placements = [];
1161
- for (let j = -halfRows; j <= halfRows; j++) {
1162
- for (let i = -halfCols; i <= halfCols; i++) {
1163
- let x = i * tileW + shiftX;
1164
- let y = j * tileH + shiftY;
1165
- if (config.mode === "brick-horizontal" && mod2(j) === 1) {
1166
- x += tileW * (config.horizontalOffset / 100);
1167
- } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
1168
- y += tileH * (config.horizontalOffset / 100);
1169
- }
1170
- const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
1171
- placements.push({ x, y, rotation });
1172
- }
1173
- }
1174
- return placements;
1175
- }
1176
- function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
1177
- ctx.save();
1178
- ctx.translate(targetW / 2, targetH / 2);
1179
- ctx.rotate(config.angle * Math.PI / 180);
1180
- for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
1181
- ctx.save();
1182
- ctx.translate(tile.x, tile.y);
1183
- ctx.rotate(tile.rotation);
1184
- ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
1185
- ctx.restore();
1186
- }
1187
- ctx.restore();
1188
- }
1189
- function clampOffset(value) {
1190
- if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1191
- return Math.max(-100, Math.min(100, value));
1192
- }
1193
- function mod2(n) {
1194
- return (n % 2 + 2) % 2;
1195
- }
1196
1339
 
1197
1340
  // src/text-curve.ts
1198
- var import_fabric3 = require("fabric");
1341
+ var import_fabric4 = require("fabric");
1199
1342
  var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1200
1343
  var MIN_ARC = 0.5;
1201
1344
  var FULL_CIRCLE_ARC = 99.5;
@@ -1318,7 +1461,7 @@ var TextCurveManager = class {
1318
1461
  if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1319
1462
  text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1320
1463
  text.set({
1321
- path: new import_fabric3.Path(curve.data, { visible: false, objectCaching: false }),
1464
+ path: new import_fabric4.Path(curve.data, { visible: false, objectCaching: false }),
1322
1465
  pathAlign: "center",
1323
1466
  pathSide: "left",
1324
1467
  pathStartOffset: Math.max(0, (curve.length - run) / 2)
@@ -1360,7 +1503,7 @@ var TextCurveManager = class {
1360
1503
  };
1361
1504
 
1362
1505
  // src/mask-presets/manager.ts
1363
- var import_fabric4 = require("fabric");
1506
+ var import_fabric5 = require("fabric");
1364
1507
 
1365
1508
  // src/mask-presets/shapes.ts
1366
1509
  var SHAPE_MASK_IDS = [
@@ -1627,13 +1770,13 @@ var MaskPresetManager = class {
1627
1770
  objectCaching: false
1628
1771
  };
1629
1772
  if (isShapeMaskId(id)) {
1630
- return new import_fabric4.Path(shapeMaskPathData(id), {
1773
+ return new import_fabric5.Path(shapeMaskPathData(id), {
1631
1774
  ...shared,
1632
1775
  scaleX: width / SHAPE_MASK_BOX,
1633
1776
  scaleY: height / SHAPE_MASK_BOX
1634
1777
  });
1635
1778
  }
1636
- return new import_fabric4.FabricImage(renderTextureMask(id), {
1779
+ return new import_fabric5.FabricImage(renderTextureMask(id), {
1637
1780
  ...shared,
1638
1781
  scaleX: width / TEXTURE_MASK_SIZE,
1639
1782
  scaleY: height / TEXTURE_MASK_SIZE
@@ -1642,7 +1785,7 @@ var MaskPresetManager = class {
1642
1785
  };
1643
1786
 
1644
1787
  // src/shadow.ts
1645
- var import_fabric5 = require("fabric");
1788
+ var import_fabric6 = require("fabric");
1646
1789
 
1647
1790
  // src/utils/color.ts
1648
1791
  var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
@@ -1680,7 +1823,7 @@ function applyLayerShadow(object, config) {
1680
1823
  }
1681
1824
  const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
1682
1825
  object.set({
1683
- shadow: new import_fabric5.Shadow({
1826
+ shadow: new import_fabric6.Shadow({
1684
1827
  color,
1685
1828
  blur: Math.max(0, next.blur),
1686
1829
  offsetX: next.offsetX,
@@ -1689,6 +1832,43 @@ function applyLayerShadow(object, config) {
1689
1832
  });
1690
1833
  }
1691
1834
 
1835
+ // src/selection-style.ts
1836
+ var DEFAULT_SELECTION_STYLE = {
1837
+ borderColor: "#c9a96e",
1838
+ cornerColor: "#c9a96e",
1839
+ cornerStrokeColor: "#101010",
1840
+ cornerSize: 11,
1841
+ cornerStyle: "circle",
1842
+ borderWidth: 1,
1843
+ borderDashArray: null,
1844
+ padding: 0,
1845
+ marqueeFill: "rgba(201, 169, 110, 0.12)"
1846
+ };
1847
+ function applyObjectSelectionStyle(object, style, zoom) {
1848
+ const scale = zoom > 0 ? 1 / zoom : 1;
1849
+ object.set({
1850
+ cornerSize: style.cornerSize * scale,
1851
+ cornerStyle: style.cornerStyle,
1852
+ cornerColor: style.cornerColor,
1853
+ cornerStrokeColor: style.cornerStrokeColor,
1854
+ // A filled handle is what makes the stroke colour visible at all.
1855
+ transparentCorners: false,
1856
+ borderColor: style.borderColor,
1857
+ borderScaleFactor: style.borderWidth * scale,
1858
+ borderDashArray: style.borderDashArray?.map((segment) => segment * scale) ?? null,
1859
+ padding: style.padding * scale
1860
+ });
1861
+ }
1862
+ function applySelectionStyle(canvas, style, zoom) {
1863
+ for (const object of canvas.getObjects()) applyObjectSelectionStyle(object, style, zoom);
1864
+ const active = canvas.getActiveObject();
1865
+ if (active) applyObjectSelectionStyle(active, style, zoom);
1866
+ canvas.selectionColor = style.marqueeFill;
1867
+ canvas.selectionBorderColor = style.borderColor;
1868
+ canvas.selectionLineWidth = style.borderWidth * (zoom > 0 ? 1 / zoom : 1);
1869
+ canvas.requestRenderAll();
1870
+ }
1871
+
1692
1872
  // src/transform.ts
1693
1873
  var SIDE_CONTROLS = ["ml", "mr", "mt", "mb"];
1694
1874
  function applyAspectLock(object, locked) {
@@ -1757,7 +1937,7 @@ var UnitConverter = class {
1757
1937
  };
1758
1938
 
1759
1939
  // src/serialization.ts
1760
- var import_fabric6 = require("fabric");
1940
+ var import_fabric7 = require("fabric");
1761
1941
  var VERSION = "2.0.0";
1762
1942
  function serializeEditor(editor) {
1763
1943
  return {
@@ -1793,7 +1973,7 @@ async function deserializeEditor(editor, state) {
1793
1973
  }
1794
1974
  const staged = await Promise.all(
1795
1975
  state.layers.map(async (serialized) => {
1796
- const fabricObject = (await import_fabric6.util.enlivenObjects([serialized.fabricObject]))[0];
1976
+ const fabricObject = (await import_fabric7.util.enlivenObjects([serialized.fabricObject]))[0];
1797
1977
  if (!fabricObject) {
1798
1978
  const source = serialized.fabricObject.src;
1799
1979
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -1804,7 +1984,7 @@ async function deserializeEditor(editor, state) {
1804
1984
  return { serialized, fabricObject };
1805
1985
  })
1806
1986
  );
1807
- const stagedBackground = state.backgroundImage ? (await import_fabric6.util.enlivenObjects([state.backgroundImage]))[0] : null;
1987
+ const stagedBackground = state.backgroundImage ? (await import_fabric7.util.enlivenObjects([state.backgroundImage]))[0] : null;
1808
1988
  if (state.backgroundImage && !stagedBackground) {
1809
1989
  const source = state.backgroundImage.src;
1810
1990
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -1851,7 +2031,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1851
2031
  }
1852
2032
 
1853
2033
  // src/export.ts
1854
- var import_fabric7 = require("fabric");
2034
+ var import_fabric8 = require("fabric");
1855
2035
 
1856
2036
  // src/displacement.ts
1857
2037
  var CHANNEL_INDEX = {
@@ -1945,7 +2125,7 @@ async function exportPNG(canvas, options = {}) {
1945
2125
  }
1946
2126
  async function exportIsolatedPNG(source, objects, options = {}) {
1947
2127
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
1948
- const canvas = new import_fabric7.StaticCanvas(element, {
2128
+ const canvas = new import_fabric8.StaticCanvas(element, {
1949
2129
  width: options.width ?? source.getWidth(),
1950
2130
  height: options.height ?? source.getHeight(),
1951
2131
  backgroundColor: options.backgroundColor || void 0
@@ -1975,7 +2155,7 @@ async function exportPrintArea(source, area, options = {}) {
1975
2155
  throw new Error("Print area does not overlap the canvas");
1976
2156
  }
1977
2157
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
1978
- const canvas = new import_fabric7.StaticCanvas(element, { width, height });
2158
+ const canvas = new import_fabric8.StaticCanvas(element, { width, height });
1979
2159
  try {
1980
2160
  const clones = await Promise.all(source.getObjects().map((object) => object.clone()));
1981
2161
  if (clones.length) canvas.add(...clones);
@@ -2415,7 +2595,7 @@ var ProjectManager = class {
2415
2595
  };
2416
2596
 
2417
2597
  // src/mask.ts
2418
- var import_fabric8 = require("fabric");
2598
+ var import_fabric9 = require("fabric");
2419
2599
  var MaskRefinementError = class extends Error {
2420
2600
  constructor(code, message, cause) {
2421
2601
  super(message);
@@ -2447,7 +2627,7 @@ var MaskController = class {
2447
2627
  throw new Error("Mask dimensions must be positive integers");
2448
2628
  }
2449
2629
  const backing = this.makeCanvas(width, height);
2450
- const image = new import_fabric8.FabricImage(backing, {
2630
+ const image = new import_fabric9.FabricImage(backing, {
2451
2631
  left: 0,
2452
2632
  top: 0,
2453
2633
  originX: "left",
@@ -2696,6 +2876,7 @@ var CanvasEditor = class {
2696
2876
  fileAdapter;
2697
2877
  imageProvider;
2698
2878
  zoomLevel = 1;
2879
+ selectionStyle = { ...DEFAULT_SELECTION_STYLE };
2699
2880
  mockup = null;
2700
2881
  // The design's configured background. The live canvas background is forced
2701
2882
  // transparent while a mockup preview is shown, so this is the source of truth
@@ -2712,7 +2893,7 @@ var CanvasEditor = class {
2712
2893
  const widthPx = this.units.toPixels(config.width);
2713
2894
  const heightPx = this.units.toPixels(config.height);
2714
2895
  this.designBackground = config.backgroundColor ?? "#ffffff";
2715
- this.canvas = new import_fabric9.Canvas(canvasElement, {
2896
+ this.canvas = new import_fabric10.Canvas(canvasElement, {
2716
2897
  width: widthPx,
2717
2898
  height: heightPx,
2718
2899
  backgroundColor: this.designBackground,
@@ -2733,16 +2914,11 @@ var CanvasEditor = class {
2733
2914
  this.layers.setHistoryCallback(() => this.history.save());
2734
2915
  this.snapping = new SnapManager(this.canvas, this.events);
2735
2916
  this.crop = new CropController(this.canvas, this.history, this.events);
2736
- this.patterns = new PatternManager(
2737
- this.canvas,
2738
- this.layers,
2739
- this.history,
2740
- this.events,
2741
- config.patternSourceResolver
2742
- );
2917
+ this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2743
2918
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2744
2919
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
2745
2920
  this.setupCanvasEvents();
2921
+ this.refreshSelectionStyle();
2746
2922
  this.history.saveImmediate();
2747
2923
  this.pages = new ProjectManager(this);
2748
2924
  this.masks = new MaskController(this);
@@ -2750,7 +2926,7 @@ var CanvasEditor = class {
2750
2926
  // ─── Layer Operations ────────────────────────────────
2751
2927
  async addImage(url, options) {
2752
2928
  try {
2753
- const img = await import_fabric9.FabricImage.fromURL(
2929
+ const img = await import_fabric10.FabricImage.fromURL(
2754
2930
  url,
2755
2931
  {},
2756
2932
  { originX: "left", originY: "top", ...options }
@@ -2775,7 +2951,7 @@ var CanvasEditor = class {
2775
2951
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
2776
2952
  const previous = layer.fabricObject;
2777
2953
  try {
2778
- const replacement = await import_fabric9.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2954
+ const replacement = await import_fabric10.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2779
2955
  replacement.set({
2780
2956
  left: previous.left,
2781
2957
  top: previous.top,
@@ -2804,7 +2980,7 @@ var CanvasEditor = class {
2804
2980
  }
2805
2981
  }
2806
2982
  addText(text, options) {
2807
- const textbox = new import_fabric9.Textbox(text, {
2983
+ const textbox = new import_fabric10.Textbox(text, {
2808
2984
  fontSize: 32,
2809
2985
  fontFamily: "Arial",
2810
2986
  fill: "#000000",
@@ -2843,10 +3019,10 @@ var CanvasEditor = class {
2843
3019
  return value === void 0 ? token : escapeXml(value);
2844
3020
  })
2845
3021
  );
2846
- const { objects, options } = await (0, import_fabric9.loadSVGFromString)(resolved);
3022
+ const { objects, options } = await (0, import_fabric10.loadSVGFromString)(resolved);
2847
3023
  const validObjects = objects.filter((object) => object !== null);
2848
3024
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
2849
- const group = import_fabric9.util.groupSVGElements(validObjects, options);
3025
+ const group = import_fabric10.util.groupSVGElements(validObjects, options);
2850
3026
  group.set({
2851
3027
  left: this.canvas.getWidth() / 2,
2852
3028
  top: this.canvas.getHeight() / 2,
@@ -2891,9 +3067,7 @@ var CanvasEditor = class {
2891
3067
  const layer = this.layers.get(id);
2892
3068
  if (!layer) return null;
2893
3069
  const clone = await layer.fabricObject.clone();
2894
- if (!layer.meta.pattern) {
2895
- clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2896
- }
3070
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2897
3071
  clone.setCoords();
2898
3072
  const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
2899
3073
  copy.meta = structuredClone(layer.meta);
@@ -2906,6 +3080,7 @@ var CanvasEditor = class {
2906
3080
  evented: !layer.locked,
2907
3081
  opacity: layer.opacity
2908
3082
  });
3083
+ this.patterns.attachTo(copy);
2909
3084
  this.canvas.setActiveObject(clone);
2910
3085
  this.canvas.requestRenderAll();
2911
3086
  this.history.save();
@@ -2919,10 +3094,10 @@ var CanvasEditor = class {
2919
3094
  const next = { ...previous, ...adjustments };
2920
3095
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
2921
3096
  image.filters = [
2922
- new import_fabric9.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
2923
- new import_fabric9.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
2924
- new import_fabric9.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
2925
- new import_fabric9.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
3097
+ new import_fabric10.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
3098
+ new import_fabric10.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
3099
+ new import_fabric10.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
3100
+ new import_fabric10.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
2926
3101
  ];
2927
3102
  layer.meta.imageAdjustments = next;
2928
3103
  image.applyFilters();
@@ -2939,7 +3114,7 @@ var CanvasEditor = class {
2939
3114
  const childData = children.map((layer) => structuredClone(layer.toData()));
2940
3115
  const objects = children.map((layer) => layer.fabricObject);
2941
3116
  for (const layer of children) this.layers.remove(layer.id);
2942
- const group = new import_fabric9.Group(objects);
3117
+ const group = new import_fabric10.Group(objects);
2943
3118
  const grouped = this.layers.add("group", group, name);
2944
3119
  grouped.meta.groupChildren = childData;
2945
3120
  this.layers.select(grouped.id);
@@ -2958,7 +3133,7 @@ var CanvasEditor = class {
2958
3133
  const objects = group.removeAll();
2959
3134
  this.layers.remove(id);
2960
3135
  const restored = objects.map((object, index) => {
2961
- import_fabric9.util.addTransformToObject(object, transform);
3136
+ import_fabric10.util.addTransformToObject(object, transform);
2962
3137
  object.setCoords();
2963
3138
  const data = childData[index];
2964
3139
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -2985,7 +3160,7 @@ var CanvasEditor = class {
2985
3160
  }
2986
3161
  try {
2987
3162
  await deserializeEditor(this, state);
2988
- this.patterns.repinAll();
3163
+ await this.patterns.rehydrateAll();
2989
3164
  } catch (error) {
2990
3165
  if (!managedByHistory) {
2991
3166
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -3014,7 +3189,7 @@ var CanvasEditor = class {
3014
3189
  const layer = this.layers.get(id);
3015
3190
  if (!layer) throw new Error(`Layer not found: ${id}`);
3016
3191
  try {
3017
- if (options.resolution === "source" && layer.fabricObject instanceof import_fabric9.FabricImage) {
3192
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric10.FabricImage) {
3018
3193
  const image = await layer.fabricObject.clone();
3019
3194
  image.set({
3020
3195
  left: 0,
@@ -3034,7 +3209,11 @@ var CanvasEditor = class {
3034
3209
  cloneObjects: false
3035
3210
  });
3036
3211
  }
3037
- return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
3212
+ return await exportIsolatedPNG(
3213
+ this.canvas,
3214
+ [layer.renderProxy ?? layer.fabricObject],
3215
+ options
3216
+ );
3038
3217
  } catch (error) {
3039
3218
  this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
3040
3219
  throw error;
@@ -3286,7 +3465,7 @@ var CanvasEditor = class {
3286
3465
  return;
3287
3466
  }
3288
3467
  try {
3289
- const image = await import_fabric9.FabricImage.fromURL(
3468
+ const image = await import_fabric10.FabricImage.fromURL(
3290
3469
  url,
3291
3470
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
3292
3471
  { originX: "left", originY: "top" }
@@ -3339,7 +3518,6 @@ var CanvasEditor = class {
3339
3518
  const sx = widthPx / oldWidth;
3340
3519
  const sy = heightPx / oldHeight;
3341
3520
  for (const layer of this.layers.getAll()) {
3342
- if (layer.meta.pattern) continue;
3343
3521
  const object = layer.fabricObject;
3344
3522
  object.set({
3345
3523
  left: (object.left ?? 0) * sx,
@@ -3360,7 +3538,7 @@ var CanvasEditor = class {
3360
3538
  }
3361
3539
  }
3362
3540
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
3363
- this.patterns.repinAll();
3541
+ this.patterns.syncArea();
3364
3542
  this.canvas.requestRenderAll();
3365
3543
  this.history.save();
3366
3544
  this.events.emit("canvas:modified", {});
@@ -3413,6 +3591,7 @@ var CanvasEditor = class {
3413
3591
  const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
3414
3592
  if (next === this.zoomLevel) return;
3415
3593
  this.zoomLevel = next;
3594
+ this.refreshSelectionStyle();
3416
3595
  this.events.emit("zoom:changed", { zoom: next });
3417
3596
  }
3418
3597
  /** @deprecated use setZoom — kept for backward compatibility. */
@@ -3557,12 +3736,35 @@ var CanvasEditor = class {
3557
3736
  this.snapping.dispose();
3558
3737
  this.crop.dispose();
3559
3738
  this.history.dispose();
3560
- clearPatternImageCache();
3739
+ this.patterns.dispose();
3561
3740
  this.events.removeAllListeners();
3562
3741
  this.canvas.dispose();
3563
3742
  }
3743
+ // ─── Selection style ────────────────────────────────
3744
+ /** Current look of the selection frame and its handles. */
3745
+ getSelectionStyle() {
3746
+ return { ...this.selectionStyle };
3747
+ }
3748
+ /**
3749
+ * Restyles the selection frame and resize handles. Sizes are in screen
3750
+ * pixels and stay constant across zoom levels (see `SelectionStyle`).
3751
+ */
3752
+ setSelectionStyle(style) {
3753
+ this.selectionStyle = { ...this.selectionStyle, ...style };
3754
+ this.refreshSelectionStyle();
3755
+ }
3756
+ refreshSelectionStyle() {
3757
+ applySelectionStyle(this.canvas, this.selectionStyle, this.zoomLevel);
3758
+ }
3564
3759
  // ─── Private ────────────────────────────────────────
3565
3760
  setupCanvasEvents() {
3761
+ this.canvas.on("object:added", (e) => {
3762
+ if (e.target) applyObjectSelectionStyle(e.target, this.selectionStyle, this.zoomLevel);
3763
+ });
3764
+ const styleActive = () => {
3765
+ const active = this.canvas.getActiveObject();
3766
+ if (active) applyObjectSelectionStyle(active, this.selectionStyle, this.zoomLevel);
3767
+ };
3566
3768
  this.canvas.on("object:modified", (e) => {
3567
3769
  if (!e.target) return;
3568
3770
  const layer = this.layers.findByObject(e.target);
@@ -3572,10 +3774,12 @@ var CanvasEditor = class {
3572
3774
  }
3573
3775
  });
3574
3776
  this.canvas.on("selection:created", (e) => {
3777
+ styleActive();
3575
3778
  const selected = (e.selected ?? []).map((obj) => this.layers.findByObject(obj)?.id).filter((id) => id !== void 0);
3576
3779
  this.events.emit("selection:changed", { selected });
3577
3780
  });
3578
3781
  this.canvas.on("selection:updated", (e) => {
3782
+ styleActive();
3579
3783
  const selected = (e.selected ?? []).map((obj) => this.layers.findByObject(obj)?.id).filter((id) => id !== void 0);
3580
3784
  this.events.emit("selection:changed", { selected });
3581
3785
  });
@@ -3657,6 +3861,7 @@ var AnnotationOverlay = class {
3657
3861
  CropController,
3658
3862
  DEFAULT_LAYER_SHADOW,
3659
3863
  DEFAULT_PATTERN_CONFIG,
3864
+ DEFAULT_SELECTION_STYLE,
3660
3865
  DEFAULT_TEXT_CURVE,
3661
3866
  EventEmitter,
3662
3867
  FontRegistry,
@@ -3675,15 +3880,14 @@ var AnnotationOverlay = class {
3675
3880
  TEXTURE_MASK_IDS,
3676
3881
  TEXTURE_MASK_SIZE,
3677
3882
  TextCurveManager,
3883
+ TiledPatternObject,
3678
3884
  UnitConverter,
3679
3885
  applyAspectLock,
3680
3886
  applyLayerShadow,
3681
- applyPatternLocks,
3887
+ applyObjectSelectionStyle,
3888
+ applySelectionStyle,
3682
3889
  buildCurvePathData,
3683
- buildPatternDataURL,
3684
- captureLocks,
3685
3890
  clamp,
3686
- clearPatternImageCache,
3687
3891
  clearTextureMaskCache,
3688
3892
  computeCoverPlacement,
3689
3893
  computePrintAreaClip,
@@ -3702,7 +3906,6 @@ var AnnotationOverlay = class {
3702
3906
  isMaskPresetId,
3703
3907
  isShapeMaskId,
3704
3908
  isTextureMaskId,
3705
- loadPatternImage,
3706
3909
  readLayerShadow,
3707
3910
  renderTextureMask,
3708
3911
  resetTransform,