@overtone-art/canvas-editor-core 0.2.5 → 0.2.7

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,14 @@
1
+ import {
2
+ computeCoverPlacement,
3
+ computePrintAreaClip,
4
+ exportDataURL,
5
+ exportMockup,
6
+ exportPNG,
7
+ exportSVG
8
+ } from "./chunk-NINRTPOJ.mjs";
9
+
1
10
  // src/editor.ts
2
- import { Canvas, FabricImage as FabricImage2, Textbox } from "fabric";
11
+ import { Canvas, FabricImage as FabricImage2, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
3
12
 
4
13
  // src/events.ts
5
14
  var EventEmitter = class {
@@ -91,6 +100,10 @@ var LayerManager = class {
91
100
  canvas;
92
101
  events;
93
102
  layers = [];
103
+ onPropertyChanged;
104
+ setHistoryCallback(callback) {
105
+ this.onPropertyChanged = callback;
106
+ }
94
107
  add(type, fabricObject, name, id) {
95
108
  const layer = new Layer(type, fabricObject, name, id);
96
109
  this.layers.push(layer);
@@ -107,12 +120,39 @@ var LayerManager = class {
107
120
  this.layers.splice(index, 1);
108
121
  this.events.emit("layer:removed", { layerId: id });
109
122
  this.emitChanged();
123
+ this.onPropertyChanged?.();
124
+ return true;
125
+ }
126
+ /** Replace a layer's render object while preserving its immutable ID and panel state. */
127
+ replaceObject(id, fabricObject) {
128
+ const layer = this.get(id);
129
+ if (!layer || layer.fabricObject === fabricObject) return false;
130
+ const previous = layer.fabricObject;
131
+ const stackIndex = this.canvas.getObjects().indexOf(previous);
132
+ const wasActive = this.canvas.getActiveObject() === previous;
133
+ this.canvas.remove(previous);
134
+ layer.fabricObject = fabricObject;
135
+ fabricObject._layerId = id;
136
+ fabricObject.set({
137
+ visible: layer.visible,
138
+ opacity: layer.opacity,
139
+ selectable: !layer.locked,
140
+ evented: !layer.locked
141
+ });
142
+ this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
143
+ if (wasActive) this.canvas.setActiveObject(fabricObject);
144
+ this.canvas.requestRenderAll();
145
+ this.events.emit("layer:modified", { layerId: id });
146
+ this.emitChanged();
147
+ this.onPropertyChanged?.();
110
148
  return true;
111
149
  }
112
150
  reorder(id, newIndex) {
113
151
  const oldIndex = this.layers.findIndex((l) => l.id === id);
114
152
  if (oldIndex === -1) return false;
115
- const clamped = Math.max(0, Math.min(this.layers.length - 1, newIndex));
153
+ if (!Number.isFinite(newIndex)) return false;
154
+ const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
155
+ if (oldIndex === clamped) return false;
116
156
  const [layer] = this.layers.splice(oldIndex, 1);
117
157
  this.layers.splice(clamped, 0, layer);
118
158
  this.layers.forEach((l, i) => {
@@ -120,6 +160,7 @@ var LayerManager = class {
120
160
  });
121
161
  this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
122
162
  this.emitChanged();
163
+ this.onPropertyChanged?.();
123
164
  return true;
124
165
  }
125
166
  select(id) {
@@ -150,33 +191,42 @@ var LayerManager = class {
150
191
  setVisibility(id, visible) {
151
192
  const layer = this.get(id);
152
193
  if (!layer) return;
194
+ if (layer.visible === visible) return;
153
195
  layer.visible = visible;
154
196
  layer.fabricObject.visible = visible;
155
197
  this.canvas.requestRenderAll();
156
198
  this.emitChanged();
199
+ this.onPropertyChanged?.();
157
200
  }
158
201
  setLocked(id, locked) {
159
202
  const layer = this.get(id);
160
203
  if (!layer) return;
204
+ if (layer.locked === locked) return;
161
205
  layer.locked = locked;
162
206
  layer.fabricObject.selectable = !locked;
163
207
  layer.fabricObject.evented = !locked;
164
208
  this.canvas.requestRenderAll();
165
209
  this.emitChanged();
210
+ this.onPropertyChanged?.();
166
211
  }
167
212
  setOpacity(id, opacity) {
168
213
  const layer = this.get(id);
169
- if (!layer) return;
170
- layer.opacity = opacity;
171
- layer.fabricObject.opacity = opacity;
214
+ if (!layer || !Number.isFinite(opacity)) return;
215
+ const next = Math.max(0, Math.min(1, opacity));
216
+ if (layer.opacity === next) return;
217
+ layer.opacity = next;
218
+ layer.fabricObject.opacity = next;
172
219
  this.canvas.requestRenderAll();
173
220
  this.emitChanged();
221
+ this.onPropertyChanged?.();
174
222
  }
175
223
  setName(id, name) {
176
224
  const layer = this.get(id);
177
225
  if (!layer) return;
226
+ if (layer.name === name) return;
178
227
  layer.name = name;
179
228
  this.emitChanged();
229
+ this.onPropertyChanged?.();
180
230
  }
181
231
  clear() {
182
232
  for (const layer of this.layers) {
@@ -200,57 +250,124 @@ var HistoryManager = class {
200
250
  undoStack = [];
201
251
  redoStack = [];
202
252
  maxSize;
253
+ maxBytes;
203
254
  paused = false;
204
255
  debounceTimer = null;
205
256
  debounceMs;
206
257
  getState;
207
258
  restoreState;
208
259
  events;
260
+ transactionDepth = 0;
261
+ transactionDirty = false;
209
262
  constructor(opts) {
210
263
  this.getState = opts.getState;
211
264
  this.restoreState = opts.restoreState;
212
265
  this.events = opts.events;
213
266
  this.maxSize = opts.maxSize ?? 50;
267
+ this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
214
268
  this.debounceMs = opts.debounceMs ?? 300;
215
269
  }
216
270
  save() {
217
271
  if (this.paused) return;
272
+ if (this.transactionDepth > 0) {
273
+ this.transactionDirty = true;
274
+ return;
275
+ }
218
276
  if (this.debounceTimer) {
219
277
  clearTimeout(this.debounceTimer);
220
278
  }
221
279
  this.debounceTimer = setTimeout(() => {
222
280
  this.saveImmediate();
223
281
  }, this.debounceMs);
282
+ this.emitChanged();
224
283
  }
225
284
  saveImmediate() {
226
- if (this.paused) return;
285
+ if (this.paused) {
286
+ this.cancelPending();
287
+ return;
288
+ }
289
+ if (this.transactionDepth > 0) {
290
+ this.transactionDirty = true;
291
+ return;
292
+ }
293
+ this.cancelPending();
227
294
  const state = this.getState();
295
+ if (this.undoStack.at(-1) === state) {
296
+ this.emitChanged();
297
+ return;
298
+ }
228
299
  this.undoStack.push(state);
229
- if (this.undoStack.length > this.maxSize) {
300
+ while (this.undoStack.length > this.maxSize) {
230
301
  this.undoStack.shift();
231
302
  }
232
303
  this.redoStack = [];
304
+ this.trimToBudget();
305
+ this.events.emit("history:snapshot", {
306
+ bytes: state.length * 2,
307
+ totalBytes: this.snapshotBytes(),
308
+ entries: this.undoStack.length
309
+ });
233
310
  this.emitChanged();
234
311
  }
312
+ beginTransaction() {
313
+ if (this.transactionDepth === 0 && this.debounceTimer) this.saveImmediate();
314
+ this.transactionDepth += 1;
315
+ }
316
+ endTransaction() {
317
+ if (this.transactionDepth === 0) return;
318
+ this.transactionDepth -= 1;
319
+ if (this.transactionDepth === 0 && this.transactionDirty) {
320
+ this.transactionDirty = false;
321
+ this.saveImmediate();
322
+ }
323
+ }
324
+ async transaction(operation) {
325
+ this.beginTransaction();
326
+ try {
327
+ return await operation();
328
+ } finally {
329
+ this.endTransaction();
330
+ }
331
+ }
235
332
  async undo() {
236
333
  this.cancelPending();
237
- const state = this.undoStack.pop();
238
- if (!state) return;
239
- this.redoStack.push(this.getState());
334
+ const current = this.getState();
335
+ const committed = this.undoStack.at(-1);
336
+ if (!committed) return;
337
+ const currentIsCommitted = current === committed;
338
+ if (currentIsCommitted && this.undoStack.length < 2) return;
339
+ const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
240
340
  this.paused = true;
241
- await this.restoreState(state);
242
- this.paused = false;
243
- this.emitChanged();
341
+ try {
342
+ await this.restoreState(target);
343
+ if (currentIsCommitted) this.undoStack.pop();
344
+ this.redoStack.push(current);
345
+ this.trimToBudget();
346
+ } catch (error) {
347
+ this.events.emit("error", { message: "Failed to undo the last change", error });
348
+ throw error;
349
+ } finally {
350
+ this.paused = false;
351
+ this.emitChanged();
352
+ }
244
353
  }
245
354
  async redo() {
246
355
  this.cancelPending();
247
- const state = this.redoStack.pop();
356
+ const state = this.redoStack.at(-1);
248
357
  if (!state) return;
249
- this.undoStack.push(this.getState());
250
358
  this.paused = true;
251
- await this.restoreState(state);
252
- this.paused = false;
253
- this.emitChanged();
359
+ try {
360
+ await this.restoreState(state);
361
+ this.redoStack.pop();
362
+ if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
363
+ this.trimToBudget();
364
+ } catch (error) {
365
+ this.events.emit("error", { message: "Failed to redo the last change", error });
366
+ throw error;
367
+ } finally {
368
+ this.paused = false;
369
+ this.emitChanged();
370
+ }
254
371
  }
255
372
  /** True while a restore (undo/redo/deserialize) is in flight. Managers that
256
373
  * react to canvas events should skip mutating history during this window. */
@@ -264,16 +381,20 @@ var HistoryManager = class {
264
381
  this.paused = false;
265
382
  }
266
383
  canUndo() {
267
- return this.undoStack.length > 0;
384
+ return this.undoStack.length > 1 || this.debounceTimer !== null;
268
385
  }
269
386
  canRedo() {
270
387
  return this.redoStack.length > 0;
271
388
  }
272
389
  clear() {
390
+ this.cancelPending();
273
391
  this.undoStack = [];
274
392
  this.redoStack = [];
275
393
  this.emitChanged();
276
394
  }
395
+ getSnapshotBytes() {
396
+ return this.snapshotBytes();
397
+ }
277
398
  cancelPending() {
278
399
  if (this.debounceTimer) {
279
400
  clearTimeout(this.debounceTimer);
@@ -281,9 +402,9 @@ var HistoryManager = class {
281
402
  }
282
403
  }
283
404
  dispose() {
284
- if (this.debounceTimer) {
285
- clearTimeout(this.debounceTimer);
286
- }
405
+ this.cancelPending();
406
+ this.transactionDepth = 0;
407
+ this.transactionDirty = false;
287
408
  }
288
409
  emitChanged() {
289
410
  this.events.emit("history:changed", {
@@ -291,6 +412,20 @@ var HistoryManager = class {
291
412
  canRedo: this.canRedo()
292
413
  });
293
414
  }
415
+ snapshotBytes() {
416
+ return [...this.undoStack, ...this.redoStack].reduce(
417
+ (total, state) => total + state.length * 2,
418
+ 0
419
+ );
420
+ }
421
+ trimToBudget() {
422
+ while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
423
+ this.undoStack.shift();
424
+ }
425
+ while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
426
+ this.redoStack.shift();
427
+ }
428
+ }
294
429
  };
295
430
 
296
431
  // src/snapping.ts
@@ -510,6 +645,10 @@ var CropController = class {
510
645
  top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
511
646
  });
512
647
  image.setCoords();
648
+ if (s.prevAngle) {
649
+ image.rotate(s.prevAngle);
650
+ image.setCoords();
651
+ }
513
652
  this.finish();
514
653
  this.history.save();
515
654
  }
@@ -544,14 +683,18 @@ var STROKE2 = "#22c55e";
544
683
  import { util } from "fabric";
545
684
  var MAX_TILES_PER_AXIS = 200;
546
685
  var PatternManager = class {
547
- constructor(canvas, layers, history) {
686
+ constructor(canvas, layers, history, events, sourceResolver) {
548
687
  this.canvas = canvas;
549
688
  this.layers = layers;
550
689
  this.history = history;
690
+ this.events = events;
691
+ this.sourceResolver = sourceResolver;
551
692
  }
552
693
  canvas;
553
694
  layers;
554
695
  history;
696
+ events;
697
+ sourceResolver;
555
698
  // Per-layer task chain. apply()/disable() both await an async setSrc on the
556
699
  // same fabric image; running two concurrently lets their setSrc resolutions
557
700
  // interleave (wrong image installed, original lost). Serialising per layer
@@ -599,6 +742,9 @@ var PatternManager = class {
599
742
  throw err;
600
743
  }
601
744
  this.history.save();
745
+ }).catch((error) => {
746
+ this.events.emit("error", { message: "Failed to apply image pattern", error });
747
+ throw error;
602
748
  });
603
749
  }
604
750
  /**
@@ -654,6 +800,9 @@ var PatternManager = class {
654
800
  delete layer.meta.pattern;
655
801
  this.canvas.requestRenderAll();
656
802
  this.history.save();
803
+ }).catch((error) => {
804
+ this.events.emit("error", { message: "Failed to clear image pattern", error });
805
+ throw error;
657
806
  });
658
807
  }
659
808
  /** Run `task` after any in-flight work for this layer, regardless of outcome. */
@@ -683,7 +832,8 @@ var PatternManager = class {
683
832
  cw,
684
833
  ch,
685
834
  tileW,
686
- tileH
835
+ tileH,
836
+ this.sourceResolver
687
837
  );
688
838
  await image.setSrc(dataUrl);
689
839
  image.set({
@@ -754,14 +904,23 @@ function elementToDataURL(image) {
754
904
  }
755
905
  var IMAGE_CACHE_MAX = 16;
756
906
  var imageCache = /* @__PURE__ */ new Map();
757
- function loadImage(src) {
907
+ function loadPatternImage(src, resolver) {
758
908
  const cached = imageCache.get(src);
759
909
  if (cached) {
760
910
  imageCache.delete(src);
761
911
  imageCache.set(src, cached);
762
912
  return cached;
763
913
  }
764
- const promise = decodeImage(src);
914
+ const promise = decodeImage(src).catch(async (originalError) => {
915
+ if (!resolver) throw originalError;
916
+ const resolved = await resolver(src);
917
+ if (!resolved || resolved === src) {
918
+ throw new Error("Pattern source resolver did not return a usable alternate URL", {
919
+ cause: originalError
920
+ });
921
+ }
922
+ return decodeImage(resolved);
923
+ });
765
924
  promise.catch(() => {
766
925
  if (imageCache.get(src) === promise) imageCache.delete(src);
767
926
  });
@@ -784,8 +943,8 @@ function decodeImage(src) {
784
943
  function clearPatternImageCache() {
785
944
  imageCache.clear();
786
945
  }
787
- async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
788
- const img = await loadImage(src);
946
+ async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
947
+ const img = await loadPatternImage(src, sourceResolver);
789
948
  const off = document.createElement("canvas");
790
949
  off.width = Math.max(1, Math.round(targetW));
791
950
  off.height = Math.max(1, Math.round(targetH));
@@ -845,16 +1004,18 @@ function mod2(n) {
845
1004
  // src/utils/units.ts
846
1005
  var MM_PER_INCH = 25.4;
847
1006
  var UnitConverter = class {
1007
+ unit;
1008
+ dpi;
848
1009
  constructor(unit = "px", dpi = 72) {
849
1010
  this.unit = unit;
850
- this.dpi = dpi;
1011
+ this.dpi = 72;
1012
+ this.setDpi(dpi);
851
1013
  }
852
- unit;
853
- dpi;
854
1014
  setUnit(unit) {
855
1015
  this.unit = unit;
856
1016
  }
857
1017
  setDpi(dpi) {
1018
+ if (!Number.isFinite(dpi) || dpi <= 0) throw new Error("DPI must be a positive number");
858
1019
  this.dpi = dpi;
859
1020
  }
860
1021
  getUnit() {
@@ -889,38 +1050,72 @@ var UnitConverter = class {
889
1050
 
890
1051
  // src/serialization.ts
891
1052
  import { util as util2 } from "fabric";
892
- var VERSION = "1.0.0";
1053
+
1054
+ // src/utils/color.ts
1055
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1056
+ function isCssColor(value, allowEmpty = false) {
1057
+ if (typeof value !== "string") return false;
1058
+ const trimmed = value.trim();
1059
+ if (!trimmed) return allowEmpty;
1060
+ return CSS_COLOR.test(trimmed);
1061
+ }
1062
+
1063
+ // src/serialization.ts
1064
+ var VERSION = "2.0.0";
893
1065
  function serializeEditor(editor) {
894
1066
  return {
895
1067
  version: VERSION,
896
1068
  canvas: {
897
1069
  width: editor.canvas.getWidth(),
898
- height: editor.canvas.getHeight()
1070
+ height: editor.canvas.getHeight(),
1071
+ unit: editor.units.getUnit(),
1072
+ dpi: editor.units.getDpi()
899
1073
  },
900
1074
  layers: editor.layers.getAll().map((layer) => layer.serialize()),
901
1075
  // The configured design background, not the live canvas value (which is
902
1076
  // forced transparent while a mockup preview is active).
903
1077
  background: editor.getDesignBackground(),
1078
+ backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
904
1079
  mockup: editor.getMockup()
905
1080
  };
906
1081
  }
907
1082
  async function deserializeEditor(editor, state) {
1083
+ if (!state || !state.canvas || !Array.isArray(state.layers)) {
1084
+ throw new Error("Invalid editor state");
1085
+ }
1086
+ 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)) {
1087
+ throw new Error("Invalid editor canvas settings");
1088
+ }
1089
+ const major = Number.parseInt(state.version?.split(".")[0] ?? "1", 10);
1090
+ if (!Number.isFinite(major) || major > 2) {
1091
+ throw new Error(`Unsupported editor state version: ${state.version}`);
1092
+ }
1093
+ if (state.background !== void 0 && !isCssColor(state.background, true)) {
1094
+ throw new Error("Invalid editor background color");
1095
+ }
1096
+ const staged = await Promise.all(
1097
+ state.layers.map(async (serialized) => ({
1098
+ serialized,
1099
+ fabricObject: (await util2.enlivenObjects([serialized.fabricObject]))[0]
1100
+ }))
1101
+ );
1102
+ const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
1103
+ editor.crop.cancel();
908
1104
  editor.layers.clear();
1105
+ if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
1106
+ if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
909
1107
  editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
910
- if (state.background) {
1108
+ if (state.background !== void 0) {
911
1109
  editor.setBackground(state.background);
912
1110
  }
913
- if (state.mockup !== void 0) {
914
- editor.setMockup(state.mockup);
915
- }
916
- for (const serializedLayer of state.layers) {
917
- await restoreLayer(editor, serializedLayer);
1111
+ editor.setBackgroundImageObject(stagedBackground, false);
1112
+ editor.setMockup(state.mockup ?? null);
1113
+ for (const item of staged) {
1114
+ restoreLayer(editor, item.serialized, item.fabricObject);
918
1115
  }
919
1116
  editor.canvas.requestRenderAll();
920
1117
  }
921
- async function restoreLayer(editor, serialized) {
922
- const objects = await util2.enlivenObjects([serialized.fabricObject]);
923
- const fabricObject = objects[0];
1118
+ function restoreLayer(editor, serialized, fabricObject) {
924
1119
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
925
1120
  if (serialized.meta) {
926
1121
  layer.meta = serialized.meta;
@@ -937,23 +1132,318 @@ async function restoreLayer(editor, serialized) {
937
1132
  return layer;
938
1133
  }
939
1134
 
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
1135
+ // src/utils/svg.ts
1136
+ function escapeXml(value) {
1137
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
1138
+ }
1139
+ function sanitizeSvg(svg) {
1140
+ const document2 = new DOMParser().parseFromString(svg, "image/svg+xml");
1141
+ if (document2.querySelector("parsererror")) throw new Error("Invalid template SVG");
1142
+ document2.querySelectorAll("script, foreignObject, iframe, object, embed, link, style").forEach((node) => node.remove());
1143
+ document2.querySelectorAll("*").forEach((node) => {
1144
+ for (const attribute of [...node.attributes]) {
1145
+ const name = attribute.name.toLowerCase();
1146
+ const value = attribute.value.trim().toLowerCase();
1147
+ const isLink = name === "href" || name === "xlink:href" || name === "src";
1148
+ const safeLink = value.startsWith("#") || /^data:image\/(?:png|jpeg|webp|gif);base64,/.test(value);
1149
+ if (name.startsWith("on") || isLink && !safeLink || /url\s*\(/.test(value) && !/url\s*\(\s*['"]?#/.test(value) || /(?:javascript:|expression\s*\()/.test(value)) {
1150
+ node.removeAttribute(attribute.name);
1151
+ }
1152
+ }
947
1153
  });
948
- const response = await fetch(dataUrl);
949
- return response.blob();
1154
+ return new XMLSerializer().serializeToString(document2.documentElement);
950
1155
  }
951
- function exportSVG(canvas) {
952
- return canvas.toSVG();
1156
+
1157
+ // src/fonts.ts
1158
+ function fontSource(source) {
1159
+ return /^(?:url|local)\(/.test(source.trim()) ? source : `url(${JSON.stringify(source)})`;
1160
+ }
1161
+ function mimeForSource(source) {
1162
+ const path = source.split(/[?#]/)[0].toLowerCase();
1163
+ if (path.endsWith(".woff2")) return "font/woff2";
1164
+ if (path.endsWith(".woff")) return "font/woff";
1165
+ if (path.endsWith(".otf")) return "font/otf";
1166
+ return "font/ttf";
1167
+ }
1168
+ function arrayBufferToBase64(buffer) {
1169
+ const bytes = new Uint8Array(buffer);
1170
+ let binary = "";
1171
+ for (let index = 0; index < bytes.length; index += 32768) {
1172
+ binary += String.fromCharCode(...bytes.subarray(index, index + 32768));
1173
+ }
1174
+ return btoa(binary);
1175
+ }
1176
+ function cssString(value) {
1177
+ return JSON.stringify(value).replace(/[<>&]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
1178
+ }
1179
+ var SAFE_WEIGHT = /^(?:normal|bold|bolder|lighter|[1-9]\d{0,2}(?:\s+[1-9]\d{0,2})?)$/i;
1180
+ var SAFE_STYLE = /^(?:normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg)?)$/i;
1181
+ var SAFE_DISPLAY = /^(?:auto|block|swap|fallback|optional)$/i;
1182
+ function cssKeyword(value, pattern, fallback) {
1183
+ const trimmed = value?.trim();
1184
+ return trimmed && pattern.test(trimmed) ? trimmed : fallback;
1185
+ }
1186
+ function sourceUrl(source) {
1187
+ const trimmed = source.trim();
1188
+ if (trimmed.startsWith("data:")) return trimmed;
1189
+ const match = trimmed.match(/^url\(\s*(['"]?)(.*?)\1\s*\)/i);
1190
+ if (match) return match[2];
1191
+ if (/^local\(/i.test(trimmed)) return null;
1192
+ return trimmed;
1193
+ }
1194
+ function localName(source) {
1195
+ const match = source.trim().match(/^local\(\s*(['"]?)([^)"'{};]*)\1\s*\)$/i);
1196
+ return match ? match[2].trim() || null : null;
1197
+ }
1198
+ var FontRegistry = class {
1199
+ definitions = /* @__PURE__ */ new Map();
1200
+ loads = /* @__PURE__ */ new Map();
1201
+ register(definition) {
1202
+ if (!definition.family.trim() || !definition.source.trim()) {
1203
+ throw new Error("Font family and source are required");
1204
+ }
1205
+ this.definitions.set(definition.family, { ...definition });
1206
+ this.loads.delete(definition.family);
1207
+ }
1208
+ unregister(family) {
1209
+ this.loads.delete(family);
1210
+ return this.definitions.delete(family);
1211
+ }
1212
+ getAll() {
1213
+ return [...this.definitions.values()].map((definition) => ({ ...definition }));
1214
+ }
1215
+ load(family) {
1216
+ const cached = this.loads.get(family);
1217
+ if (cached) return cached;
1218
+ const definition = this.definitions.get(family);
1219
+ if (!definition) return Promise.reject(new Error(`Font is not registered: ${family}`));
1220
+ if (typeof FontFace === "undefined" || typeof document === "undefined") {
1221
+ return Promise.reject(new Error("Font loading requires a browser FontFace API"));
1222
+ }
1223
+ const promise = new FontFace(definition.family, fontSource(definition.source), {
1224
+ weight: definition.weight,
1225
+ style: definition.style,
1226
+ display: definition.display
1227
+ }).load().then((font) => {
1228
+ document.fonts.add(font);
1229
+ return font;
1230
+ });
1231
+ promise.catch(() => {
1232
+ if (this.loads.get(family) === promise) this.loads.delete(family);
1233
+ });
1234
+ this.loads.set(family, promise);
1235
+ return promise;
1236
+ }
1237
+ async ready() {
1238
+ if (this.definitions.size === 0) return;
1239
+ await Promise.all([...this.definitions.keys()].map((family) => this.load(family)));
1240
+ await document.fonts.ready;
1241
+ }
1242
+ async getEmbeddedCss() {
1243
+ const rules = await Promise.all(
1244
+ this.getAll().map(async (definition) => {
1245
+ const url = sourceUrl(definition.source);
1246
+ let cssSource;
1247
+ if (url && !url.startsWith("data:")) {
1248
+ const response = await fetch(url);
1249
+ if (!response.ok) throw new Error(`Failed to fetch font: ${url}`);
1250
+ const data = arrayBufferToBase64(await response.arrayBuffer());
1251
+ const mime = response.headers.get("content-type") || mimeForSource(url);
1252
+ cssSource = `url(${cssString(`data:${mime};base64,${data}`)})`;
1253
+ } else if (url) {
1254
+ cssSource = `url(${cssString(url)})`;
1255
+ } else {
1256
+ const name = localName(definition.source);
1257
+ if (!name) {
1258
+ throw new Error(`Unsupported font source for embedding: ${definition.family}`);
1259
+ }
1260
+ cssSource = `local(${cssString(name)})`;
1261
+ }
1262
+ const weight = cssKeyword(definition.weight, SAFE_WEIGHT, "normal");
1263
+ const style = cssKeyword(definition.style, SAFE_STYLE, "normal");
1264
+ const display = cssKeyword(definition.display, SAFE_DISPLAY, "swap");
1265
+ return `@font-face{font-family:${cssString(definition.family)};src:${cssSource};font-weight:${weight};font-style:${style};font-display:${display}}`;
1266
+ })
1267
+ );
1268
+ return rules.join("\n");
1269
+ }
1270
+ };
1271
+
1272
+ // src/licensing.ts
1273
+ function domainMatches(hostname, pattern) {
1274
+ const host = hostname.toLowerCase();
1275
+ const expected = pattern.toLowerCase();
1276
+ if (expected.startsWith("*.")) {
1277
+ const suffix = expected.slice(1);
1278
+ return host.endsWith(suffix) && host.length > suffix.length;
1279
+ }
1280
+ return host === expected;
953
1281
  }
954
- function exportDataURL(canvas, format = "png", multiplier = 1) {
955
- return canvas.toDataURL({ format, multiplier });
1282
+ function isLocal(hostname) {
1283
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
956
1284
  }
1285
+ var LicenseManager = class {
1286
+ constructor(config = {}) {
1287
+ this.config = config;
1288
+ const hostname = config.hostname ?? globalThis.location?.hostname ?? "localhost";
1289
+ const environment = config.environment ?? "development";
1290
+ if (environment !== "production" || isLocal(hostname)) {
1291
+ this.status = { state: "exempt", payload: null };
1292
+ this.readyPromise = Promise.resolve(this.status);
1293
+ } else if (!config.key) {
1294
+ this.status = { state: "community", payload: null };
1295
+ this.readyPromise = Promise.resolve(this.status);
1296
+ } else {
1297
+ this.status = { state: "checking", payload: null };
1298
+ this.readyPromise = this.validate(config.key, hostname);
1299
+ }
1300
+ }
1301
+ config;
1302
+ status;
1303
+ readyPromise;
1304
+ getStatus() {
1305
+ return this.status;
1306
+ }
1307
+ ready() {
1308
+ return this.readyPromise;
1309
+ }
1310
+ hasFeature(feature) {
1311
+ return this.status.state === "valid" && (this.status.payload.features ?? []).includes(feature);
1312
+ }
1313
+ track(name) {
1314
+ this.config.onUsage?.({
1315
+ name,
1316
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1317
+ licenseId: this.status.state === "valid" ? this.status.payload.id : void 0
1318
+ });
1319
+ }
1320
+ async validate(key, hostname) {
1321
+ const payload = await this.config.verifyOffline?.(key) ?? null;
1322
+ if (!payload) return this.status = { state: "invalid", payload: null };
1323
+ if (payload.expiresAt && Date.parse(payload.expiresAt) < Date.now()) {
1324
+ return this.status = { state: "expired", payload };
1325
+ }
1326
+ if (!payload.domains.some((domain) => domainMatches(hostname, domain))) {
1327
+ return this.status = { state: "domain-mismatch", payload };
1328
+ }
1329
+ return this.status = { state: "valid", payload };
1330
+ }
1331
+ };
1332
+
1333
+ // src/project.ts
1334
+ var ProjectManager = class {
1335
+ constructor(editor) {
1336
+ this.editor = editor;
1337
+ const first = { id: generateId(), name: "Page 1", state: editor.toJSON() };
1338
+ this.pages = [first];
1339
+ this.activePageId = first.id;
1340
+ }
1341
+ editor;
1342
+ pages;
1343
+ activePageId;
1344
+ getAll() {
1345
+ return this.pages.map(({ id, name }) => ({ id, name }));
1346
+ }
1347
+ getActivePageId() {
1348
+ return this.activePageId;
1349
+ }
1350
+ add(name = `Page ${this.pages.length + 1}`, cloneCurrent = false) {
1351
+ this.saveCurrent();
1352
+ const source = structuredClone(this.editor.toJSON());
1353
+ const state = cloneCurrent ? source : this.blankState(source);
1354
+ const page = { id: generateId(), name, state };
1355
+ this.pages.push(page);
1356
+ this.emitChanged();
1357
+ return page.id;
1358
+ }
1359
+ async switchTo(id) {
1360
+ if (id === this.activePageId) return true;
1361
+ const page = this.pages.find((candidate) => candidate.id === id);
1362
+ if (!page) return false;
1363
+ this.saveCurrent();
1364
+ await this.editor.fromJSON(structuredClone(page.state));
1365
+ this.activePageId = id;
1366
+ this.emitChanged();
1367
+ return true;
1368
+ }
1369
+ async duplicate(id) {
1370
+ this.saveCurrent();
1371
+ const source = this.pages.find((page2) => page2.id === id);
1372
+ if (!source) return null;
1373
+ const page = {
1374
+ id: generateId(),
1375
+ name: `${source.name} copy`,
1376
+ state: structuredClone(source.state)
1377
+ };
1378
+ const index = this.pages.indexOf(source);
1379
+ this.pages.splice(index + 1, 0, page);
1380
+ this.emitChanged();
1381
+ return page.id;
1382
+ }
1383
+ async remove(id) {
1384
+ if (this.pages.length === 1) return false;
1385
+ const index = this.pages.findIndex((page) => page.id === id);
1386
+ if (index < 0) return false;
1387
+ if (id === this.activePageId) {
1388
+ const next = this.pages[index + 1] ?? this.pages[index - 1];
1389
+ await this.editor.fromJSON(structuredClone(next.state));
1390
+ this.activePageId = next.id;
1391
+ }
1392
+ this.pages.splice(index, 1);
1393
+ this.emitChanged();
1394
+ return true;
1395
+ }
1396
+ rename(id, name) {
1397
+ const page = this.pages.find((candidate) => candidate.id === id);
1398
+ const trimmed = name.trim();
1399
+ if (!page || !trimmed || page.name === trimmed) return false;
1400
+ page.name = trimmed;
1401
+ this.emitChanged();
1402
+ return true;
1403
+ }
1404
+ reorder(id, newIndex) {
1405
+ const index = this.pages.findIndex((page2) => page2.id === id);
1406
+ if (index < 0 || !Number.isFinite(newIndex)) return false;
1407
+ const target = Math.max(0, Math.min(this.pages.length - 1, Math.round(newIndex)));
1408
+ if (target === index) return false;
1409
+ const [page] = this.pages.splice(index, 1);
1410
+ this.pages.splice(target, 0, page);
1411
+ this.emitChanged();
1412
+ return true;
1413
+ }
1414
+ toJSON() {
1415
+ this.saveCurrent();
1416
+ return {
1417
+ version: "1.0.0",
1418
+ activePageId: this.activePageId,
1419
+ pages: structuredClone(this.pages)
1420
+ };
1421
+ }
1422
+ async fromJSON(project) {
1423
+ if (project?.version !== "1.0.0" || !Array.isArray(project.pages) || project.pages.length === 0 || !project.pages.some((page) => page.id === project.activePageId)) {
1424
+ throw new Error("Invalid project state");
1425
+ }
1426
+ const pages = structuredClone(project.pages);
1427
+ const active = pages.find((page) => page.id === project.activePageId);
1428
+ await this.editor.fromJSON(structuredClone(active.state));
1429
+ this.pages = pages;
1430
+ this.activePageId = active.id;
1431
+ this.emitChanged();
1432
+ }
1433
+ saveCurrent() {
1434
+ const page = this.pages.find((candidate) => candidate.id === this.activePageId);
1435
+ if (page) page.state = structuredClone(this.editor.toJSON());
1436
+ }
1437
+ blankState(source) {
1438
+ return { ...source, layers: [], mockup: null };
1439
+ }
1440
+ emitChanged() {
1441
+ this.editor.events.emit("project:changed", {
1442
+ activePageId: this.activePageId,
1443
+ pages: this.getAll()
1444
+ });
1445
+ }
1446
+ };
957
1447
 
958
1448
  // src/editor.ts
959
1449
  var MIN_ZOOM = 0.1;
@@ -967,6 +1457,9 @@ var CanvasEditor = class {
967
1457
  snapping;
968
1458
  crop;
969
1459
  patterns;
1460
+ fonts;
1461
+ licensing;
1462
+ pages;
970
1463
  fileAdapter;
971
1464
  imageProvider;
972
1465
  zoomLevel = 1;
@@ -975,8 +1468,12 @@ var CanvasEditor = class {
975
1468
  // transparent while a mockup preview is shown, so this is the source of truth
976
1469
  // for serialization and export — not the (possibly transient) canvas value.
977
1470
  designBackground;
1471
+ designBackgroundImage = null;
978
1472
  constructor(canvasElement, config) {
979
1473
  this.events = new EventEmitter();
1474
+ this.fonts = new FontRegistry();
1475
+ config.fonts?.forEach((font) => this.fonts.register(font));
1476
+ this.licensing = new LicenseManager(config.license);
980
1477
  this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
981
1478
  const widthPx = this.units.toPixels(config.width);
982
1479
  const heightPx = this.units.toPixels(config.height);
@@ -999,18 +1496,73 @@ var CanvasEditor = class {
999
1496
  },
1000
1497
  events: this.events
1001
1498
  });
1499
+ this.layers.setHistoryCallback(() => this.history.save());
1002
1500
  this.snapping = new SnapManager(this.canvas, this.events);
1003
1501
  this.crop = new CropController(this.canvas, this.history, this.events);
1004
- this.patterns = new PatternManager(this.canvas, this.layers, this.history);
1502
+ this.patterns = new PatternManager(
1503
+ this.canvas,
1504
+ this.layers,
1505
+ this.history,
1506
+ this.events,
1507
+ config.patternSourceResolver
1508
+ );
1005
1509
  this.setupCanvasEvents();
1006
1510
  this.history.saveImmediate();
1511
+ this.pages = new ProjectManager(this);
1007
1512
  }
1008
1513
  // ─── Layer Operations ────────────────────────────────
1009
1514
  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;
1515
+ try {
1516
+ const img = await FabricImage2.fromURL(
1517
+ url,
1518
+ {},
1519
+ { originX: "left", originY: "top", ...options }
1520
+ );
1521
+ const layer = this.layers.add("image", img);
1522
+ this.history.save();
1523
+ return layer;
1524
+ } catch (error) {
1525
+ this.events.emit("error", { message: "Failed to add image", error });
1526
+ throw error;
1527
+ }
1528
+ }
1529
+ /** Replace an image source without changing its layer identity or visual transform. */
1530
+ async replaceImageSource(layerId, url) {
1531
+ const layer = this.layers.get(layerId);
1532
+ if (!layer || layer.type !== "image") throw new Error(`Image layer not found: ${layerId}`);
1533
+ if (layer.meta.pattern) {
1534
+ throw new Error("Clear the pattern before replacing the image source");
1535
+ }
1536
+ if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1537
+ const previous = layer.fabricObject;
1538
+ try {
1539
+ const replacement = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
1540
+ replacement.set({
1541
+ left: previous.left,
1542
+ top: previous.top,
1543
+ originX: previous.originX,
1544
+ originY: previous.originY,
1545
+ width: previous.width,
1546
+ height: previous.height,
1547
+ cropX: previous.cropX,
1548
+ cropY: previous.cropY,
1549
+ scaleX: previous.scaleX,
1550
+ scaleY: previous.scaleY,
1551
+ angle: previous.angle,
1552
+ skewX: previous.skewX,
1553
+ skewY: previous.skewY,
1554
+ flipX: previous.flipX,
1555
+ flipY: previous.flipY
1556
+ });
1557
+ replacement.filters = [...previous.filters];
1558
+ replacement.applyFilters();
1559
+ replacement.setCoords();
1560
+ this.layers.replaceObject(layerId, replacement);
1561
+ return layer;
1562
+ } catch (error) {
1563
+ this.events.emit("error", { message: "Failed to replace image source", error });
1564
+ throw error;
1565
+ }
1014
1566
  }
1015
1567
  addText(text, options) {
1016
1568
  const textbox = new Textbox(text, {
@@ -1033,27 +1585,42 @@ var CanvasEditor = class {
1033
1585
  this.history.save();
1034
1586
  return layer;
1035
1587
  }
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
1588
+ async addTemplate(template, params) {
1589
+ const values = {};
1590
+ for (const parameter of template.parameters) {
1591
+ const value = params[parameter.key] ?? parameter.default;
1592
+ if (value === void 0) throw new Error(`Missing template parameter: ${parameter.key}`);
1593
+ if (parameter.type === "number" && !Number.isFinite(Number(value))) {
1594
+ throw new Error(`Invalid number for template parameter: ${parameter.key}`);
1595
+ }
1596
+ if (parameter.type === "color" && !isCssColor(value)) {
1597
+ throw new Error(`Invalid color for template parameter: ${parameter.key}`);
1598
+ }
1599
+ values[parameter.key] = value;
1600
+ }
1601
+ const resolved = sanitizeSvg(
1602
+ template.svg.replace(/\{\{(\w+)(?:[|:]([^}]*))?\}\}/g, (token, key, fallback) => {
1603
+ const value = values[key] ?? fallback;
1604
+ return value === void 0 ? token : escapeXml(value);
1605
+ })
1050
1606
  );
1607
+ const { objects, options } = await loadSVGFromString(resolved);
1608
+ const validObjects = objects.filter((object) => object !== null);
1609
+ if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
1610
+ const group = util3.groupSVGElements(validObjects, options);
1611
+ group.set({
1612
+ left: this.canvas.getWidth() / 2,
1613
+ top: this.canvas.getHeight() / 2,
1614
+ originX: "center",
1615
+ originY: "center"
1616
+ });
1617
+ const layer = this.layers.add("template", group, template.name);
1051
1618
  this.history.save();
1052
1619
  return layer;
1053
1620
  }
1054
1621
  removeLayer(id) {
1055
- this.layers.remove(id);
1056
- this.history.save();
1622
+ if (this.crop.activeLayerId() === id) this.crop.cancel();
1623
+ if (this.layers.remove(id)) this.history.save();
1057
1624
  }
1058
1625
  selectLayer(id) {
1059
1626
  this.layers.select(id);
@@ -1088,35 +1655,173 @@ var CanvasEditor = class {
1088
1655
  clone.setCoords();
1089
1656
  const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
1090
1657
  copy.meta = structuredClone(layer.meta);
1658
+ copy.visible = layer.visible;
1659
+ copy.locked = layer.locked;
1660
+ copy.opacity = layer.opacity;
1661
+ clone.set({
1662
+ visible: layer.visible,
1663
+ selectable: !layer.locked,
1664
+ evented: !layer.locked,
1665
+ opacity: layer.opacity
1666
+ });
1091
1667
  this.canvas.setActiveObject(clone);
1092
1668
  this.canvas.requestRenderAll();
1093
1669
  this.history.save();
1094
1670
  return copy;
1095
1671
  }
1672
+ applyImageAdjustments(layerId, adjustments) {
1673
+ const layer = this.layers.get(layerId);
1674
+ if (!layer || layer.type !== "image") return false;
1675
+ const image = layer.fabricObject;
1676
+ const previous = layer.meta.imageAdjustments ?? {};
1677
+ const next = { ...previous, ...adjustments };
1678
+ const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
1679
+ image.filters = [
1680
+ new filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
1681
+ new filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
1682
+ new filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
1683
+ new filters.Blur({ blur: clampAdjustment(next.blur, 0) })
1684
+ ];
1685
+ layer.meta.imageAdjustments = next;
1686
+ image.applyFilters();
1687
+ this.canvas.requestRenderAll();
1688
+ this.history.save();
1689
+ return true;
1690
+ }
1691
+ /** Combine two or more layers into a single editable group layer. */
1692
+ async groupLayers(ids, name = "Group") {
1693
+ const uniqueIds = [...new Set(ids)];
1694
+ const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
1695
+ if (children.length < 2 || children.length !== uniqueIds.length) return null;
1696
+ return this.history.transaction(() => {
1697
+ const childData = children.map((layer) => structuredClone(layer.toData()));
1698
+ const objects = children.map((layer) => layer.fabricObject);
1699
+ for (const layer of children) this.layers.remove(layer.id);
1700
+ const group = new Group(objects);
1701
+ const grouped = this.layers.add("group", group, name);
1702
+ grouped.meta.groupChildren = childData;
1703
+ this.layers.select(grouped.id);
1704
+ this.history.save();
1705
+ return grouped;
1706
+ });
1707
+ }
1708
+ /** Restore a group created by groupLayers back to its original layer records. */
1709
+ async ungroupLayer(id) {
1710
+ const grouped = this.layers.get(id);
1711
+ const childData = grouped?.meta.groupChildren;
1712
+ if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
1713
+ const group = grouped.fabricObject;
1714
+ return this.history.transaction(() => {
1715
+ const transform = group.calcTransformMatrix();
1716
+ const objects = group.removeAll();
1717
+ this.layers.remove(id);
1718
+ const restored = objects.map((object, index) => {
1719
+ util3.addTransformToObject(object, transform);
1720
+ object.setCoords();
1721
+ const data = childData[index];
1722
+ const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
1723
+ if (data?.meta) layer.meta = structuredClone(data.meta);
1724
+ if (data && !data.visible) this.layers.setVisibility(layer.id, false);
1725
+ if (data?.locked) this.layers.setLocked(layer.id, true);
1726
+ if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
1727
+ return layer;
1728
+ });
1729
+ this.history.save();
1730
+ this.layers.select(restored[0]?.id ?? null);
1731
+ return restored;
1732
+ });
1733
+ }
1096
1734
  // ─── Serialization ──────────────────────────────────
1097
1735
  toJSON() {
1098
1736
  return serializeEditor(this);
1099
1737
  }
1100
1738
  async fromJSON(state) {
1101
- await deserializeEditor(this, state);
1102
- this.patterns.repinAll();
1739
+ const managedByHistory = this.history.isRestoring();
1740
+ if (!managedByHistory) {
1741
+ this.history.saveImmediate();
1742
+ this.history.pause();
1743
+ }
1744
+ try {
1745
+ await deserializeEditor(this, state);
1746
+ this.patterns.repinAll();
1747
+ } catch (error) {
1748
+ if (!managedByHistory) {
1749
+ this.events.emit("error", { message: "Failed to load editor state", error });
1750
+ }
1751
+ throw error;
1752
+ } finally {
1753
+ if (!managedByHistory) this.history.resume();
1754
+ }
1755
+ if (!managedByHistory) {
1756
+ this.history.clear();
1757
+ this.history.saveImmediate();
1758
+ }
1103
1759
  }
1104
1760
  // ─── Export ──────────────────────────────────────────
1105
1761
  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;
1762
+ return this.toRaster(options?.format ?? "png", options);
1763
+ }
1764
+ async toJPEG(options) {
1765
+ return this.toRaster("jpeg", options);
1766
+ }
1767
+ async toWebP(options) {
1768
+ return this.toRaster("webp", options);
1769
+ }
1770
+ async toRaster(format, options) {
1771
+ this.events.emit("export:start", { format });
1772
+ try {
1773
+ await this.fonts.ready();
1774
+ const blob = await this.withDesignBackground(
1775
+ () => exportPNG(this.canvas, { ...options, format })
1776
+ );
1777
+ this.events.emit("export:complete", { format });
1778
+ this.licensing.track(`export:${format}`);
1779
+ return blob;
1780
+ } catch (error) {
1781
+ this.events.emit("error", { message: `Failed to export ${format.toUpperCase()}`, error });
1782
+ throw error;
1783
+ }
1110
1784
  }
1111
1785
  toSVG() {
1112
1786
  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;
1787
+ try {
1788
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
1789
+ this.events.emit("export:complete", { format: "svg" });
1790
+ this.licensing.track("export:svg");
1791
+ return svg;
1792
+ } catch (error) {
1793
+ this.events.emit("error", { message: "Failed to export SVG", error });
1794
+ throw error;
1795
+ }
1796
+ }
1797
+ async toSVGAsync(options = {}) {
1798
+ await this.fonts.ready();
1799
+ const svg = this.toSVG();
1800
+ if (options.embedFonts === false || this.fonts.getAll().length === 0) return svg;
1801
+ try {
1802
+ const css = await this.fonts.getEmbeddedCss();
1803
+ return svg.replace(/(<svg\b[^>]*>)/i, `$1<defs><style>${css}</style></defs>`);
1804
+ } catch (error) {
1805
+ this.events.emit("error", { message: "Failed to embed fonts in SVG", error });
1806
+ throw error;
1807
+ }
1116
1808
  }
1117
1809
  toDataURL(format = "png", multiplier = 1) {
1118
1810
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1119
1811
  }
1812
+ /** Export the current product-preview composite. Advanced warping is host-defined. */
1813
+ async toMockupImage(options = {}) {
1814
+ if (!this.mockup) throw new Error("No mockup is configured");
1815
+ await this.fonts.ready();
1816
+ try {
1817
+ const blob = await exportMockup(this.canvas, this.mockup, options);
1818
+ this.licensing.track(`export:mockup:${options.format ?? "png"}`);
1819
+ return blob;
1820
+ } catch (error) {
1821
+ this.events.emit("error", { message: "Failed to export mockup", error });
1822
+ throw error;
1823
+ }
1824
+ }
1120
1825
  /**
1121
1826
  * Run an export with the configured design background applied, even when a
1122
1827
  * mockup preview has forced the live canvas transparent — so exports reflect
@@ -1125,11 +1830,14 @@ var CanvasEditor = class {
1125
1830
  withDesignBackground(fn) {
1126
1831
  if (!this.mockup) return fn();
1127
1832
  const previewBg = this.canvas.backgroundColor;
1833
+ const previewImage = this.canvas.backgroundImage;
1128
1834
  this.canvas.backgroundColor = this.designBackground;
1835
+ this.canvas.backgroundImage = this.designBackgroundImage ?? void 0;
1129
1836
  try {
1130
1837
  return fn();
1131
1838
  } finally {
1132
1839
  this.canvas.backgroundColor = previewBg;
1840
+ this.canvas.backgroundImage = previewImage;
1133
1841
  }
1134
1842
  }
1135
1843
  toPrintifyPositioning() {
@@ -1144,7 +1852,8 @@ var CanvasEditor = class {
1144
1852
  for (const layer of imageLayers) {
1145
1853
  const obj = layer.fabricObject;
1146
1854
  const center = obj.getCenterPoint();
1147
- result[layer.name] = {
1855
+ const key = result[layer.name] ? `${layer.name}-${layer.id}` : layer.name;
1856
+ result[key] = {
1148
1857
  x: round2(center.x / canvasW),
1149
1858
  y: round2(center.y / canvasH),
1150
1859
  scale: round2(obj.scaleX ?? 1),
@@ -1153,26 +1862,92 @@ var CanvasEditor = class {
1153
1862
  }
1154
1863
  return result;
1155
1864
  }
1865
+ /** Provider-neutral, ID-stable placement data in 0..1 canvas coordinates. */
1866
+ toNormalizedPositioning() {
1867
+ const canvasWidth = this.canvas.getWidth();
1868
+ const canvasHeight = this.canvas.getHeight();
1869
+ return this.layers.getAll().map((layer) => {
1870
+ const object = layer.fabricObject;
1871
+ const center = object.getCenterPoint();
1872
+ const bounds = object.getBoundingRect();
1873
+ return {
1874
+ layerId: layer.id,
1875
+ name: layer.name,
1876
+ type: layer.type,
1877
+ centerX: round2(center.x / canvasWidth),
1878
+ centerY: round2(center.y / canvasHeight),
1879
+ width: round2(bounds.width / canvasWidth),
1880
+ height: round2(bounds.height / canvasHeight),
1881
+ scaleX: round2(object.scaleX ?? 1),
1882
+ scaleY: round2(object.scaleY ?? 1),
1883
+ angle: round2(object.angle ?? 0)
1884
+ };
1885
+ });
1886
+ }
1887
+ toProviderPositioning(adapter) {
1888
+ return adapter.map(this.toNormalizedPositioning(), {
1889
+ width: this.canvas.getWidth(),
1890
+ height: this.canvas.getHeight()
1891
+ });
1892
+ }
1156
1893
  // ─── File Operations ────────────────────────────────
1157
1894
  async save(filename, format) {
1158
1895
  if (!this.fileAdapter) return void 0;
1159
1896
  let data;
1160
1897
  if (format === "png") {
1161
1898
  data = await this.toPNG();
1899
+ } else if (format === "jpeg") {
1900
+ data = await this.toJPEG();
1901
+ } else if (format === "webp") {
1902
+ data = await this.toWebP();
1162
1903
  } else if (format === "svg") {
1163
- data = this.toSVG();
1904
+ data = await this.toSVGAsync();
1164
1905
  } else {
1165
1906
  data = JSON.stringify(this.toJSON(), null, 2);
1166
1907
  }
1167
- return this.fileAdapter.save(data, filename, format);
1908
+ try {
1909
+ return await this.fileAdapter.save(data, filename, format);
1910
+ } catch (error) {
1911
+ this.events.emit("error", { message: `Failed to save ${format}`, error });
1912
+ throw error;
1913
+ }
1168
1914
  }
1169
1915
  async uploadImage(file) {
1170
- if (!this.imageProvider) return void 0;
1171
- return this.imageProvider.upload(file);
1916
+ if (!this.imageProvider?.upload) return void 0;
1917
+ try {
1918
+ return await this.imageProvider.upload(file);
1919
+ } catch (error) {
1920
+ this.events.emit("error", { message: "Failed to upload image", error });
1921
+ throw error;
1922
+ }
1172
1923
  }
1173
1924
  async browseImages() {
1174
1925
  if (!this.imageProvider?.browse) return null;
1175
- return this.imageProvider.browse();
1926
+ try {
1927
+ return await this.imageProvider.browse();
1928
+ } catch (error) {
1929
+ this.events.emit("error", { message: "Failed to browse images", error });
1930
+ throw error;
1931
+ }
1932
+ }
1933
+ async searchImages(query, options) {
1934
+ if (!this.imageProvider?.search) return null;
1935
+ try {
1936
+ return await this.imageProvider.search(query, options);
1937
+ } catch (error) {
1938
+ this.events.emit("error", { message: "Failed to search images", error });
1939
+ throw error;
1940
+ }
1941
+ }
1942
+ /** Track provider usage, then insert its hotlinked image as a normal layer. */
1943
+ async addProviderImage(image, options) {
1944
+ try {
1945
+ await this.imageProvider?.trackUse?.(image);
1946
+ } catch (error) {
1947
+ this.events.emit("error", { message: "Failed to record image use", error });
1948
+ throw error;
1949
+ }
1950
+ return this.addImage(image.url, options);
1176
1951
  }
1177
1952
  setFileAdapter(adapter) {
1178
1953
  this.fileAdapter = adapter;
@@ -1188,15 +1963,130 @@ var CanvasEditor = class {
1188
1963
  this.canvas.requestRenderAll();
1189
1964
  }
1190
1965
  this.history.save();
1966
+ this.events.emit("canvas:modified", {});
1191
1967
  }
1192
1968
  getDesignBackground() {
1193
1969
  return this.designBackground;
1194
1970
  }
1195
- resize(width, height) {
1971
+ getDesignBackgroundImage() {
1972
+ return this.designBackgroundImage;
1973
+ }
1974
+ async setBackgroundImage(url, options = {}) {
1975
+ if (url === null) {
1976
+ this.setBackgroundImageObject(null);
1977
+ return;
1978
+ }
1979
+ try {
1980
+ const image = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
1981
+ const width = image.width || 1;
1982
+ const height = image.height || 1;
1983
+ const canvasWidth = this.canvas.getWidth();
1984
+ const canvasHeight = this.canvas.getHeight();
1985
+ const fit = options.fit ?? "cover";
1986
+ const sx = canvasWidth / width;
1987
+ const sy = canvasHeight / height;
1988
+ const scaleX = fit === "stretch" ? sx : fit === "contain" ? Math.min(sx, sy) : Math.max(sx, sy);
1989
+ const scaleY = fit === "stretch" ? sy : scaleX;
1990
+ image.set({
1991
+ left: (canvasWidth - width * scaleX) / 2,
1992
+ top: (canvasHeight - height * scaleY) / 2,
1993
+ scaleX,
1994
+ scaleY,
1995
+ opacity: clamp(options.opacity ?? 1, 0, 1),
1996
+ selectable: false,
1997
+ evented: false
1998
+ });
1999
+ this.setBackgroundImageObject(image);
2000
+ } catch (error) {
2001
+ this.events.emit("error", { message: "Failed to set background image", error });
2002
+ throw error;
2003
+ }
2004
+ }
2005
+ /** Used by state restoration and advanced integrations with an existing Fabric object. */
2006
+ setBackgroundImageObject(image, save = true) {
2007
+ this.designBackgroundImage = image;
2008
+ this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
2009
+ this.canvas.requestRenderAll();
2010
+ if (save) this.history.save();
2011
+ this.events.emit("canvas:modified", {});
2012
+ }
2013
+ setTransparentBackground() {
2014
+ this.setBackground("");
2015
+ }
2016
+ resize(width, height, options = {}) {
1196
2017
  const widthPx = this.units.toPixels(width);
1197
2018
  const heightPx = this.units.toPixels(height);
2019
+ const oldWidth = this.canvas.getWidth();
2020
+ const oldHeight = this.canvas.getHeight();
2021
+ const scaleContent = options.scaleContent ?? true;
2022
+ if (scaleContent && oldWidth > 0 && oldHeight > 0) {
2023
+ const sx = widthPx / oldWidth;
2024
+ const sy = heightPx / oldHeight;
2025
+ for (const layer of this.layers.getAll()) {
2026
+ if (layer.meta.pattern) continue;
2027
+ const object = layer.fabricObject;
2028
+ object.set({
2029
+ left: (object.left ?? 0) * sx,
2030
+ top: (object.top ?? 0) * sy,
2031
+ scaleX: (object.scaleX ?? 1) * sx,
2032
+ scaleY: (object.scaleY ?? 1) * sy
2033
+ });
2034
+ object.setCoords();
2035
+ }
2036
+ if (this.designBackgroundImage) {
2037
+ this.designBackgroundImage.set({
2038
+ left: (this.designBackgroundImage.left ?? 0) * sx,
2039
+ top: (this.designBackgroundImage.top ?? 0) * sy,
2040
+ scaleX: (this.designBackgroundImage.scaleX ?? 1) * sx,
2041
+ scaleY: (this.designBackgroundImage.scaleY ?? 1) * sy
2042
+ });
2043
+ this.designBackgroundImage.setCoords();
2044
+ }
2045
+ }
1198
2046
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
2047
+ this.patterns.repinAll();
1199
2048
  this.canvas.requestRenderAll();
2049
+ this.history.save();
2050
+ this.events.emit("canvas:modified", {});
2051
+ }
2052
+ /** Effective source resolution for an image at its current physical size. */
2053
+ getImageDpi(layerId) {
2054
+ const layer = this.layers.get(layerId);
2055
+ if (!layer || layer.type !== "image") return null;
2056
+ const image = layer.fabricObject;
2057
+ const sourcePixels = image.width ?? 0;
2058
+ const displayedPixels = image.getScaledWidth();
2059
+ if (sourcePixels <= 0 || displayedPixels <= 0) return null;
2060
+ return round2(sourcePixels / (displayedPixels / this.units.getDpi()));
2061
+ }
2062
+ validateImageDpi(minimumDpi = 300) {
2063
+ return this.layers.getAll().filter((layer) => layer.type === "image" && !layer.meta.pattern).flatMap((layer) => {
2064
+ const effectiveDpi = this.getImageDpi(layer.id);
2065
+ return effectiveDpi !== null && effectiveDpi < minimumDpi ? [{ layerId: layer.id, layerName: layer.name, effectiveDpi, minimumDpi }] : [];
2066
+ });
2067
+ }
2068
+ /** Render a small data-URL preview without changing document dimensions. */
2069
+ toThumbnail(maxWidth = 320, maxHeight = 320, format = "png") {
2070
+ const multiplier = Math.min(
2071
+ 1,
2072
+ maxWidth / this.canvas.getWidth(),
2073
+ maxHeight / this.canvas.getHeight()
2074
+ );
2075
+ return this.toDataURL(format, multiplier);
2076
+ }
2077
+ bringToFront(id) {
2078
+ return this.layers.reorder(id, this.layers.count() - 1);
2079
+ }
2080
+ sendToBack(id) {
2081
+ return this.layers.reorder(id, 0);
2082
+ }
2083
+ bringForward(id) {
2084
+ const index = this.layers.getAll().findIndex((layer) => layer.id === id);
2085
+ return index >= 0 && this.layers.reorder(id, index + 1);
2086
+ }
2087
+ sendBackward(id) {
2088
+ const index = this.layers.getAll().findIndex((layer) => layer.id === id);
2089
+ return index >= 0 && this.layers.reorder(id, index - 1);
1200
2090
  }
1201
2091
  // ─── Zoom ───────────────────────────────────────────
1202
2092
  //
@@ -1234,6 +2124,20 @@ var CanvasEditor = class {
1234
2124
  const sy = (viewportHeight - padding * 2) / h;
1235
2125
  this.setZoom(Math.min(sx, sy));
1236
2126
  }
2127
+ /** Zoom until the current selection fills the artboard viewport. */
2128
+ zoomToSelection(padding = 24) {
2129
+ const active = this.canvas.getActiveObject();
2130
+ if (!active) return;
2131
+ active.setCoords();
2132
+ const bounds = active.getBoundingRect();
2133
+ if (bounds.width <= 0 || bounds.height <= 0) return;
2134
+ this.setZoom(
2135
+ Math.min(
2136
+ (this.canvas.getWidth() - padding * 2) / bounds.width,
2137
+ (this.canvas.getHeight() - padding * 2) / bounds.height
2138
+ )
2139
+ );
2140
+ }
1237
2141
  // ─── Patterns ───────────────────────────────────────
1238
2142
  applyPattern(layerId, config) {
1239
2143
  return this.patterns.apply(layerId, config);
@@ -1245,8 +2149,10 @@ var CanvasEditor = class {
1245
2149
  setMockup(mockup) {
1246
2150
  this.mockup = mockup;
1247
2151
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2152
+ this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
1248
2153
  this.canvas.requestRenderAll();
1249
2154
  this.events.emit("mockup:changed", { mockup });
2155
+ this.history.save();
1250
2156
  }
1251
2157
  clearMockup() {
1252
2158
  this.setMockup(null);
@@ -1259,6 +2165,7 @@ var CanvasEditor = class {
1259
2165
  this.snapping.dispose();
1260
2166
  this.crop.dispose();
1261
2167
  this.history.dispose();
2168
+ clearPatternImageCache();
1262
2169
  this.events.removeAllListeners();
1263
2170
  this.canvas.dispose();
1264
2171
  }
@@ -1299,15 +2206,29 @@ var DEFAULT_PATTERN_CONFIG = {
1299
2206
  rotationStepH: 0,
1300
2207
  rotationStepV: 0
1301
2208
  };
2209
+
2210
+ // src/presets.ts
2211
+ var CANVAS_SIZE_PRESETS = [
2212
+ { id: "a4-portrait", name: "A4 portrait", width: 210, height: 297, unit: "mm", dpi: 300 },
2213
+ { id: "a4-landscape", name: "A4 landscape", width: 297, height: 210, unit: "mm", dpi: 300 },
2214
+ { id: "us-letter", name: "US Letter", width: 8.5, height: 11, unit: "in", dpi: 300 },
2215
+ { id: "shirt-front", name: "Garment front", width: 12, height: 16, unit: "in", dpi: 300 },
2216
+ { id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
2217
+ { id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
2218
+ ];
1302
2219
  export {
2220
+ CANVAS_SIZE_PRESETS,
1303
2221
  CanvasEditor,
1304
2222
  CropController,
1305
2223
  DEFAULT_PATTERN_CONFIG,
1306
2224
  EventEmitter,
2225
+ FontRegistry,
1307
2226
  HistoryManager,
1308
2227
  Layer,
1309
2228
  LayerManager,
2229
+ LicenseManager,
1310
2230
  PatternManager,
2231
+ ProjectManager,
1311
2232
  SnapManager,
1312
2233
  UnitConverter,
1313
2234
  applyPatternLocks,
@@ -1315,15 +2236,22 @@ export {
1315
2236
  captureLocks,
1316
2237
  clamp,
1317
2238
  clearPatternImageCache,
2239
+ computeCoverPlacement,
2240
+ computePrintAreaClip,
1318
2241
  computeTilePositions,
1319
2242
  deserializeEditor,
1320
2243
  drawTiles,
2244
+ escapeXml,
1321
2245
  exportDataURL,
2246
+ exportMockup,
1322
2247
  exportPNG,
1323
2248
  exportSVG,
1324
2249
  generateId,
2250
+ isCssColor,
2251
+ loadPatternImage,
1325
2252
  restoreLocks,
1326
2253
  round2,
2254
+ sanitizeSvg,
1327
2255
  serializeEditor
1328
2256
  };
1329
2257
  //# sourceMappingURL=index.mjs.map