@overtone-art/canvas-editor-core 0.2.6 → 0.2.8

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
@@ -1,5 +1,16 @@
1
+ import {
2
+ computeCoverPlacement,
3
+ computePrintAreaClip,
4
+ displaceRgba,
5
+ exportDataURL,
6
+ exportIsolatedPNG,
7
+ exportMockup,
8
+ exportPNG,
9
+ exportSVG
10
+ } from "./chunk-ORZZ6MGQ.mjs";
11
+
1
12
  // src/editor.ts
2
- import { Canvas, FabricImage as FabricImage2, Textbox } from "fabric";
13
+ import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
3
14
 
4
15
  // src/events.ts
5
16
  var EventEmitter = class {
@@ -91,6 +102,10 @@ var LayerManager = class {
91
102
  canvas;
92
103
  events;
93
104
  layers = [];
105
+ onPropertyChanged;
106
+ setHistoryCallback(callback) {
107
+ this.onPropertyChanged = callback;
108
+ }
94
109
  add(type, fabricObject, name, id) {
95
110
  const layer = new Layer(type, fabricObject, name, id);
96
111
  this.layers.push(layer);
@@ -107,12 +122,39 @@ var LayerManager = class {
107
122
  this.layers.splice(index, 1);
108
123
  this.events.emit("layer:removed", { layerId: id });
109
124
  this.emitChanged();
125
+ this.onPropertyChanged?.();
126
+ return true;
127
+ }
128
+ /** Replace a layer's render object while preserving its immutable ID and panel state. */
129
+ replaceObject(id, fabricObject) {
130
+ const layer = this.get(id);
131
+ if (!layer || layer.fabricObject === fabricObject) return false;
132
+ const previous = layer.fabricObject;
133
+ const stackIndex = this.canvas.getObjects().indexOf(previous);
134
+ const wasActive = this.canvas.getActiveObject() === previous;
135
+ this.canvas.remove(previous);
136
+ layer.fabricObject = fabricObject;
137
+ fabricObject._layerId = id;
138
+ fabricObject.set({
139
+ visible: layer.visible,
140
+ opacity: layer.opacity,
141
+ selectable: !layer.locked,
142
+ evented: !layer.locked
143
+ });
144
+ this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
145
+ if (wasActive) this.canvas.setActiveObject(fabricObject);
146
+ this.canvas.requestRenderAll();
147
+ this.events.emit("layer:modified", { layerId: id });
148
+ this.emitChanged();
149
+ this.onPropertyChanged?.();
110
150
  return true;
111
151
  }
112
152
  reorder(id, newIndex) {
113
153
  const oldIndex = this.layers.findIndex((l) => l.id === id);
114
154
  if (oldIndex === -1) return false;
115
- const clamped = Math.max(0, Math.min(this.layers.length - 1, newIndex));
155
+ if (!Number.isFinite(newIndex)) return false;
156
+ const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
157
+ if (oldIndex === clamped) return false;
116
158
  const [layer] = this.layers.splice(oldIndex, 1);
117
159
  this.layers.splice(clamped, 0, layer);
118
160
  this.layers.forEach((l, i) => {
@@ -120,6 +162,7 @@ var LayerManager = class {
120
162
  });
121
163
  this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
122
164
  this.emitChanged();
165
+ this.onPropertyChanged?.();
123
166
  return true;
124
167
  }
125
168
  select(id) {
@@ -150,33 +193,42 @@ var LayerManager = class {
150
193
  setVisibility(id, visible) {
151
194
  const layer = this.get(id);
152
195
  if (!layer) return;
196
+ if (layer.visible === visible) return;
153
197
  layer.visible = visible;
154
198
  layer.fabricObject.visible = visible;
155
199
  this.canvas.requestRenderAll();
156
200
  this.emitChanged();
201
+ this.onPropertyChanged?.();
157
202
  }
158
203
  setLocked(id, locked) {
159
204
  const layer = this.get(id);
160
205
  if (!layer) return;
206
+ if (layer.locked === locked) return;
161
207
  layer.locked = locked;
162
208
  layer.fabricObject.selectable = !locked;
163
209
  layer.fabricObject.evented = !locked;
164
210
  this.canvas.requestRenderAll();
165
211
  this.emitChanged();
212
+ this.onPropertyChanged?.();
166
213
  }
167
214
  setOpacity(id, opacity) {
168
215
  const layer = this.get(id);
169
- if (!layer) return;
170
- layer.opacity = opacity;
171
- layer.fabricObject.opacity = opacity;
216
+ if (!layer || !Number.isFinite(opacity)) return;
217
+ const next = Math.max(0, Math.min(1, opacity));
218
+ if (layer.opacity === next) return;
219
+ layer.opacity = next;
220
+ layer.fabricObject.opacity = next;
172
221
  this.canvas.requestRenderAll();
173
222
  this.emitChanged();
223
+ this.onPropertyChanged?.();
174
224
  }
175
225
  setName(id, name) {
176
226
  const layer = this.get(id);
177
227
  if (!layer) return;
228
+ if (layer.name === name) return;
178
229
  layer.name = name;
179
230
  this.emitChanged();
231
+ this.onPropertyChanged?.();
180
232
  }
181
233
  clear() {
182
234
  for (const layer of this.layers) {
@@ -196,61 +248,133 @@ var LayerManager = class {
196
248
  };
197
249
 
198
250
  // src/history.ts
199
- var HistoryManager = class {
251
+ var HistoryManager = class _HistoryManager {
252
+ static ASSET_KEY = "__canvasEditorHistoryAsset";
200
253
  undoStack = [];
201
254
  redoStack = [];
255
+ assets = /* @__PURE__ */ new Map();
256
+ assetIds = /* @__PURE__ */ new Map();
257
+ nextAssetId = 1;
202
258
  maxSize;
259
+ maxBytes;
203
260
  paused = false;
204
261
  debounceTimer = null;
205
262
  debounceMs;
206
263
  getState;
207
264
  restoreState;
208
265
  events;
266
+ transactionDepth = 0;
267
+ transactionDirty = false;
209
268
  constructor(opts) {
210
269
  this.getState = opts.getState;
211
270
  this.restoreState = opts.restoreState;
212
271
  this.events = opts.events;
213
272
  this.maxSize = opts.maxSize ?? 50;
273
+ this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
214
274
  this.debounceMs = opts.debounceMs ?? 300;
215
275
  }
216
276
  save() {
217
277
  if (this.paused) return;
278
+ if (this.transactionDepth > 0) {
279
+ this.transactionDirty = true;
280
+ return;
281
+ }
218
282
  if (this.debounceTimer) {
219
283
  clearTimeout(this.debounceTimer);
220
284
  }
221
285
  this.debounceTimer = setTimeout(() => {
222
286
  this.saveImmediate();
223
287
  }, this.debounceMs);
288
+ this.emitChanged();
224
289
  }
225
290
  saveImmediate() {
226
- if (this.paused) return;
227
- const state = this.getState();
291
+ if (this.paused) {
292
+ this.cancelPending();
293
+ return;
294
+ }
295
+ if (this.transactionDepth > 0) {
296
+ this.transactionDirty = true;
297
+ return;
298
+ }
299
+ this.cancelPending();
300
+ const rawState = this.getState();
301
+ const state = this.compactState(rawState);
302
+ if (this.undoStack.at(-1) === state) {
303
+ this.emitChanged();
304
+ return;
305
+ }
228
306
  this.undoStack.push(state);
229
- if (this.undoStack.length > this.maxSize) {
307
+ while (this.undoStack.length > this.maxSize) {
230
308
  this.undoStack.shift();
231
309
  }
232
310
  this.redoStack = [];
311
+ this.trimToBudget();
312
+ this.events.emit("history:snapshot", {
313
+ bytes: rawState.length * 2,
314
+ totalBytes: this.snapshotBytes(),
315
+ entries: this.undoStack.length
316
+ });
233
317
  this.emitChanged();
234
318
  }
319
+ beginTransaction() {
320
+ if (this.transactionDepth === 0 && this.debounceTimer) this.saveImmediate();
321
+ this.transactionDepth += 1;
322
+ }
323
+ endTransaction() {
324
+ if (this.transactionDepth === 0) return;
325
+ this.transactionDepth -= 1;
326
+ if (this.transactionDepth === 0 && this.transactionDirty) {
327
+ this.transactionDirty = false;
328
+ this.saveImmediate();
329
+ }
330
+ }
331
+ async transaction(operation) {
332
+ this.beginTransaction();
333
+ try {
334
+ return await operation();
335
+ } finally {
336
+ this.endTransaction();
337
+ }
338
+ }
235
339
  async undo() {
236
340
  this.cancelPending();
237
- const state = this.undoStack.pop();
238
- if (!state) return;
239
- this.redoStack.push(this.getState());
341
+ const committed = this.undoStack.at(-1);
342
+ if (!committed) return;
343
+ const current = this.compactState(this.getState());
344
+ const currentIsCommitted = current === committed;
345
+ if (currentIsCommitted && this.undoStack.length < 2) return;
346
+ const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
240
347
  this.paused = true;
241
- await this.restoreState(state);
242
- this.paused = false;
243
- this.emitChanged();
348
+ try {
349
+ await this.restoreState(this.expandState(target));
350
+ if (currentIsCommitted) this.undoStack.pop();
351
+ this.redoStack.push(current);
352
+ this.trimToBudget();
353
+ } catch (error) {
354
+ this.events.emit("error", { message: "Failed to undo the last change", error });
355
+ throw error;
356
+ } finally {
357
+ this.paused = false;
358
+ this.emitChanged();
359
+ }
244
360
  }
245
361
  async redo() {
246
362
  this.cancelPending();
247
- const state = this.redoStack.pop();
363
+ const state = this.redoStack.at(-1);
248
364
  if (!state) return;
249
- this.undoStack.push(this.getState());
250
365
  this.paused = true;
251
- await this.restoreState(state);
252
- this.paused = false;
253
- this.emitChanged();
366
+ try {
367
+ await this.restoreState(this.expandState(state));
368
+ this.redoStack.pop();
369
+ if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
370
+ this.trimToBudget();
371
+ } catch (error) {
372
+ this.events.emit("error", { message: "Failed to redo the last change", error });
373
+ throw error;
374
+ } finally {
375
+ this.paused = false;
376
+ this.emitChanged();
377
+ }
254
378
  }
255
379
  /** True while a restore (undo/redo/deserialize) is in flight. Managers that
256
380
  * react to canvas events should skip mutating history during this window. */
@@ -264,16 +388,22 @@ var HistoryManager = class {
264
388
  this.paused = false;
265
389
  }
266
390
  canUndo() {
267
- return this.undoStack.length > 0;
391
+ return this.undoStack.length > 1 || this.debounceTimer !== null;
268
392
  }
269
393
  canRedo() {
270
394
  return this.redoStack.length > 0;
271
395
  }
272
396
  clear() {
397
+ this.cancelPending();
273
398
  this.undoStack = [];
274
399
  this.redoStack = [];
400
+ this.assets.clear();
401
+ this.assetIds.clear();
275
402
  this.emitChanged();
276
403
  }
404
+ getSnapshotBytes() {
405
+ return this.snapshotBytes();
406
+ }
277
407
  cancelPending() {
278
408
  if (this.debounceTimer) {
279
409
  clearTimeout(this.debounceTimer);
@@ -281,9 +411,13 @@ var HistoryManager = class {
281
411
  }
282
412
  }
283
413
  dispose() {
284
- if (this.debounceTimer) {
285
- clearTimeout(this.debounceTimer);
286
- }
414
+ this.cancelPending();
415
+ this.transactionDepth = 0;
416
+ this.transactionDirty = false;
417
+ this.undoStack = [];
418
+ this.redoStack = [];
419
+ this.assets.clear();
420
+ this.assetIds.clear();
287
421
  }
288
422
  emitChanged() {
289
423
  this.events.emit("history:changed", {
@@ -291,6 +425,94 @@ var HistoryManager = class {
291
425
  canRedo: this.canRedo()
292
426
  });
293
427
  }
428
+ snapshotBytes() {
429
+ const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
430
+ (total, state) => total + state.length * 2,
431
+ 0
432
+ );
433
+ const assetBytes = [...this.assets.values()].reduce(
434
+ (total, asset) => total + asset.length * 2,
435
+ 0
436
+ );
437
+ return stackBytes + assetBytes;
438
+ }
439
+ trimToBudget() {
440
+ this.pruneAssets();
441
+ while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
442
+ this.undoStack.shift();
443
+ this.pruneAssets();
444
+ }
445
+ while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
446
+ this.redoStack.shift();
447
+ this.pruneAssets();
448
+ }
449
+ }
450
+ /**
451
+ * History is internal and can content-address large raster strings without
452
+ * changing the public EditorState wire format. Unchanged images are retained
453
+ * once even when dozens of snapshots reference them.
454
+ */
455
+ compactState(state) {
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(state);
459
+ } catch {
460
+ return state;
461
+ }
462
+ const visit = (value) => {
463
+ if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
464
+ let id = this.assetIds.get(value);
465
+ if (!id) {
466
+ id = `a${this.nextAssetId++}`;
467
+ this.assetIds.set(value, id);
468
+ this.assets.set(id, value);
469
+ }
470
+ return { [_HistoryManager.ASSET_KEY]: id };
471
+ }
472
+ if (Array.isArray(value)) return value.map(visit);
473
+ if (!value || typeof value !== "object") return value;
474
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
475
+ };
476
+ return JSON.stringify(visit(parsed));
477
+ }
478
+ expandState(state) {
479
+ let parsed;
480
+ try {
481
+ parsed = JSON.parse(state);
482
+ } catch {
483
+ return state;
484
+ }
485
+ const visit = (value) => {
486
+ if (Array.isArray(value)) return value.map(visit);
487
+ if (!value || typeof value !== "object") return value;
488
+ const record = value;
489
+ const id = record[_HistoryManager.ASSET_KEY];
490
+ if (typeof id === "string" && Object.keys(record).length === 1) {
491
+ const asset = this.assets.get(id);
492
+ if (!asset) throw new Error(`Missing history raster asset: ${id}`);
493
+ return asset;
494
+ }
495
+ return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
496
+ };
497
+ return JSON.stringify(visit(parsed));
498
+ }
499
+ /**
500
+ * Runs on every commit, so it scans for the serialized reference marker
501
+ * instead of re-parsing every snapshot — parsing multi-megabyte raster states
502
+ * per save would cost more than the retention it reclaims. `JSON.stringify`
503
+ * emits the marker verbatim; the same text inside user data is escaped and
504
+ * therefore cannot match.
505
+ */
506
+ pruneAssets() {
507
+ if (this.assets.size === 0) return;
508
+ const states = [...this.undoStack, ...this.redoStack];
509
+ for (const [id, asset] of this.assets) {
510
+ const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
511
+ if (states.some((state) => state.includes(marker))) continue;
512
+ this.assets.delete(id);
513
+ this.assetIds.delete(asset);
514
+ }
515
+ }
294
516
  };
295
517
 
296
518
  // src/snapping.ts
@@ -510,6 +732,10 @@ var CropController = class {
510
732
  top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
511
733
  });
512
734
  image.setCoords();
735
+ if (s.prevAngle) {
736
+ image.rotate(s.prevAngle);
737
+ image.setCoords();
738
+ }
513
739
  this.finish();
514
740
  this.history.save();
515
741
  }
@@ -544,14 +770,18 @@ var STROKE2 = "#22c55e";
544
770
  import { util } from "fabric";
545
771
  var MAX_TILES_PER_AXIS = 200;
546
772
  var PatternManager = class {
547
- constructor(canvas, layers, history) {
773
+ constructor(canvas, layers, history, events, sourceResolver) {
548
774
  this.canvas = canvas;
549
775
  this.layers = layers;
550
776
  this.history = history;
777
+ this.events = events;
778
+ this.sourceResolver = sourceResolver;
551
779
  }
552
780
  canvas;
553
781
  layers;
554
782
  history;
783
+ events;
784
+ sourceResolver;
555
785
  // Per-layer task chain. apply()/disable() both await an async setSrc on the
556
786
  // same fabric image; running two concurrently lets their setSrc resolutions
557
787
  // interleave (wrong image installed, original lost). Serialising per layer
@@ -599,6 +829,9 @@ var PatternManager = class {
599
829
  throw err;
600
830
  }
601
831
  this.history.save();
832
+ }).catch((error) => {
833
+ this.events.emit("error", { message: "Failed to apply image pattern", error });
834
+ throw error;
602
835
  });
603
836
  }
604
837
  /**
@@ -654,6 +887,9 @@ var PatternManager = class {
654
887
  delete layer.meta.pattern;
655
888
  this.canvas.requestRenderAll();
656
889
  this.history.save();
890
+ }).catch((error) => {
891
+ this.events.emit("error", { message: "Failed to clear image pattern", error });
892
+ throw error;
657
893
  });
658
894
  }
659
895
  /** Run `task` after any in-flight work for this layer, regardless of outcome. */
@@ -683,7 +919,8 @@ var PatternManager = class {
683
919
  cw,
684
920
  ch,
685
921
  tileW,
686
- tileH
922
+ tileH,
923
+ this.sourceResolver
687
924
  );
688
925
  await image.setSrc(dataUrl);
689
926
  image.set({
@@ -754,14 +991,23 @@ function elementToDataURL(image) {
754
991
  }
755
992
  var IMAGE_CACHE_MAX = 16;
756
993
  var imageCache = /* @__PURE__ */ new Map();
757
- function loadImage(src) {
994
+ function loadPatternImage(src, resolver) {
758
995
  const cached = imageCache.get(src);
759
996
  if (cached) {
760
997
  imageCache.delete(src);
761
998
  imageCache.set(src, cached);
762
999
  return cached;
763
1000
  }
764
- const promise = decodeImage(src);
1001
+ const promise = decodeImage(src).catch(async (originalError) => {
1002
+ if (!resolver) throw originalError;
1003
+ const resolved = await resolver(src);
1004
+ if (!resolved || resolved === src) {
1005
+ throw new Error("Pattern source resolver did not return a usable alternate URL", {
1006
+ cause: originalError
1007
+ });
1008
+ }
1009
+ return decodeImage(resolved);
1010
+ });
765
1011
  promise.catch(() => {
766
1012
  if (imageCache.get(src) === promise) imageCache.delete(src);
767
1013
  });
@@ -784,8 +1030,8 @@ function decodeImage(src) {
784
1030
  function clearPatternImageCache() {
785
1031
  imageCache.clear();
786
1032
  }
787
- async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
788
- const img = await loadImage(src);
1033
+ async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
1034
+ const img = await loadPatternImage(src, sourceResolver);
789
1035
  const off = document.createElement("canvas");
790
1036
  off.width = Math.max(1, Math.round(targetW));
791
1037
  off.height = Math.max(1, Math.round(targetH));
@@ -845,16 +1091,18 @@ function mod2(n) {
845
1091
  // src/utils/units.ts
846
1092
  var MM_PER_INCH = 25.4;
847
1093
  var UnitConverter = class {
1094
+ unit;
1095
+ dpi;
848
1096
  constructor(unit = "px", dpi = 72) {
849
1097
  this.unit = unit;
850
- this.dpi = dpi;
1098
+ this.dpi = 72;
1099
+ this.setDpi(dpi);
851
1100
  }
852
- unit;
853
- dpi;
854
1101
  setUnit(unit) {
855
1102
  this.unit = unit;
856
1103
  }
857
1104
  setDpi(dpi) {
1105
+ if (!Number.isFinite(dpi) || dpi <= 0) throw new Error("DPI must be a positive number");
858
1106
  this.dpi = dpi;
859
1107
  }
860
1108
  getUnit() {
@@ -889,38 +1137,92 @@ var UnitConverter = class {
889
1137
 
890
1138
  // src/serialization.ts
891
1139
  import { util as util2 } from "fabric";
892
- var VERSION = "1.0.0";
1140
+
1141
+ // src/utils/color.ts
1142
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1143
+ function isCssColor(value, allowEmpty = false) {
1144
+ if (typeof value !== "string") return false;
1145
+ const trimmed = value.trim();
1146
+ if (!trimmed) return allowEmpty;
1147
+ return CSS_COLOR.test(trimmed);
1148
+ }
1149
+
1150
+ // src/serialization.ts
1151
+ var VERSION = "2.0.0";
893
1152
  function serializeEditor(editor) {
894
1153
  return {
895
1154
  version: VERSION,
896
1155
  canvas: {
897
1156
  width: editor.canvas.getWidth(),
898
- height: editor.canvas.getHeight()
1157
+ height: editor.canvas.getHeight(),
1158
+ unit: editor.units.getUnit(),
1159
+ dpi: editor.units.getDpi()
899
1160
  },
900
1161
  layers: editor.layers.getAll().map((layer) => layer.serialize()),
901
1162
  // The configured design background, not the live canvas value (which is
902
1163
  // forced transparent while a mockup preview is active).
903
1164
  background: editor.getDesignBackground(),
1165
+ backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
1166
+ backgroundImageOptions: editor.getBackgroundImageOptions(),
904
1167
  mockup: editor.getMockup()
905
1168
  };
906
1169
  }
907
1170
  async function deserializeEditor(editor, state) {
1171
+ if (!state || !state.canvas || !Array.isArray(state.layers)) {
1172
+ throw new Error("Invalid editor state");
1173
+ }
1174
+ if (!Number.isFinite(state.canvas.width) || !Number.isFinite(state.canvas.height) || state.canvas.width <= 0 || state.canvas.height <= 0 || state.canvas.unit !== void 0 && !["px", "mm", "in"].includes(state.canvas.unit) || state.canvas.dpi !== void 0 && (!Number.isFinite(state.canvas.dpi) || state.canvas.dpi <= 0)) {
1175
+ throw new Error("Invalid editor canvas settings");
1176
+ }
1177
+ const major = Number.parseInt(state.version?.split(".")[0] ?? "1", 10);
1178
+ if (!Number.isFinite(major) || major > 2) {
1179
+ throw new Error(`Unsupported editor state version: ${state.version}`);
1180
+ }
1181
+ if (state.background !== void 0 && !isCssColor(state.background, true)) {
1182
+ throw new Error("Invalid editor background color");
1183
+ }
1184
+ const staged = await Promise.all(
1185
+ state.layers.map(async (serialized) => {
1186
+ const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
1187
+ if (!fabricObject) {
1188
+ const source = serialized.fabricObject.src;
1189
+ if (typeof source === "string" && source.startsWith("blob:")) {
1190
+ throw new Error(`Failed to restore expired object URL: ${source}`);
1191
+ }
1192
+ throw new Error(`Failed to restore layer: ${serialized.id}`);
1193
+ }
1194
+ return { serialized, fabricObject };
1195
+ })
1196
+ );
1197
+ const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
1198
+ if (state.backgroundImage && !stagedBackground) {
1199
+ const source = state.backgroundImage.src;
1200
+ if (typeof source === "string" && source.startsWith("blob:")) {
1201
+ throw new Error(`Failed to restore expired background object URL: ${source}`);
1202
+ }
1203
+ throw new Error("Failed to restore background image");
1204
+ }
1205
+ editor.crop.cancel();
1206
+ editor.masks.detach();
908
1207
  editor.layers.clear();
1208
+ if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
1209
+ if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
909
1210
  editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
910
- if (state.background) {
1211
+ if (state.background !== void 0) {
911
1212
  editor.setBackground(state.background);
912
1213
  }
913
- if (state.mockup !== void 0) {
914
- editor.setMockup(state.mockup);
915
- }
916
- for (const serializedLayer of state.layers) {
917
- await restoreLayer(editor, serializedLayer);
1214
+ editor.setBackgroundImageObject(
1215
+ stagedBackground ?? null,
1216
+ false,
1217
+ state.backgroundImageOptions ?? null
1218
+ );
1219
+ editor.setMockup(state.mockup ?? null);
1220
+ for (const item of staged) {
1221
+ restoreLayer(editor, item.serialized, item.fabricObject);
918
1222
  }
919
1223
  editor.canvas.requestRenderAll();
920
1224
  }
921
- async function restoreLayer(editor, serialized) {
922
- const objects = await util2.enlivenObjects([serialized.fabricObject]);
923
- const fabricObject = objects[0];
1225
+ function restoreLayer(editor, serialized, fabricObject) {
924
1226
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
925
1227
  if (serialized.meta) {
926
1228
  layer.meta = serialized.meta;
@@ -937,27 +1239,583 @@ async function restoreLayer(editor, serialized) {
937
1239
  return layer;
938
1240
  }
939
1241
 
940
- // src/export.ts
941
- async function exportPNG(canvas, options = {}) {
942
- const { multiplier = 1, format = "png", quality = 1 } = options;
943
- const dataUrl = canvas.toDataURL({
944
- format,
945
- multiplier,
946
- quality
1242
+ // src/utils/svg.ts
1243
+ function escapeXml(value) {
1244
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
1245
+ }
1246
+ function sanitizeSvg(svg) {
1247
+ const document2 = new DOMParser().parseFromString(svg, "image/svg+xml");
1248
+ if (document2.querySelector("parsererror")) throw new Error("Invalid template SVG");
1249
+ document2.querySelectorAll("script, foreignObject, iframe, object, embed, link, style").forEach((node) => node.remove());
1250
+ document2.querySelectorAll("*").forEach((node) => {
1251
+ for (const attribute of [...node.attributes]) {
1252
+ const name = attribute.name.toLowerCase();
1253
+ const value = attribute.value.trim().toLowerCase();
1254
+ const isLink = name === "href" || name === "xlink:href" || name === "src";
1255
+ const safeLink = value.startsWith("#") || /^data:image\/(?:png|jpeg|webp|gif);base64,/.test(value);
1256
+ if (name.startsWith("on") || isLink && !safeLink || /url\s*\(/.test(value) && !/url\s*\(\s*['"]?#/.test(value) || /(?:javascript:|expression\s*\()/.test(value)) {
1257
+ node.removeAttribute(attribute.name);
1258
+ }
1259
+ }
947
1260
  });
948
- const response = await fetch(dataUrl);
949
- return response.blob();
1261
+ return new XMLSerializer().serializeToString(document2.documentElement);
950
1262
  }
951
- function exportSVG(canvas) {
952
- return canvas.toSVG();
1263
+
1264
+ // src/fonts.ts
1265
+ function fontSource(source) {
1266
+ return /^(?:url|local)\(/.test(source.trim()) ? source : `url(${JSON.stringify(source)})`;
1267
+ }
1268
+ function mimeForSource(source) {
1269
+ const path = source.split(/[?#]/)[0].toLowerCase();
1270
+ if (path.endsWith(".woff2")) return "font/woff2";
1271
+ if (path.endsWith(".woff")) return "font/woff";
1272
+ if (path.endsWith(".otf")) return "font/otf";
1273
+ return "font/ttf";
1274
+ }
1275
+ function arrayBufferToBase64(buffer) {
1276
+ const bytes = new Uint8Array(buffer);
1277
+ let binary = "";
1278
+ for (let index = 0; index < bytes.length; index += 32768) {
1279
+ binary += String.fromCharCode(...bytes.subarray(index, index + 32768));
1280
+ }
1281
+ return btoa(binary);
1282
+ }
1283
+ function cssString(value) {
1284
+ return JSON.stringify(value).replace(/[<>&]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
953
1285
  }
954
- function exportDataURL(canvas, format = "png", multiplier = 1) {
955
- return canvas.toDataURL({ format, multiplier });
1286
+ var SAFE_WEIGHT = /^(?:normal|bold|bolder|lighter|[1-9]\d{0,2}(?:\s+[1-9]\d{0,2})?)$/i;
1287
+ var SAFE_STYLE = /^(?:normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg)?)$/i;
1288
+ var SAFE_DISPLAY = /^(?:auto|block|swap|fallback|optional)$/i;
1289
+ function cssKeyword(value, pattern, fallback) {
1290
+ const trimmed = value?.trim();
1291
+ return trimmed && pattern.test(trimmed) ? trimmed : fallback;
956
1292
  }
1293
+ function sourceUrl(source) {
1294
+ const trimmed = source.trim();
1295
+ if (trimmed.startsWith("data:")) return trimmed;
1296
+ const match = trimmed.match(/^url\(\s*(['"]?)(.*?)\1\s*\)/i);
1297
+ if (match) return match[2];
1298
+ if (/^local\(/i.test(trimmed)) return null;
1299
+ return trimmed;
1300
+ }
1301
+ function localName(source) {
1302
+ const match = source.trim().match(/^local\(\s*(['"]?)([^)"'{};]*)\1\s*\)$/i);
1303
+ return match ? match[2].trim() || null : null;
1304
+ }
1305
+ var FontRegistry = class {
1306
+ definitions = /* @__PURE__ */ new Map();
1307
+ loads = /* @__PURE__ */ new Map();
1308
+ register(definition) {
1309
+ if (!definition.family.trim() || !definition.source.trim()) {
1310
+ throw new Error("Font family and source are required");
1311
+ }
1312
+ this.definitions.set(definition.family, { ...definition });
1313
+ this.loads.delete(definition.family);
1314
+ }
1315
+ unregister(family) {
1316
+ this.loads.delete(family);
1317
+ return this.definitions.delete(family);
1318
+ }
1319
+ getAll() {
1320
+ return [...this.definitions.values()].map((definition) => ({ ...definition }));
1321
+ }
1322
+ load(family) {
1323
+ const cached = this.loads.get(family);
1324
+ if (cached) return cached;
1325
+ const definition = this.definitions.get(family);
1326
+ if (!definition) return Promise.reject(new Error(`Font is not registered: ${family}`));
1327
+ if (typeof FontFace === "undefined" || typeof document === "undefined") {
1328
+ return Promise.reject(new Error("Font loading requires a browser FontFace API"));
1329
+ }
1330
+ const promise = new FontFace(definition.family, fontSource(definition.source), {
1331
+ weight: definition.weight,
1332
+ style: definition.style,
1333
+ display: definition.display
1334
+ }).load().then((font) => {
1335
+ document.fonts.add(font);
1336
+ return font;
1337
+ });
1338
+ promise.catch(() => {
1339
+ if (this.loads.get(family) === promise) this.loads.delete(family);
1340
+ });
1341
+ this.loads.set(family, promise);
1342
+ return promise;
1343
+ }
1344
+ async ready() {
1345
+ if (this.definitions.size === 0) return;
1346
+ await Promise.all([...this.definitions.keys()].map((family) => this.load(family)));
1347
+ await document.fonts.ready;
1348
+ }
1349
+ async getEmbeddedCss() {
1350
+ const rules = await Promise.all(
1351
+ this.getAll().map(async (definition) => {
1352
+ const url = sourceUrl(definition.source);
1353
+ let cssSource;
1354
+ if (url && !url.startsWith("data:")) {
1355
+ const response = await fetch(url);
1356
+ if (!response.ok) throw new Error(`Failed to fetch font: ${url}`);
1357
+ const data = arrayBufferToBase64(await response.arrayBuffer());
1358
+ const mime = response.headers.get("content-type") || mimeForSource(url);
1359
+ cssSource = `url(${cssString(`data:${mime};base64,${data}`)})`;
1360
+ } else if (url) {
1361
+ cssSource = `url(${cssString(url)})`;
1362
+ } else {
1363
+ const name = localName(definition.source);
1364
+ if (!name) {
1365
+ throw new Error(`Unsupported font source for embedding: ${definition.family}`);
1366
+ }
1367
+ cssSource = `local(${cssString(name)})`;
1368
+ }
1369
+ const weight = cssKeyword(definition.weight, SAFE_WEIGHT, "normal");
1370
+ const style = cssKeyword(definition.style, SAFE_STYLE, "normal");
1371
+ const display = cssKeyword(definition.display, SAFE_DISPLAY, "swap");
1372
+ return `@font-face{font-family:${cssString(definition.family)};src:${cssSource};font-weight:${weight};font-style:${style};font-display:${display}}`;
1373
+ })
1374
+ );
1375
+ return rules.join("\n");
1376
+ }
1377
+ };
1378
+
1379
+ // src/licensing.ts
1380
+ function domainMatches(hostname, pattern) {
1381
+ const host = hostname.toLowerCase();
1382
+ const expected = pattern.toLowerCase();
1383
+ if (expected.startsWith("*.")) {
1384
+ const suffix = expected.slice(1);
1385
+ return host.endsWith(suffix) && host.length > suffix.length;
1386
+ }
1387
+ return host === expected;
1388
+ }
1389
+ function isLocal(hostname) {
1390
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
1391
+ }
1392
+ var LicenseManager = class {
1393
+ constructor(config = {}) {
1394
+ this.config = config;
1395
+ const hostname = config.hostname ?? globalThis.location?.hostname ?? "localhost";
1396
+ const environment = config.environment ?? "development";
1397
+ if (environment !== "production" || isLocal(hostname)) {
1398
+ this.status = { state: "exempt", payload: null };
1399
+ this.readyPromise = Promise.resolve(this.status);
1400
+ } else if (!config.key) {
1401
+ this.status = { state: "community", payload: null };
1402
+ this.readyPromise = Promise.resolve(this.status);
1403
+ } else {
1404
+ this.status = { state: "checking", payload: null };
1405
+ this.readyPromise = this.validate(config.key, hostname);
1406
+ }
1407
+ }
1408
+ config;
1409
+ status;
1410
+ readyPromise;
1411
+ getStatus() {
1412
+ return this.status;
1413
+ }
1414
+ ready() {
1415
+ return this.readyPromise;
1416
+ }
1417
+ hasFeature(feature) {
1418
+ return this.status.state === "valid" && (this.status.payload.features ?? []).includes(feature);
1419
+ }
1420
+ track(name) {
1421
+ this.config.onUsage?.({
1422
+ name,
1423
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1424
+ licenseId: this.status.state === "valid" ? this.status.payload.id : void 0
1425
+ });
1426
+ }
1427
+ async validate(key, hostname) {
1428
+ const payload = await this.config.verifyOffline?.(key) ?? null;
1429
+ if (!payload) return this.status = { state: "invalid", payload: null };
1430
+ if (payload.expiresAt && Date.parse(payload.expiresAt) < Date.now()) {
1431
+ return this.status = { state: "expired", payload };
1432
+ }
1433
+ if (!payload.domains.some((domain) => domainMatches(hostname, domain))) {
1434
+ return this.status = { state: "domain-mismatch", payload };
1435
+ }
1436
+ return this.status = { state: "valid", payload };
1437
+ }
1438
+ };
1439
+
1440
+ // src/project.ts
1441
+ var ProjectManager = class {
1442
+ constructor(editor) {
1443
+ this.editor = editor;
1444
+ const first = { id: generateId(), name: "Page 1", state: editor.toJSON() };
1445
+ this.pages = [first];
1446
+ this.activePageId = first.id;
1447
+ }
1448
+ editor;
1449
+ pages;
1450
+ activePageId;
1451
+ getAll() {
1452
+ return this.pages.map(({ id, name }) => ({ id, name }));
1453
+ }
1454
+ getActivePageId() {
1455
+ return this.activePageId;
1456
+ }
1457
+ add(name = `Page ${this.pages.length + 1}`, cloneCurrent = false) {
1458
+ this.saveCurrent();
1459
+ const source = structuredClone(this.editor.toJSON());
1460
+ const state = cloneCurrent ? source : this.blankState(source);
1461
+ const page = { id: generateId(), name, state };
1462
+ this.pages.push(page);
1463
+ this.emitChanged();
1464
+ return page.id;
1465
+ }
1466
+ async switchTo(id) {
1467
+ if (id === this.activePageId) return true;
1468
+ const page = this.pages.find((candidate) => candidate.id === id);
1469
+ if (!page) return false;
1470
+ this.saveCurrent();
1471
+ await this.editor.fromJSON(structuredClone(page.state));
1472
+ this.activePageId = id;
1473
+ this.emitChanged();
1474
+ return true;
1475
+ }
1476
+ async duplicate(id) {
1477
+ this.saveCurrent();
1478
+ const source = this.pages.find((page2) => page2.id === id);
1479
+ if (!source) return null;
1480
+ const page = {
1481
+ id: generateId(),
1482
+ name: `${source.name} copy`,
1483
+ state: structuredClone(source.state)
1484
+ };
1485
+ const index = this.pages.indexOf(source);
1486
+ this.pages.splice(index + 1, 0, page);
1487
+ this.emitChanged();
1488
+ return page.id;
1489
+ }
1490
+ async remove(id) {
1491
+ if (this.pages.length === 1) return false;
1492
+ const index = this.pages.findIndex((page) => page.id === id);
1493
+ if (index < 0) return false;
1494
+ if (id === this.activePageId) {
1495
+ const next = this.pages[index + 1] ?? this.pages[index - 1];
1496
+ await this.editor.fromJSON(structuredClone(next.state));
1497
+ this.activePageId = next.id;
1498
+ }
1499
+ this.pages.splice(index, 1);
1500
+ this.emitChanged();
1501
+ return true;
1502
+ }
1503
+ rename(id, name) {
1504
+ const page = this.pages.find((candidate) => candidate.id === id);
1505
+ const trimmed = name.trim();
1506
+ if (!page || !trimmed || page.name === trimmed) return false;
1507
+ page.name = trimmed;
1508
+ this.emitChanged();
1509
+ return true;
1510
+ }
1511
+ reorder(id, newIndex) {
1512
+ const index = this.pages.findIndex((page2) => page2.id === id);
1513
+ if (index < 0 || !Number.isFinite(newIndex)) return false;
1514
+ const target = Math.max(0, Math.min(this.pages.length - 1, Math.round(newIndex)));
1515
+ if (target === index) return false;
1516
+ const [page] = this.pages.splice(index, 1);
1517
+ this.pages.splice(target, 0, page);
1518
+ this.emitChanged();
1519
+ return true;
1520
+ }
1521
+ toJSON() {
1522
+ this.saveCurrent();
1523
+ return {
1524
+ version: "1.0.0",
1525
+ activePageId: this.activePageId,
1526
+ pages: structuredClone(this.pages)
1527
+ };
1528
+ }
1529
+ async fromJSON(project) {
1530
+ if (project?.version !== "1.0.0" || !Array.isArray(project.pages) || project.pages.length === 0 || !project.pages.some((page) => page.id === project.activePageId)) {
1531
+ throw new Error("Invalid project state");
1532
+ }
1533
+ const pages = structuredClone(project.pages);
1534
+ const active = pages.find((page) => page.id === project.activePageId);
1535
+ await this.editor.fromJSON(structuredClone(active.state));
1536
+ this.pages = pages;
1537
+ this.activePageId = active.id;
1538
+ this.emitChanged();
1539
+ }
1540
+ saveCurrent() {
1541
+ const page = this.pages.find((candidate) => candidate.id === this.activePageId);
1542
+ if (page) page.state = structuredClone(this.editor.toJSON());
1543
+ }
1544
+ blankState(source) {
1545
+ return { ...source, layers: [], mockup: null };
1546
+ }
1547
+ emitChanged() {
1548
+ this.editor.events.emit("project:changed", {
1549
+ activePageId: this.activePageId,
1550
+ pages: this.getAll()
1551
+ });
1552
+ }
1553
+ };
1554
+
1555
+ // src/mask.ts
1556
+ import { FabricImage as FabricImage2 } from "fabric";
1557
+ var MaskRefinementError = class extends Error {
1558
+ constructor(code, message, cause) {
1559
+ super(message);
1560
+ this.code = code;
1561
+ this.cause = cause;
1562
+ this.name = "MaskRefinementError";
1563
+ }
1564
+ code;
1565
+ cause;
1566
+ };
1567
+ var MaskController = class {
1568
+ constructor(editor) {
1569
+ this.editor = editor;
1570
+ }
1571
+ editor;
1572
+ backing = null;
1573
+ context = null;
1574
+ layerId = null;
1575
+ brush = null;
1576
+ previousPoint = null;
1577
+ strokeBackup = null;
1578
+ strokeStartedAt = null;
1579
+ lastInteractionLatencyMs = 0;
1580
+ refinement = null;
1581
+ disposed = false;
1582
+ async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
1583
+ this.assertActive();
1584
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
1585
+ throw new Error("Mask dimensions must be positive integers");
1586
+ }
1587
+ const backing = this.makeCanvas(width, height);
1588
+ const image = new FabricImage2(backing, {
1589
+ left: 0,
1590
+ top: 0,
1591
+ originX: "left",
1592
+ originY: "top",
1593
+ selectable: false
1594
+ });
1595
+ const layer = this.editor.layers.add("mask", image, "Mask");
1596
+ layer.meta.mask = { width, height, revision: 0 };
1597
+ this.editor.history.saveImmediate();
1598
+ this.attachBacking(layer.id, backing);
1599
+ return layer;
1600
+ }
1601
+ attach(layerId) {
1602
+ this.assertActive();
1603
+ this.cancelStroke();
1604
+ const layer = this.requireMask(layerId);
1605
+ const image = layer.fabricObject;
1606
+ const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
1607
+ const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
1608
+ const backing = this.makeCanvas(width, height);
1609
+ const context = backing.getContext("2d");
1610
+ if (!context) throw new Error("2D mask context is unavailable");
1611
+ const element = image.getElement();
1612
+ if (element) context.drawImage(element, 0, 0, width, height);
1613
+ this.attachBacking(layerId, backing);
1614
+ }
1615
+ beginStroke(options) {
1616
+ this.assertActive();
1617
+ if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
1618
+ if (this.brush) throw new Error("A mask stroke is already active");
1619
+ if (!Number.isFinite(options.size) || options.size <= 0) {
1620
+ throw new Error("Mask brush size must be positive");
1621
+ }
1622
+ this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
1623
+ this.previousPoint = null;
1624
+ this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
1625
+ this.strokeStartedAt = performance.now();
1626
+ }
1627
+ addPoint(point) {
1628
+ if (!this.brush || !this.context || !this.backing || !this.layerId) {
1629
+ throw new Error("No active mask stroke");
1630
+ }
1631
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
1632
+ const previous = this.previousPoint ?? point;
1633
+ const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
1634
+ const step = Math.max(1, this.brush.size / 4);
1635
+ const samples = Math.max(1, Math.ceil(distance / step));
1636
+ for (let index = 0; index <= samples; index += 1) {
1637
+ const ratio = index / samples;
1638
+ this.drawDot({
1639
+ x: previous.x + (point.x - previous.x) * ratio,
1640
+ y: previous.y + (point.y - previous.y) * ratio
1641
+ });
1642
+ }
1643
+ this.previousPoint = point;
1644
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
1645
+ this.editor.canvas.requestRenderAll();
1646
+ }
1647
+ async endStroke() {
1648
+ if (!this.brush || !this.backing || !this.layerId) return;
1649
+ const layerId = this.layerId;
1650
+ this.brush = null;
1651
+ this.previousPoint = null;
1652
+ this.strokeBackup = null;
1653
+ const dataUrl = this.backing.toDataURL("image/png");
1654
+ await this.editor.history.transaction(async () => {
1655
+ await this.editor.replaceImageSource(layerId, dataUrl);
1656
+ const layer = this.requireMask(layerId);
1657
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
1658
+ });
1659
+ this.attach(layerId);
1660
+ if (this.strokeStartedAt !== null) {
1661
+ this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
1662
+ this.strokeStartedAt = null;
1663
+ }
1664
+ }
1665
+ cancelStroke() {
1666
+ if (this.strokeBackup && this.context && this.backing && this.layerId) {
1667
+ this.context.putImageData(this.strokeBackup, 0, 0);
1668
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
1669
+ this.editor.canvas.requestRenderAll();
1670
+ }
1671
+ this.brush = null;
1672
+ this.previousPoint = null;
1673
+ this.strokeBackup = null;
1674
+ this.strokeStartedAt = null;
1675
+ }
1676
+ isStrokeActive() {
1677
+ return this.brush !== null;
1678
+ }
1679
+ activeLayerId() {
1680
+ return this.layerId;
1681
+ }
1682
+ detach(layerId) {
1683
+ if (layerId && this.layerId !== layerId) return;
1684
+ this.cancelStroke();
1685
+ this.cancelRefinement();
1686
+ this.backing = null;
1687
+ this.context = null;
1688
+ this.layerId = null;
1689
+ }
1690
+ async refine(layerId, provider, prompts, options = {}) {
1691
+ this.assertActive();
1692
+ const layer = this.editor.layers.get(layerId);
1693
+ if (!layer || layer.type !== "mask") {
1694
+ throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
1695
+ }
1696
+ this.cancelRefinement();
1697
+ const controller = new AbortController();
1698
+ this.refinement = controller;
1699
+ const abort = () => controller.abort(options.signal?.reason);
1700
+ options.signal?.addEventListener("abort", abort, { once: true });
1701
+ if (options.signal?.aborted) abort();
1702
+ try {
1703
+ const image = layer.fabricObject;
1704
+ const result = await provider.refine(
1705
+ {
1706
+ mask: image.getSrc(),
1707
+ width: layer.meta.mask?.width ?? image.width ?? 1,
1708
+ height: layer.meta.mask?.height ?? image.height ?? 1,
1709
+ prompts: structuredClone(prompts)
1710
+ },
1711
+ { signal: controller.signal, onProgress: options.onProgress }
1712
+ );
1713
+ if (controller.signal.aborted)
1714
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
1715
+ if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
1716
+ throw new MaskRefinementError(
1717
+ "invalid-result",
1718
+ "Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
1719
+ );
1720
+ }
1721
+ await this.editor.history.transaction(async () => {
1722
+ await this.editor.replaceImageSource(layerId, result.dataUrl);
1723
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
1724
+ });
1725
+ this.attach(layerId);
1726
+ return result;
1727
+ } catch (error) {
1728
+ if (error instanceof MaskRefinementError) throw error;
1729
+ if (controller.signal.aborted) {
1730
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
1731
+ }
1732
+ throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
1733
+ } finally {
1734
+ options.signal?.removeEventListener("abort", abort);
1735
+ if (this.refinement === controller) this.refinement = null;
1736
+ }
1737
+ }
1738
+ cancelRefinement() {
1739
+ this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
1740
+ this.refinement = null;
1741
+ }
1742
+ measure() {
1743
+ if (!this.backing) return null;
1744
+ const started = performance.now();
1745
+ this.context?.getImageData(0, 0, 1, 1);
1746
+ const backingBytes = this.backing.width * this.backing.height * 4;
1747
+ const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
1748
+ const memory = performance;
1749
+ return {
1750
+ width: this.backing.width,
1751
+ height: this.backing.height,
1752
+ backingBytes,
1753
+ strokeBackupBytes,
1754
+ // The backing store and rollback ImageData dominate interactive mask
1755
+ // memory. Encoded historical rasters are reported separately below.
1756
+ estimatedPeakBytes: backingBytes + strokeBackupBytes,
1757
+ historyBytes: this.editor.history.getSnapshotBytes(),
1758
+ interactionLatencyMs: this.lastInteractionLatencyMs,
1759
+ ...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
1760
+ elapsedMs: performance.now() - started
1761
+ };
1762
+ }
1763
+ dispose() {
1764
+ this.detach();
1765
+ this.disposed = true;
1766
+ }
1767
+ drawDot(point) {
1768
+ const context = this.context;
1769
+ const brush = this.brush;
1770
+ const radius = brush.size / 2;
1771
+ context.save();
1772
+ context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
1773
+ const gradient = context.createRadialGradient(
1774
+ point.x,
1775
+ point.y,
1776
+ radius * brush.hardness,
1777
+ point.x,
1778
+ point.y,
1779
+ radius
1780
+ );
1781
+ const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
1782
+ gradient.addColorStop(0, color);
1783
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1784
+ context.fillStyle = gradient;
1785
+ context.beginPath();
1786
+ context.arc(point.x, point.y, radius, 0, Math.PI * 2);
1787
+ context.fill();
1788
+ context.restore();
1789
+ }
1790
+ makeCanvas(width, height) {
1791
+ const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
1792
+ canvas.width = width;
1793
+ canvas.height = height;
1794
+ return canvas;
1795
+ }
1796
+ attachBacking(layerId, backing) {
1797
+ const context = backing.getContext("2d");
1798
+ if (!context) throw new Error("2D mask context is unavailable");
1799
+ this.layerId = layerId;
1800
+ this.backing = backing;
1801
+ this.context = context;
1802
+ }
1803
+ requireMask(layerId) {
1804
+ const layer = this.editor.layers.get(layerId);
1805
+ if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
1806
+ return layer;
1807
+ }
1808
+ assertActive() {
1809
+ if (this.disposed) throw new Error("Mask controller has been disposed");
1810
+ }
1811
+ };
957
1812
 
958
1813
  // src/editor.ts
959
1814
  var MIN_ZOOM = 0.1;
960
1815
  var MAX_ZOOM = 8;
1816
+ function isTaintedCanvasError(error) {
1817
+ return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
1818
+ }
961
1819
  var CanvasEditor = class {
962
1820
  canvas;
963
1821
  layers;
@@ -967,6 +1825,10 @@ var CanvasEditor = class {
967
1825
  snapping;
968
1826
  crop;
969
1827
  patterns;
1828
+ fonts;
1829
+ licensing;
1830
+ pages;
1831
+ masks;
970
1832
  fileAdapter;
971
1833
  imageProvider;
972
1834
  zoomLevel = 1;
@@ -975,8 +1837,13 @@ var CanvasEditor = class {
975
1837
  // transparent while a mockup preview is shown, so this is the source of truth
976
1838
  // for serialization and export — not the (possibly transient) canvas value.
977
1839
  designBackground;
1840
+ designBackgroundImage = null;
1841
+ backgroundImageOptions = null;
978
1842
  constructor(canvasElement, config) {
979
1843
  this.events = new EventEmitter();
1844
+ this.fonts = new FontRegistry();
1845
+ config.fonts?.forEach((font) => this.fonts.register(font));
1846
+ this.licensing = new LicenseManager(config.license);
980
1847
  this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
981
1848
  const widthPx = this.units.toPixels(config.width);
982
1849
  const heightPx = this.units.toPixels(config.height);
@@ -999,18 +1866,76 @@ var CanvasEditor = class {
999
1866
  },
1000
1867
  events: this.events
1001
1868
  });
1869
+ this.layers.setHistoryCallback(() => this.history.save());
1002
1870
  this.snapping = new SnapManager(this.canvas, this.events);
1003
1871
  this.crop = new CropController(this.canvas, this.history, this.events);
1004
- this.patterns = new PatternManager(this.canvas, this.layers, this.history);
1872
+ this.patterns = new PatternManager(
1873
+ this.canvas,
1874
+ this.layers,
1875
+ this.history,
1876
+ this.events,
1877
+ config.patternSourceResolver
1878
+ );
1005
1879
  this.setupCanvasEvents();
1006
1880
  this.history.saveImmediate();
1881
+ this.pages = new ProjectManager(this);
1882
+ this.masks = new MaskController(this);
1007
1883
  }
1008
1884
  // ─── Layer Operations ────────────────────────────────
1009
1885
  async addImage(url, options) {
1010
- const img = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top", ...options });
1011
- const layer = this.layers.add("image", img);
1012
- this.history.save();
1013
- return layer;
1886
+ try {
1887
+ const img = await FabricImage3.fromURL(
1888
+ url,
1889
+ {},
1890
+ { originX: "left", originY: "top", ...options }
1891
+ );
1892
+ const layer = this.layers.add("image", img);
1893
+ this.history.save();
1894
+ return layer;
1895
+ } catch (error) {
1896
+ this.events.emit("error", { message: "Failed to add image", error });
1897
+ throw error;
1898
+ }
1899
+ }
1900
+ /** Replace an image source without changing its layer identity or visual transform. */
1901
+ async replaceImageSource(layerId, url) {
1902
+ const layer = this.layers.get(layerId);
1903
+ if (!layer || layer.type !== "image" && layer.type !== "mask") {
1904
+ throw new Error(`Image or mask layer not found: ${layerId}`);
1905
+ }
1906
+ if (layer.meta.pattern) {
1907
+ throw new Error("Clear the pattern before replacing the image source");
1908
+ }
1909
+ if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1910
+ const previous = layer.fabricObject;
1911
+ try {
1912
+ const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
1913
+ replacement.set({
1914
+ left: previous.left,
1915
+ top: previous.top,
1916
+ originX: previous.originX,
1917
+ originY: previous.originY,
1918
+ width: previous.width,
1919
+ height: previous.height,
1920
+ cropX: previous.cropX,
1921
+ cropY: previous.cropY,
1922
+ scaleX: previous.scaleX,
1923
+ scaleY: previous.scaleY,
1924
+ angle: previous.angle,
1925
+ skewX: previous.skewX,
1926
+ skewY: previous.skewY,
1927
+ flipX: previous.flipX,
1928
+ flipY: previous.flipY
1929
+ });
1930
+ replacement.filters = [...previous.filters];
1931
+ replacement.applyFilters();
1932
+ replacement.setCoords();
1933
+ this.layers.replaceObject(layerId, replacement);
1934
+ return layer;
1935
+ } catch (error) {
1936
+ this.events.emit("error", { message: "Failed to replace image source", error });
1937
+ throw error;
1938
+ }
1014
1939
  }
1015
1940
  addText(text, options) {
1016
1941
  const textbox = new Textbox(text, {
@@ -1033,27 +1958,45 @@ var CanvasEditor = class {
1033
1958
  this.history.save();
1034
1959
  return layer;
1035
1960
  }
1036
- async addTemplate(template, _params) {
1037
- const { Textbox: TextboxClass } = await import("fabric");
1038
- const placeholder = new TextboxClass(`[Template: ${template.name}]`, {
1039
- fontSize: 24,
1040
- fontFamily: "Arial",
1041
- fill: "#666666",
1042
- width: 300,
1043
- originX: "left",
1044
- originY: "top"
1045
- });
1046
- const layer = this.layers.add(
1047
- "template",
1048
- placeholder,
1049
- template.name
1961
+ async addTemplate(template, params) {
1962
+ const values = {};
1963
+ for (const parameter of template.parameters) {
1964
+ const value = params[parameter.key] ?? parameter.default;
1965
+ if (value === void 0) throw new Error(`Missing template parameter: ${parameter.key}`);
1966
+ if (parameter.type === "number" && !Number.isFinite(Number(value))) {
1967
+ throw new Error(`Invalid number for template parameter: ${parameter.key}`);
1968
+ }
1969
+ if (parameter.type === "color" && !isCssColor(value)) {
1970
+ throw new Error(`Invalid color for template parameter: ${parameter.key}`);
1971
+ }
1972
+ values[parameter.key] = value;
1973
+ }
1974
+ const resolved = sanitizeSvg(
1975
+ template.svg.replace(/\{\{(\w+)(?:[|:]([^}]*))?\}\}/g, (token, key, fallback) => {
1976
+ const value = values[key] ?? fallback;
1977
+ return value === void 0 ? token : escapeXml(value);
1978
+ })
1050
1979
  );
1980
+ const { objects, options } = await loadSVGFromString(resolved);
1981
+ const validObjects = objects.filter((object) => object !== null);
1982
+ if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
1983
+ const group = util3.groupSVGElements(validObjects, options);
1984
+ group.set({
1985
+ left: this.canvas.getWidth() / 2,
1986
+ top: this.canvas.getHeight() / 2,
1987
+ originX: "center",
1988
+ originY: "center"
1989
+ });
1990
+ const layer = this.layers.add("template", group, template.name);
1051
1991
  this.history.save();
1052
1992
  return layer;
1053
1993
  }
1054
1994
  removeLayer(id) {
1055
- this.layers.remove(id);
1056
- this.history.save();
1995
+ if (this.crop.activeLayerId() === id) this.crop.cancel();
1996
+ if (this.masks.activeLayerId() === id) {
1997
+ this.masks.detach(id);
1998
+ }
1999
+ if (this.layers.remove(id)) this.history.save();
1057
2000
  }
1058
2001
  selectLayer(id) {
1059
2002
  this.layers.select(id);
@@ -1088,35 +2031,220 @@ var CanvasEditor = class {
1088
2031
  clone.setCoords();
1089
2032
  const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
1090
2033
  copy.meta = structuredClone(layer.meta);
2034
+ copy.visible = layer.visible;
2035
+ copy.locked = layer.locked;
2036
+ copy.opacity = layer.opacity;
2037
+ clone.set({
2038
+ visible: layer.visible,
2039
+ selectable: !layer.locked,
2040
+ evented: !layer.locked,
2041
+ opacity: layer.opacity
2042
+ });
1091
2043
  this.canvas.setActiveObject(clone);
1092
2044
  this.canvas.requestRenderAll();
1093
2045
  this.history.save();
1094
2046
  return copy;
1095
2047
  }
2048
+ applyImageAdjustments(layerId, adjustments) {
2049
+ const layer = this.layers.get(layerId);
2050
+ if (!layer || layer.type !== "image") return false;
2051
+ const image = layer.fabricObject;
2052
+ const previous = layer.meta.imageAdjustments ?? {};
2053
+ const next = { ...previous, ...adjustments };
2054
+ const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
2055
+ image.filters = [
2056
+ new filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
2057
+ new filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
2058
+ new filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
2059
+ new filters.Blur({ blur: clampAdjustment(next.blur, 0) })
2060
+ ];
2061
+ layer.meta.imageAdjustments = next;
2062
+ image.applyFilters();
2063
+ this.canvas.requestRenderAll();
2064
+ this.history.save();
2065
+ return true;
2066
+ }
2067
+ /** Combine two or more layers into a single editable group layer. */
2068
+ async groupLayers(ids, name = "Group") {
2069
+ const uniqueIds = [...new Set(ids)];
2070
+ const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
2071
+ if (children.length < 2 || children.length !== uniqueIds.length) return null;
2072
+ return this.history.transaction(() => {
2073
+ const childData = children.map((layer) => structuredClone(layer.toData()));
2074
+ const objects = children.map((layer) => layer.fabricObject);
2075
+ for (const layer of children) this.layers.remove(layer.id);
2076
+ const group = new Group(objects);
2077
+ const grouped = this.layers.add("group", group, name);
2078
+ grouped.meta.groupChildren = childData;
2079
+ this.layers.select(grouped.id);
2080
+ this.history.save();
2081
+ return grouped;
2082
+ });
2083
+ }
2084
+ /** Restore a group created by groupLayers back to its original layer records. */
2085
+ async ungroupLayer(id) {
2086
+ const grouped = this.layers.get(id);
2087
+ const childData = grouped?.meta.groupChildren;
2088
+ if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
2089
+ const group = grouped.fabricObject;
2090
+ return this.history.transaction(() => {
2091
+ const transform = group.calcTransformMatrix();
2092
+ const objects = group.removeAll();
2093
+ this.layers.remove(id);
2094
+ const restored = objects.map((object, index) => {
2095
+ util3.addTransformToObject(object, transform);
2096
+ object.setCoords();
2097
+ const data = childData[index];
2098
+ const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
2099
+ if (data?.meta) layer.meta = structuredClone(data.meta);
2100
+ if (data && !data.visible) this.layers.setVisibility(layer.id, false);
2101
+ if (data?.locked) this.layers.setLocked(layer.id, true);
2102
+ if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
2103
+ return layer;
2104
+ });
2105
+ this.history.save();
2106
+ this.layers.select(restored[0]?.id ?? null);
2107
+ return restored;
2108
+ });
2109
+ }
1096
2110
  // ─── Serialization ──────────────────────────────────
1097
2111
  toJSON() {
1098
2112
  return serializeEditor(this);
1099
2113
  }
1100
2114
  async fromJSON(state) {
1101
- await deserializeEditor(this, state);
1102
- this.patterns.repinAll();
2115
+ const managedByHistory = this.history.isRestoring();
2116
+ if (!managedByHistory) {
2117
+ this.history.saveImmediate();
2118
+ this.history.pause();
2119
+ }
2120
+ try {
2121
+ await deserializeEditor(this, state);
2122
+ this.patterns.repinAll();
2123
+ } catch (error) {
2124
+ if (!managedByHistory) {
2125
+ this.events.emit("error", { message: "Failed to load editor state", error });
2126
+ }
2127
+ throw error;
2128
+ } finally {
2129
+ if (!managedByHistory) this.history.resume();
2130
+ }
2131
+ if (!managedByHistory) {
2132
+ this.history.clear();
2133
+ this.history.saveImmediate();
2134
+ }
1103
2135
  }
1104
2136
  // ─── Export ──────────────────────────────────────────
1105
2137
  async toPNG(options) {
1106
- this.events.emit("export:start", { format: "png" });
1107
- const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
1108
- this.events.emit("export:complete", { format: "png" });
1109
- return blob;
2138
+ return this.toRaster(options?.format ?? "png", options);
2139
+ }
2140
+ async toJPEG(options) {
2141
+ return this.toRaster("jpeg", options);
2142
+ }
2143
+ async toWebP(options) {
2144
+ return this.toRaster("webp", options);
2145
+ }
2146
+ /** Export one layer in document coordinates or at its native image resolution. */
2147
+ async exportLayer(id, options = {}) {
2148
+ const layer = this.layers.get(id);
2149
+ if (!layer) throw new Error(`Layer not found: ${id}`);
2150
+ try {
2151
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
2152
+ const image = await layer.fabricObject.clone();
2153
+ image.set({
2154
+ left: 0,
2155
+ top: 0,
2156
+ originX: "left",
2157
+ originY: "top",
2158
+ scaleX: 1,
2159
+ scaleY: 1,
2160
+ angle: 0,
2161
+ flipX: false,
2162
+ flipY: false
2163
+ });
2164
+ return await exportIsolatedPNG(this.canvas, [image], {
2165
+ ...options,
2166
+ width: image.width || 1,
2167
+ height: image.height || 1,
2168
+ cloneObjects: false
2169
+ });
2170
+ }
2171
+ return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
2172
+ } catch (error) {
2173
+ this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
2174
+ throw error;
2175
+ }
2176
+ }
2177
+ /** Export only the configured document background, excluding design layers. */
2178
+ async exportBackground(options = {}) {
2179
+ try {
2180
+ return await exportIsolatedPNG(this.canvas, [], {
2181
+ ...options,
2182
+ backgroundColor: this.designBackground,
2183
+ backgroundImage: this.designBackgroundImage
2184
+ });
2185
+ } catch (error) {
2186
+ this.events.emit("error", { message: "Failed to export background", error });
2187
+ throw error;
2188
+ }
2189
+ }
2190
+ async toRaster(format, options) {
2191
+ this.events.emit("export:start", { format });
2192
+ try {
2193
+ await this.fonts.ready();
2194
+ const blob = await this.withDesignBackground(
2195
+ () => exportPNG(this.canvas, { ...options, format })
2196
+ );
2197
+ this.events.emit("export:complete", { format });
2198
+ this.licensing.track(`export:${format}`);
2199
+ return blob;
2200
+ } catch (error) {
2201
+ this.events.emit("error", {
2202
+ message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
2203
+ error
2204
+ });
2205
+ throw error;
2206
+ }
1110
2207
  }
1111
2208
  toSVG() {
1112
2209
  this.events.emit("export:start", { format: "svg" });
1113
- const svg = this.withDesignBackground(() => exportSVG(this.canvas));
1114
- this.events.emit("export:complete", { format: "svg" });
1115
- return svg;
2210
+ try {
2211
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
2212
+ this.events.emit("export:complete", { format: "svg" });
2213
+ this.licensing.track("export:svg");
2214
+ return svg;
2215
+ } catch (error) {
2216
+ this.events.emit("error", { message: "Failed to export SVG", error });
2217
+ throw error;
2218
+ }
2219
+ }
2220
+ async toSVGAsync(options = {}) {
2221
+ await this.fonts.ready();
2222
+ const svg = this.toSVG();
2223
+ if (options.embedFonts === false || this.fonts.getAll().length === 0) return svg;
2224
+ try {
2225
+ const css = await this.fonts.getEmbeddedCss();
2226
+ return svg.replace(/(<svg\b[^>]*>)/i, `$1<defs><style>${css}</style></defs>`);
2227
+ } catch (error) {
2228
+ this.events.emit("error", { message: "Failed to embed fonts in SVG", error });
2229
+ throw error;
2230
+ }
1116
2231
  }
1117
2232
  toDataURL(format = "png", multiplier = 1) {
1118
2233
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1119
2234
  }
2235
+ /** Export the current product-preview composite. Advanced warping is host-defined. */
2236
+ async toMockupImage(options = {}) {
2237
+ if (!this.mockup) throw new Error("No mockup is configured");
2238
+ await this.fonts.ready();
2239
+ try {
2240
+ const blob = await exportMockup(this.canvas, this.mockup, options);
2241
+ this.licensing.track(`export:mockup:${options.format ?? "png"}`);
2242
+ return blob;
2243
+ } catch (error) {
2244
+ this.events.emit("error", { message: "Failed to export mockup", error });
2245
+ throw error;
2246
+ }
2247
+ }
1120
2248
  /**
1121
2249
  * Run an export with the configured design background applied, even when a
1122
2250
  * mockup preview has forced the live canvas transparent — so exports reflect
@@ -1125,11 +2253,14 @@ var CanvasEditor = class {
1125
2253
  withDesignBackground(fn) {
1126
2254
  if (!this.mockup) return fn();
1127
2255
  const previewBg = this.canvas.backgroundColor;
2256
+ const previewImage = this.canvas.backgroundImage;
1128
2257
  this.canvas.backgroundColor = this.designBackground;
2258
+ this.canvas.backgroundImage = this.designBackgroundImage ?? void 0;
1129
2259
  try {
1130
2260
  return fn();
1131
2261
  } finally {
1132
2262
  this.canvas.backgroundColor = previewBg;
2263
+ this.canvas.backgroundImage = previewImage;
1133
2264
  }
1134
2265
  }
1135
2266
  toPrintifyPositioning() {
@@ -1144,7 +2275,8 @@ var CanvasEditor = class {
1144
2275
  for (const layer of imageLayers) {
1145
2276
  const obj = layer.fabricObject;
1146
2277
  const center = obj.getCenterPoint();
1147
- result[layer.name] = {
2278
+ const key = result[layer.name] ? `${layer.name}-${layer.id}` : layer.name;
2279
+ result[key] = {
1148
2280
  x: round2(center.x / canvasW),
1149
2281
  y: round2(center.y / canvasH),
1150
2282
  scale: round2(obj.scaleX ?? 1),
@@ -1153,26 +2285,92 @@ var CanvasEditor = class {
1153
2285
  }
1154
2286
  return result;
1155
2287
  }
2288
+ /** Provider-neutral, ID-stable placement data in 0..1 canvas coordinates. */
2289
+ toNormalizedPositioning() {
2290
+ const canvasWidth = this.canvas.getWidth();
2291
+ const canvasHeight = this.canvas.getHeight();
2292
+ return this.layers.getAll().map((layer) => {
2293
+ const object = layer.fabricObject;
2294
+ const center = object.getCenterPoint();
2295
+ const bounds = object.getBoundingRect();
2296
+ return {
2297
+ layerId: layer.id,
2298
+ name: layer.name,
2299
+ type: layer.type,
2300
+ centerX: round2(center.x / canvasWidth),
2301
+ centerY: round2(center.y / canvasHeight),
2302
+ width: round2(bounds.width / canvasWidth),
2303
+ height: round2(bounds.height / canvasHeight),
2304
+ scaleX: round2(object.scaleX ?? 1),
2305
+ scaleY: round2(object.scaleY ?? 1),
2306
+ angle: round2(object.angle ?? 0)
2307
+ };
2308
+ });
2309
+ }
2310
+ toProviderPositioning(adapter) {
2311
+ return adapter.map(this.toNormalizedPositioning(), {
2312
+ width: this.canvas.getWidth(),
2313
+ height: this.canvas.getHeight()
2314
+ });
2315
+ }
1156
2316
  // ─── File Operations ────────────────────────────────
1157
2317
  async save(filename, format) {
1158
2318
  if (!this.fileAdapter) return void 0;
1159
2319
  let data;
1160
2320
  if (format === "png") {
1161
2321
  data = await this.toPNG();
2322
+ } else if (format === "jpeg") {
2323
+ data = await this.toJPEG();
2324
+ } else if (format === "webp") {
2325
+ data = await this.toWebP();
1162
2326
  } else if (format === "svg") {
1163
- data = this.toSVG();
2327
+ data = await this.toSVGAsync();
1164
2328
  } else {
1165
2329
  data = JSON.stringify(this.toJSON(), null, 2);
1166
2330
  }
1167
- return this.fileAdapter.save(data, filename, format);
2331
+ try {
2332
+ return await this.fileAdapter.save(data, filename, format);
2333
+ } catch (error) {
2334
+ this.events.emit("error", { message: `Failed to save ${format}`, error });
2335
+ throw error;
2336
+ }
1168
2337
  }
1169
2338
  async uploadImage(file) {
1170
- if (!this.imageProvider) return void 0;
1171
- return this.imageProvider.upload(file);
2339
+ if (!this.imageProvider?.upload) return void 0;
2340
+ try {
2341
+ return await this.imageProvider.upload(file);
2342
+ } catch (error) {
2343
+ this.events.emit("error", { message: "Failed to upload image", error });
2344
+ throw error;
2345
+ }
1172
2346
  }
1173
2347
  async browseImages() {
1174
2348
  if (!this.imageProvider?.browse) return null;
1175
- return this.imageProvider.browse();
2349
+ try {
2350
+ return await this.imageProvider.browse();
2351
+ } catch (error) {
2352
+ this.events.emit("error", { message: "Failed to browse images", error });
2353
+ throw error;
2354
+ }
2355
+ }
2356
+ async searchImages(query, options) {
2357
+ if (!this.imageProvider?.search) return null;
2358
+ try {
2359
+ return await this.imageProvider.search(query, options);
2360
+ } catch (error) {
2361
+ this.events.emit("error", { message: "Failed to search images", error });
2362
+ throw error;
2363
+ }
2364
+ }
2365
+ /** Track provider usage, then insert its hotlinked image as a normal layer. */
2366
+ async addProviderImage(image, options) {
2367
+ try {
2368
+ await this.imageProvider?.trackUse?.(image);
2369
+ } catch (error) {
2370
+ this.events.emit("error", { message: "Failed to record image use", error });
2371
+ throw error;
2372
+ }
2373
+ return this.addImage(image.url, options);
1176
2374
  }
1177
2375
  setFileAdapter(adapter) {
1178
2376
  this.fileAdapter = adapter;
@@ -1188,15 +2386,140 @@ var CanvasEditor = class {
1188
2386
  this.canvas.requestRenderAll();
1189
2387
  }
1190
2388
  this.history.save();
2389
+ this.events.emit("canvas:modified", {});
1191
2390
  }
1192
2391
  getDesignBackground() {
1193
2392
  return this.designBackground;
1194
2393
  }
1195
- resize(width, height) {
2394
+ getDesignBackgroundImage() {
2395
+ return this.designBackgroundImage;
2396
+ }
2397
+ getBackgroundImageOptions() {
2398
+ return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
2399
+ }
2400
+ async setBackgroundImage(url, options = {}) {
2401
+ if (url === null) {
2402
+ this.setBackgroundImageObject(null);
2403
+ return;
2404
+ }
2405
+ try {
2406
+ const image = await FabricImage3.fromURL(
2407
+ url,
2408
+ { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
2409
+ { originX: "left", originY: "top" }
2410
+ );
2411
+ const width = image.width || 1;
2412
+ const height = image.height || 1;
2413
+ const canvasWidth = this.canvas.getWidth();
2414
+ const canvasHeight = this.canvas.getHeight();
2415
+ const fit = options.fit ?? "cover";
2416
+ const sx = canvasWidth / width;
2417
+ const sy = canvasHeight / height;
2418
+ const scaleX = fit === "stretch" ? sx : fit === "contain" ? Math.min(sx, sy) : Math.max(sx, sy);
2419
+ const scaleY = fit === "stretch" ? sy : scaleX;
2420
+ image.set({
2421
+ left: (canvasWidth - width * scaleX) / 2,
2422
+ top: (canvasHeight - height * scaleY) / 2,
2423
+ scaleX,
2424
+ scaleY,
2425
+ opacity: clamp(options.opacity ?? 1, 0, 1),
2426
+ selectable: false,
2427
+ evented: false
2428
+ });
2429
+ const serializableOptions = { ...options };
2430
+ delete serializableOptions.signal;
2431
+ this.setBackgroundImageObject(image, true, serializableOptions);
2432
+ } catch (error) {
2433
+ this.events.emit("error", { message: "Failed to set background image", error });
2434
+ throw error;
2435
+ }
2436
+ }
2437
+ /** Used by state restoration and advanced integrations with an existing Fabric object. */
2438
+ setBackgroundImageObject(image, save = true, options = null) {
2439
+ this.designBackgroundImage = image;
2440
+ this.backgroundImageOptions = image ? options : null;
2441
+ this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
2442
+ this.canvas.requestRenderAll();
2443
+ if (save) this.history.save();
2444
+ this.events.emit("canvas:modified", {});
2445
+ }
2446
+ setTransparentBackground() {
2447
+ this.setBackground("");
2448
+ }
2449
+ resize(width, height, options = {}) {
1196
2450
  const widthPx = this.units.toPixels(width);
1197
2451
  const heightPx = this.units.toPixels(height);
2452
+ const oldWidth = this.canvas.getWidth();
2453
+ const oldHeight = this.canvas.getHeight();
2454
+ const scaleContent = options.scaleContent ?? true;
2455
+ if (scaleContent && oldWidth > 0 && oldHeight > 0) {
2456
+ const sx = widthPx / oldWidth;
2457
+ const sy = heightPx / oldHeight;
2458
+ for (const layer of this.layers.getAll()) {
2459
+ if (layer.meta.pattern) continue;
2460
+ const object = layer.fabricObject;
2461
+ object.set({
2462
+ left: (object.left ?? 0) * sx,
2463
+ top: (object.top ?? 0) * sy,
2464
+ scaleX: (object.scaleX ?? 1) * sx,
2465
+ scaleY: (object.scaleY ?? 1) * sy
2466
+ });
2467
+ object.setCoords();
2468
+ }
2469
+ if (this.designBackgroundImage) {
2470
+ this.designBackgroundImage.set({
2471
+ left: (this.designBackgroundImage.left ?? 0) * sx,
2472
+ top: (this.designBackgroundImage.top ?? 0) * sy,
2473
+ scaleX: (this.designBackgroundImage.scaleX ?? 1) * sx,
2474
+ scaleY: (this.designBackgroundImage.scaleY ?? 1) * sy
2475
+ });
2476
+ this.designBackgroundImage.setCoords();
2477
+ }
2478
+ }
1198
2479
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
2480
+ this.patterns.repinAll();
1199
2481
  this.canvas.requestRenderAll();
2482
+ this.history.save();
2483
+ this.events.emit("canvas:modified", {});
2484
+ }
2485
+ /** Effective source resolution for an image at its current physical size. */
2486
+ getImageDpi(layerId) {
2487
+ const layer = this.layers.get(layerId);
2488
+ if (!layer || layer.type !== "image") return null;
2489
+ const image = layer.fabricObject;
2490
+ const sourcePixels = image.width ?? 0;
2491
+ const displayedPixels = image.getScaledWidth();
2492
+ if (sourcePixels <= 0 || displayedPixels <= 0) return null;
2493
+ return round2(sourcePixels / (displayedPixels / this.units.getDpi()));
2494
+ }
2495
+ validateImageDpi(minimumDpi = 300) {
2496
+ return this.layers.getAll().filter((layer) => layer.type === "image" && !layer.meta.pattern).flatMap((layer) => {
2497
+ const effectiveDpi = this.getImageDpi(layer.id);
2498
+ return effectiveDpi !== null && effectiveDpi < minimumDpi ? [{ layerId: layer.id, layerName: layer.name, effectiveDpi, minimumDpi }] : [];
2499
+ });
2500
+ }
2501
+ /** Render a small data-URL preview without changing document dimensions. */
2502
+ toThumbnail(maxWidth = 320, maxHeight = 320, format = "png") {
2503
+ const multiplier = Math.min(
2504
+ 1,
2505
+ maxWidth / this.canvas.getWidth(),
2506
+ maxHeight / this.canvas.getHeight()
2507
+ );
2508
+ return this.toDataURL(format, multiplier);
2509
+ }
2510
+ bringToFront(id) {
2511
+ return this.layers.reorder(id, this.layers.count() - 1);
2512
+ }
2513
+ sendToBack(id) {
2514
+ return this.layers.reorder(id, 0);
2515
+ }
2516
+ bringForward(id) {
2517
+ const index = this.layers.getAll().findIndex((layer) => layer.id === id);
2518
+ return index >= 0 && this.layers.reorder(id, index + 1);
2519
+ }
2520
+ sendBackward(id) {
2521
+ const index = this.layers.getAll().findIndex((layer) => layer.id === id);
2522
+ return index >= 0 && this.layers.reorder(id, index - 1);
1200
2523
  }
1201
2524
  // ─── Zoom ───────────────────────────────────────────
1202
2525
  //
@@ -1234,6 +2557,20 @@ var CanvasEditor = class {
1234
2557
  const sy = (viewportHeight - padding * 2) / h;
1235
2558
  this.setZoom(Math.min(sx, sy));
1236
2559
  }
2560
+ /** Zoom until the current selection fills the artboard viewport. */
2561
+ zoomToSelection(padding = 24) {
2562
+ const active = this.canvas.getActiveObject();
2563
+ if (!active) return;
2564
+ active.setCoords();
2565
+ const bounds = active.getBoundingRect();
2566
+ if (bounds.width <= 0 || bounds.height <= 0) return;
2567
+ this.setZoom(
2568
+ Math.min(
2569
+ (this.canvas.getWidth() - padding * 2) / bounds.width,
2570
+ (this.canvas.getHeight() - padding * 2) / bounds.height
2571
+ )
2572
+ );
2573
+ }
1237
2574
  // ─── Patterns ───────────────────────────────────────
1238
2575
  applyPattern(layerId, config) {
1239
2576
  return this.patterns.apply(layerId, config);
@@ -1245,8 +2582,10 @@ var CanvasEditor = class {
1245
2582
  setMockup(mockup) {
1246
2583
  this.mockup = mockup;
1247
2584
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2585
+ this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
1248
2586
  this.canvas.requestRenderAll();
1249
2587
  this.events.emit("mockup:changed", { mockup });
2588
+ this.history.save();
1250
2589
  }
1251
2590
  clearMockup() {
1252
2591
  this.setMockup(null);
@@ -1256,9 +2595,11 @@ var CanvasEditor = class {
1256
2595
  }
1257
2596
  // ─── Cleanup ────────────────────────────────────────
1258
2597
  dispose() {
2598
+ this.masks.dispose();
1259
2599
  this.snapping.dispose();
1260
2600
  this.crop.dispose();
1261
2601
  this.history.dispose();
2602
+ clearPatternImageCache();
1262
2603
  this.events.removeAllListeners();
1263
2604
  this.canvas.dispose();
1264
2605
  }
@@ -1299,15 +2640,73 @@ var DEFAULT_PATTERN_CONFIG = {
1299
2640
  rotationStepH: 0,
1300
2641
  rotationStepV: 0
1301
2642
  };
2643
+
2644
+ // src/presets.ts
2645
+ var CANVAS_SIZE_PRESETS = [
2646
+ { id: "a4-portrait", name: "A4 portrait", width: 210, height: 297, unit: "mm", dpi: 300 },
2647
+ { id: "a4-landscape", name: "A4 landscape", width: 297, height: 210, unit: "mm", dpi: 300 },
2648
+ { id: "us-letter", name: "US Letter", width: 8.5, height: 11, unit: "in", dpi: 300 },
2649
+ { id: "shirt-front", name: "Garment front", width: 12, height: 16, unit: "in", dpi: 300 },
2650
+ { id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
2651
+ { id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
2652
+ ];
2653
+
2654
+ // src/annotations.ts
2655
+ var AnnotationOverlay = class {
2656
+ items = /* @__PURE__ */ new Map();
2657
+ transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
2658
+ set(annotation) {
2659
+ this.items.set(annotation.id, structuredClone(annotation));
2660
+ }
2661
+ remove(id) {
2662
+ return this.items.delete(id);
2663
+ }
2664
+ clear() {
2665
+ this.items.clear();
2666
+ }
2667
+ getAll() {
2668
+ return [...this.items.values()].map((item) => structuredClone(item));
2669
+ }
2670
+ setTransform(transform) {
2671
+ if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
2672
+ throw new Error("Annotation zoom must be positive");
2673
+ }
2674
+ this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
2675
+ }
2676
+ documentToViewport(point) {
2677
+ return {
2678
+ x: point.x * this.transform.zoom + this.transform.panX,
2679
+ y: point.y * this.transform.zoom + this.transform.panY
2680
+ };
2681
+ }
2682
+ viewportToDocument(point) {
2683
+ return {
2684
+ x: (point.x - this.transform.panX) / this.transform.zoom,
2685
+ y: (point.y - this.transform.panY) / this.transform.zoom
2686
+ };
2687
+ }
2688
+ documentToDevice(point) {
2689
+ const viewport = this.documentToViewport(point);
2690
+ const ratio = this.transform.devicePixelRatio ?? 1;
2691
+ return { x: viewport.x * ratio, y: viewport.y * ratio };
2692
+ }
2693
+ };
1302
2694
  export {
2695
+ AnnotationOverlay,
2696
+ CANVAS_SIZE_PRESETS,
1303
2697
  CanvasEditor,
1304
2698
  CropController,
1305
2699
  DEFAULT_PATTERN_CONFIG,
1306
2700
  EventEmitter,
2701
+ FontRegistry,
1307
2702
  HistoryManager,
1308
2703
  Layer,
1309
2704
  LayerManager,
2705
+ LicenseManager,
2706
+ MaskController,
2707
+ MaskRefinementError,
1310
2708
  PatternManager,
2709
+ ProjectManager,
1311
2710
  SnapManager,
1312
2711
  UnitConverter,
1313
2712
  applyPatternLocks,
@@ -1315,15 +2714,23 @@ export {
1315
2714
  captureLocks,
1316
2715
  clamp,
1317
2716
  clearPatternImageCache,
2717
+ computeCoverPlacement,
2718
+ computePrintAreaClip,
1318
2719
  computeTilePositions,
1319
2720
  deserializeEditor,
2721
+ displaceRgba,
1320
2722
  drawTiles,
2723
+ escapeXml,
1321
2724
  exportDataURL,
2725
+ exportMockup,
1322
2726
  exportPNG,
1323
2727
  exportSVG,
1324
2728
  generateId,
2729
+ isCssColor,
2730
+ loadPatternImage,
1325
2731
  restoreLocks,
1326
2732
  round2,
2733
+ sanitizeSvg,
1327
2734
  serializeEditor
1328
2735
  };
1329
2736
  //# sourceMappingURL=index.mjs.map