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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,47 +20,71 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AnnotationOverlay: () => AnnotationOverlay,
23
24
  CANVAS_SIZE_PRESETS: () => CANVAS_SIZE_PRESETS,
24
25
  CanvasEditor: () => CanvasEditor,
25
26
  CropController: () => CropController,
27
+ DEFAULT_LAYER_SHADOW: () => DEFAULT_LAYER_SHADOW,
26
28
  DEFAULT_PATTERN_CONFIG: () => DEFAULT_PATTERN_CONFIG,
29
+ DEFAULT_TEXT_CURVE: () => DEFAULT_TEXT_CURVE,
27
30
  EventEmitter: () => EventEmitter,
28
31
  FontRegistry: () => FontRegistry,
29
32
  HistoryManager: () => HistoryManager,
30
33
  Layer: () => Layer,
31
34
  LayerManager: () => LayerManager,
32
35
  LicenseManager: () => LicenseManager,
36
+ MaskController: () => MaskController,
37
+ MaskPresetManager: () => MaskPresetManager,
38
+ MaskRefinementError: () => MaskRefinementError,
33
39
  PatternManager: () => PatternManager,
34
40
  ProjectManager: () => ProjectManager,
41
+ SHAPE_MASK_BOX: () => SHAPE_MASK_BOX,
42
+ SHAPE_MASK_IDS: () => SHAPE_MASK_IDS,
35
43
  SnapManager: () => SnapManager,
44
+ TEXTURE_MASK_IDS: () => TEXTURE_MASK_IDS,
45
+ TEXTURE_MASK_SIZE: () => TEXTURE_MASK_SIZE,
46
+ TextCurveManager: () => TextCurveManager,
36
47
  UnitConverter: () => UnitConverter,
48
+ applyAspectLock: () => applyAspectLock,
49
+ applyLayerShadow: () => applyLayerShadow,
37
50
  applyPatternLocks: () => applyPatternLocks,
51
+ buildCurvePathData: () => buildCurvePathData,
38
52
  buildPatternDataURL: () => buildPatternDataURL,
39
53
  captureLocks: () => captureLocks,
40
54
  clamp: () => clamp,
41
55
  clearPatternImageCache: () => clearPatternImageCache,
56
+ clearTextureMaskCache: () => clearTextureMaskCache,
42
57
  computeCoverPlacement: () => computeCoverPlacement,
43
58
  computePrintAreaClip: () => computePrintAreaClip,
44
59
  computeTilePositions: () => computeTilePositions,
45
60
  deserializeEditor: () => deserializeEditor,
61
+ displaceRgba: () => displaceRgba,
46
62
  drawTiles: () => drawTiles,
47
63
  escapeXml: () => escapeXml,
48
64
  exportDataURL: () => exportDataURL,
49
65
  exportMockup: () => exportMockup,
50
66
  exportPNG: () => exportPNG,
67
+ exportPrintArea: () => exportPrintArea,
51
68
  exportSVG: () => exportSVG,
52
69
  generateId: () => generateId,
53
70
  isCssColor: () => isCssColor,
71
+ isMaskPresetId: () => isMaskPresetId,
72
+ isShapeMaskId: () => isShapeMaskId,
73
+ isTextureMaskId: () => isTextureMaskId,
54
74
  loadPatternImage: () => loadPatternImage,
75
+ readLayerShadow: () => readLayerShadow,
76
+ renderTextureMask: () => renderTextureMask,
77
+ resetTransform: () => resetTransform,
55
78
  restoreLocks: () => restoreLocks,
56
79
  round2: () => round2,
57
80
  sanitizeSvg: () => sanitizeSvg,
58
- serializeEditor: () => serializeEditor
81
+ serializeEditor: () => serializeEditor,
82
+ shapeMaskPathData: () => shapeMaskPathData
59
83
  });
60
84
  module.exports = __toCommonJS(index_exports);
61
85
 
62
86
  // src/editor.ts
63
- var import_fabric4 = require("fabric");
87
+ var import_fabric9 = require("fabric");
64
88
 
65
89
  // src/events.ts
66
90
  var EventEmitter = class {
@@ -298,9 +322,13 @@ var LayerManager = class {
298
322
  };
299
323
 
300
324
  // src/history.ts
301
- var HistoryManager = class {
325
+ var HistoryManager = class _HistoryManager {
326
+ static ASSET_KEY = "__canvasEditorHistoryAsset";
302
327
  undoStack = [];
303
328
  redoStack = [];
329
+ assets = /* @__PURE__ */ new Map();
330
+ assetIds = /* @__PURE__ */ new Map();
331
+ nextAssetId = 1;
304
332
  maxSize;
305
333
  maxBytes;
306
334
  paused = false;
@@ -343,7 +371,8 @@ var HistoryManager = class {
343
371
  return;
344
372
  }
345
373
  this.cancelPending();
346
- const state = this.getState();
374
+ const rawState = this.getState();
375
+ const state = this.compactState(rawState);
347
376
  if (this.undoStack.at(-1) === state) {
348
377
  this.emitChanged();
349
378
  return;
@@ -355,7 +384,7 @@ var HistoryManager = class {
355
384
  this.redoStack = [];
356
385
  this.trimToBudget();
357
386
  this.events.emit("history:snapshot", {
358
- bytes: state.length * 2,
387
+ bytes: rawState.length * 2,
359
388
  totalBytes: this.snapshotBytes(),
360
389
  entries: this.undoStack.length
361
390
  });
@@ -383,15 +412,15 @@ var HistoryManager = class {
383
412
  }
384
413
  async undo() {
385
414
  this.cancelPending();
386
- const current = this.getState();
387
415
  const committed = this.undoStack.at(-1);
388
416
  if (!committed) return;
417
+ const current = this.compactState(this.getState());
389
418
  const currentIsCommitted = current === committed;
390
419
  if (currentIsCommitted && this.undoStack.length < 2) return;
391
420
  const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
392
421
  this.paused = true;
393
422
  try {
394
- await this.restoreState(target);
423
+ await this.restoreState(this.expandState(target));
395
424
  if (currentIsCommitted) this.undoStack.pop();
396
425
  this.redoStack.push(current);
397
426
  this.trimToBudget();
@@ -409,7 +438,7 @@ var HistoryManager = class {
409
438
  if (!state) return;
410
439
  this.paused = true;
411
440
  try {
412
- await this.restoreState(state);
441
+ await this.restoreState(this.expandState(state));
413
442
  this.redoStack.pop();
414
443
  if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
415
444
  this.trimToBudget();
@@ -442,6 +471,8 @@ var HistoryManager = class {
442
471
  this.cancelPending();
443
472
  this.undoStack = [];
444
473
  this.redoStack = [];
474
+ this.assets.clear();
475
+ this.assetIds.clear();
445
476
  this.emitChanged();
446
477
  }
447
478
  getSnapshotBytes() {
@@ -457,6 +488,10 @@ var HistoryManager = class {
457
488
  this.cancelPending();
458
489
  this.transactionDepth = 0;
459
490
  this.transactionDirty = false;
491
+ this.undoStack = [];
492
+ this.redoStack = [];
493
+ this.assets.clear();
494
+ this.assetIds.clear();
460
495
  }
461
496
  emitChanged() {
462
497
  this.events.emit("history:changed", {
@@ -465,17 +500,91 @@ var HistoryManager = class {
465
500
  });
466
501
  }
467
502
  snapshotBytes() {
468
- return [...this.undoStack, ...this.redoStack].reduce(
503
+ const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
469
504
  (total, state) => total + state.length * 2,
470
505
  0
471
506
  );
507
+ const assetBytes = [...this.assets.values()].reduce(
508
+ (total, asset) => total + asset.length * 2,
509
+ 0
510
+ );
511
+ return stackBytes + assetBytes;
472
512
  }
473
513
  trimToBudget() {
514
+ this.pruneAssets();
474
515
  while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
475
516
  this.undoStack.shift();
517
+ this.pruneAssets();
476
518
  }
477
519
  while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
478
520
  this.redoStack.shift();
521
+ this.pruneAssets();
522
+ }
523
+ }
524
+ /**
525
+ * History is internal and can content-address large raster strings without
526
+ * changing the public EditorState wire format. Unchanged images are retained
527
+ * once even when dozens of snapshots reference them.
528
+ */
529
+ compactState(state) {
530
+ let parsed;
531
+ try {
532
+ parsed = JSON.parse(state);
533
+ } catch {
534
+ return state;
535
+ }
536
+ const visit = (value) => {
537
+ if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
538
+ let id = this.assetIds.get(value);
539
+ if (!id) {
540
+ id = `a${this.nextAssetId++}`;
541
+ this.assetIds.set(value, id);
542
+ this.assets.set(id, value);
543
+ }
544
+ return { [_HistoryManager.ASSET_KEY]: id };
545
+ }
546
+ if (Array.isArray(value)) return value.map(visit);
547
+ if (!value || typeof value !== "object") return value;
548
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
549
+ };
550
+ return JSON.stringify(visit(parsed));
551
+ }
552
+ expandState(state) {
553
+ let parsed;
554
+ try {
555
+ parsed = JSON.parse(state);
556
+ } catch {
557
+ return state;
558
+ }
559
+ const visit = (value) => {
560
+ if (Array.isArray(value)) return value.map(visit);
561
+ if (!value || typeof value !== "object") return value;
562
+ const record = value;
563
+ const id = record[_HistoryManager.ASSET_KEY];
564
+ if (typeof id === "string" && Object.keys(record).length === 1) {
565
+ const asset = this.assets.get(id);
566
+ if (!asset) throw new Error(`Missing history raster asset: ${id}`);
567
+ return asset;
568
+ }
569
+ return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
570
+ };
571
+ return JSON.stringify(visit(parsed));
572
+ }
573
+ /**
574
+ * Runs on every commit, so it scans for the serialized reference marker
575
+ * instead of re-parsing every snapshot — parsing multi-megabyte raster states
576
+ * per save would cost more than the retention it reclaims. `JSON.stringify`
577
+ * emits the marker verbatim; the same text inside user data is escaped and
578
+ * therefore cannot match.
579
+ */
580
+ pruneAssets() {
581
+ if (this.assets.size === 0) return;
582
+ const states = [...this.undoStack, ...this.redoStack];
583
+ for (const [id, asset] of this.assets) {
584
+ const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
585
+ if (states.some((state) => state.includes(marker))) continue;
586
+ this.assets.delete(id);
587
+ this.assetIds.delete(asset);
479
588
  }
480
589
  }
481
590
  };
@@ -1053,6 +1162,521 @@ function mod2(n) {
1053
1162
  return (n % 2 + 2) % 2;
1054
1163
  }
1055
1164
 
1165
+ // src/text-curve.ts
1166
+ var import_fabric3 = require("fabric");
1167
+ var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1168
+ var MIN_ARC = 0.5;
1169
+ var FULL_CIRCLE_ARC = 99.5;
1170
+ var MIN_SWEEP = 0.12;
1171
+ var WAVE_PERIOD_EM = 4.1;
1172
+ var WAVE_AMPLITUDE_EM = 0.9;
1173
+ var WAVE_STEP = 6;
1174
+ var MEASURE_WIDTH = 1e5;
1175
+ var PATH_SLACK = 0.06;
1176
+ function isCurvable(object) {
1177
+ return !!object && typeof object.text === "string";
1178
+ }
1179
+ function measureText(text) {
1180
+ const authored = text.width;
1181
+ try {
1182
+ text.set({ width: MEASURE_WIDTH });
1183
+ text.initDimensions?.();
1184
+ return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1185
+ } finally {
1186
+ if (authored !== void 0) text.set({ width: authored });
1187
+ text.initDimensions?.();
1188
+ }
1189
+ }
1190
+ function arcPathData(width, arc) {
1191
+ const magnitude = Math.min(100, Math.abs(arc));
1192
+ const direction = arc < 0 ? -1 : 1;
1193
+ const full = magnitude >= FULL_CIRCLE_ARC;
1194
+ const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1195
+ const radius = width / sweep;
1196
+ const centerX = width / 2;
1197
+ const point = (angle) => [
1198
+ centerX + radius * Math.sin(angle),
1199
+ direction * radius * (1 - Math.cos(angle))
1200
+ ];
1201
+ const sweepFlag = direction > 0 ? 1 : 0;
1202
+ const format = ([x, y]) => `${round(x)} ${round(y)}`;
1203
+ if (full) {
1204
+ const start2 = point(-Math.PI);
1205
+ const top = point(0);
1206
+ return {
1207
+ data: [
1208
+ `M ${format(start2)}`,
1209
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
1210
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
1211
+ ].join(" "),
1212
+ length: width
1213
+ };
1214
+ }
1215
+ const start = point(-sweep / 2);
1216
+ const end = point(sweep / 2);
1217
+ const largeArc = sweep > Math.PI ? 1 : 0;
1218
+ return {
1219
+ data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1220
+ // radius = width / sweep, so the arc is exactly `width` long.
1221
+ length: width
1222
+ };
1223
+ }
1224
+ function wavePathData(width, fontSize, wave) {
1225
+ const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
1226
+ const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
1227
+ const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
1228
+ const commands = [];
1229
+ let length = 0;
1230
+ let previous = null;
1231
+ for (let index = 0; index <= steps; index++) {
1232
+ const x = width * index / steps;
1233
+ const y = amplitude * Math.sin(x / period * Math.PI * 2);
1234
+ if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
1235
+ previous = [x, y];
1236
+ commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
1237
+ }
1238
+ return { data: commands.join(" "), length };
1239
+ }
1240
+ function round(value) {
1241
+ return Math.round(value * 100) / 100;
1242
+ }
1243
+ function buildCurvePathData(config, width, fontSize) {
1244
+ if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
1245
+ if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
1246
+ return null;
1247
+ }
1248
+ function normalize(config) {
1249
+ const arc = clamp(config.arc ?? 0, -100, 100);
1250
+ return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
1251
+ }
1252
+ var TextCurveManager = class {
1253
+ constructor(canvas, layers, history, events) {
1254
+ this.canvas = canvas;
1255
+ this.layers = layers;
1256
+ this.history = history;
1257
+ this.events = events;
1258
+ }
1259
+ canvas;
1260
+ layers;
1261
+ history;
1262
+ events;
1263
+ /** Curve parameters for a layer, or null when it is not curved text. */
1264
+ get(layerId) {
1265
+ const layer = this.layers.get(layerId);
1266
+ if (!layer || !isCurvable(layer.fabricObject)) return null;
1267
+ return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
1268
+ }
1269
+ isCurved(layerId) {
1270
+ const curve = this.get(layerId);
1271
+ return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
1272
+ }
1273
+ /** Apply (or update) the curve on a text layer. Zeroed config clears it. */
1274
+ apply(layerId, config, save = true) {
1275
+ const layer = this.layers.get(layerId);
1276
+ if (!layer || !isCurvable(layer.fabricObject)) return false;
1277
+ const next = normalize(config);
1278
+ const text = layer.fabricObject;
1279
+ const run = measureText(text);
1280
+ const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
1281
+ if (!curve) {
1282
+ this.detach(text, layer.meta.curveWidth);
1283
+ delete layer.meta.curve;
1284
+ delete layer.meta.curveWidth;
1285
+ } else {
1286
+ if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1287
+ text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1288
+ text.set({
1289
+ path: new import_fabric3.Path(curve.data, { visible: false, objectCaching: false }),
1290
+ pathAlign: "center",
1291
+ pathSide: "left",
1292
+ pathStartOffset: Math.max(0, (curve.length - run) / 2)
1293
+ });
1294
+ layer.meta.curve = next;
1295
+ }
1296
+ text.initDimensions?.();
1297
+ text.setCoords();
1298
+ text.dirty = true;
1299
+ this.canvas.requestRenderAll();
1300
+ this.events.emit("layer:modified", { layerId });
1301
+ if (save) this.history.save();
1302
+ return true;
1303
+ }
1304
+ /** Remove the curve, restoring the authored text box width. */
1305
+ clear(layerId, save = true) {
1306
+ return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
1307
+ }
1308
+ /**
1309
+ * Rebuild the path from the stored parameters. Text content, font family and
1310
+ * font size all change the run's width, and the path is sized to that width —
1311
+ * without this the curve keeps the geometry of the text it was created from.
1312
+ */
1313
+ refresh(layerId, save = false) {
1314
+ const curve = this.layers.get(layerId)?.meta.curve;
1315
+ if (!curve) return false;
1316
+ return this.apply(layerId, curve, save);
1317
+ }
1318
+ /** Rebuild every curved layer — used after a state restore. */
1319
+ refreshAll() {
1320
+ for (const layer of this.layers.getAll()) {
1321
+ if (layer.meta.curve) this.refresh(layer.id);
1322
+ }
1323
+ }
1324
+ detach(text, authoredWidth) {
1325
+ text.set({ path: null, pathStartOffset: 0 });
1326
+ if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1327
+ }
1328
+ };
1329
+
1330
+ // src/mask-presets/manager.ts
1331
+ var import_fabric4 = require("fabric");
1332
+
1333
+ // src/mask-presets/shapes.ts
1334
+ var SHAPE_MASK_IDS = [
1335
+ "circle",
1336
+ "square",
1337
+ "triangle",
1338
+ "star",
1339
+ "heart",
1340
+ "octagram",
1341
+ "arch",
1342
+ "zigzag"
1343
+ ];
1344
+ function isShapeMaskId(value) {
1345
+ return typeof value === "string" && SHAPE_MASK_IDS.includes(value);
1346
+ }
1347
+ function starPoints(points, outer, inner, start = -Math.PI / 2) {
1348
+ const step = Math.PI / points;
1349
+ const coordinates = [];
1350
+ for (let index = 0; index < points * 2; index++) {
1351
+ const radius = index % 2 === 0 ? outer : inner;
1352
+ const angle = start + index * step;
1353
+ const x = 50 + Math.cos(angle) * radius;
1354
+ const y = 50 + Math.sin(angle) * radius;
1355
+ coordinates.push(`${index === 0 ? "M" : "L"} ${round3(x)} ${round3(y)}`);
1356
+ }
1357
+ return `${coordinates.join(" ")} Z`;
1358
+ }
1359
+ function round3(value) {
1360
+ return Math.round(value * 100) / 100;
1361
+ }
1362
+ var SHAPE_PATHS = {
1363
+ circle: "M 96 50 A 46 46 0 1 1 4 50 A 46 46 0 1 1 96 50 Z",
1364
+ square: "M 4 4 H 96 V 96 H 4 Z",
1365
+ triangle: "M 50 4 L 96 96 L 4 96 Z",
1366
+ star: starPoints(5, 46, 22),
1367
+ heart: "M 50 96 C -8 55 12 4 50 28 C 88 4 108 55 50 96 Z",
1368
+ octagram: starPoints(8, 46, 27),
1369
+ arch: "M 4 96 V 50 A 46 46 0 0 1 96 50 V 96 Z",
1370
+ zigzag: starPoints(16, 46, 39)
1371
+ };
1372
+ function shapeMaskPathData(id) {
1373
+ return SHAPE_PATHS[id];
1374
+ }
1375
+ var SHAPE_MASK_BOX = 100;
1376
+
1377
+ // src/mask-presets/textures.ts
1378
+ var TEXTURE_MASK_IDS = [
1379
+ "vignette",
1380
+ "halftone",
1381
+ "spray",
1382
+ "grunge",
1383
+ "torn",
1384
+ "band"
1385
+ ];
1386
+ function isTextureMaskId(value) {
1387
+ return typeof value === "string" && TEXTURE_MASK_IDS.includes(value);
1388
+ }
1389
+ var TEXTURE_MASK_SIZE = 320;
1390
+ var cache = /* @__PURE__ */ new Map();
1391
+ function seeded(seed) {
1392
+ let state = seed;
1393
+ return () => {
1394
+ state = state * 16807 % 2147483647;
1395
+ return (state - 1) / 2147483646;
1396
+ };
1397
+ }
1398
+ function traceRoughEdge(context, random, jitter, inset) {
1399
+ const size = TEXTURE_MASK_SIZE;
1400
+ const corners = [
1401
+ [inset, inset],
1402
+ [size - inset, inset],
1403
+ [size - inset, size - inset],
1404
+ [inset, size - inset]
1405
+ ];
1406
+ context.beginPath();
1407
+ for (let corner = 0; corner < corners.length; corner++) {
1408
+ const [fromX, fromY] = corners[corner];
1409
+ const [toX, toY] = corners[(corner + 1) % corners.length];
1410
+ for (let step = 0; step <= 26; step++) {
1411
+ const ratio = step / 26;
1412
+ const x = fromX + (toX - fromX) * ratio + (random() - 0.5) * jitter;
1413
+ const y = fromY + (toY - fromY) * ratio + (random() - 0.5) * jitter;
1414
+ if (corner === 0 && step === 0) context.moveTo(x, y);
1415
+ else context.lineTo(x, y);
1416
+ }
1417
+ }
1418
+ context.closePath();
1419
+ context.fill();
1420
+ }
1421
+ function paint(id, context) {
1422
+ const size = TEXTURE_MASK_SIZE;
1423
+ const center = size / 2;
1424
+ context.fillStyle = "#ffffff";
1425
+ switch (id) {
1426
+ case "vignette": {
1427
+ const gradient = context.createRadialGradient(
1428
+ center,
1429
+ center,
1430
+ size * 0.18,
1431
+ center,
1432
+ center,
1433
+ size * 0.52
1434
+ );
1435
+ gradient.addColorStop(0, "rgba(255,255,255,1)");
1436
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1437
+ context.fillStyle = gradient;
1438
+ context.fillRect(0, 0, size, size);
1439
+ return;
1440
+ }
1441
+ case "band": {
1442
+ const gradient = context.createLinearGradient(0, 0, 0, size);
1443
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
1444
+ gradient.addColorStop(0.25, "rgba(255,255,255,1)");
1445
+ gradient.addColorStop(0.75, "rgba(255,255,255,1)");
1446
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1447
+ context.fillStyle = gradient;
1448
+ context.fillRect(0, 0, size, size);
1449
+ return;
1450
+ }
1451
+ case "halftone": {
1452
+ const pitch = 16;
1453
+ for (let y = pitch / 2; y < size; y += pitch) {
1454
+ for (let x = pitch / 2; x < size; x += pitch) {
1455
+ const distance = Math.hypot(x - center, y - center) / (size * 0.52);
1456
+ const radius = Math.max(0, pitch / 2 * (1 - distance) * 1.15);
1457
+ if (radius < 0.4) continue;
1458
+ context.beginPath();
1459
+ context.arc(x, y, radius, 0, Math.PI * 2);
1460
+ context.fill();
1461
+ }
1462
+ }
1463
+ return;
1464
+ }
1465
+ case "spray": {
1466
+ const random = seeded(42);
1467
+ const core = context.createRadialGradient(center, center, 10, center, center, size * 0.42);
1468
+ core.addColorStop(0, "rgba(255,255,255,1)");
1469
+ core.addColorStop(1, "rgba(255,255,255,0.85)");
1470
+ context.fillStyle = core;
1471
+ context.beginPath();
1472
+ context.arc(center, center, size * 0.42, 0, Math.PI * 2);
1473
+ context.fill();
1474
+ context.fillStyle = "rgba(255,255,255,0.9)";
1475
+ for (let dot = 0; dot < 900; dot++) {
1476
+ const angle = random() * Math.PI * 2;
1477
+ const distance = size * (0.3 + random() * 0.24);
1478
+ const x = center + Math.cos(angle) * distance;
1479
+ const y = center + Math.sin(angle) * distance;
1480
+ context.globalAlpha = 1 - (distance / size - 0.3) / 0.24;
1481
+ context.beginPath();
1482
+ context.arc(x, y, random() * 2.4, 0, Math.PI * 2);
1483
+ context.fill();
1484
+ }
1485
+ context.globalAlpha = 1;
1486
+ return;
1487
+ }
1488
+ case "grunge": {
1489
+ const random = seeded(7);
1490
+ traceRoughEdge(context, random, 14, size * 0.06);
1491
+ context.globalCompositeOperation = "destination-out";
1492
+ for (let speckle = 0; speckle < 240; speckle++) {
1493
+ const x = random() * size;
1494
+ const y = random() * size;
1495
+ const nearEdge = Math.min(x, y, size - x, size - y) < size * 0.12;
1496
+ if (!nearEdge && random() >= 0.12) continue;
1497
+ context.beginPath();
1498
+ context.arc(x, y, random() * 6 + 1, 0, Math.PI * 2);
1499
+ context.fill();
1500
+ }
1501
+ context.globalCompositeOperation = "source-over";
1502
+ return;
1503
+ }
1504
+ case "torn": {
1505
+ traceRoughEdge(context, seeded(99), 10, size * 0.08);
1506
+ return;
1507
+ }
1508
+ }
1509
+ }
1510
+ function renderTextureMask(id) {
1511
+ const cached = cache.get(id);
1512
+ if (cached) return cached;
1513
+ if (typeof document === "undefined") {
1514
+ throw new Error("Texture masks require a DOM canvas");
1515
+ }
1516
+ const canvas = document.createElement("canvas");
1517
+ canvas.width = TEXTURE_MASK_SIZE;
1518
+ canvas.height = TEXTURE_MASK_SIZE;
1519
+ const context = canvas.getContext("2d");
1520
+ if (!context) throw new Error("Texture masks require a 2D canvas context");
1521
+ paint(id, context);
1522
+ cache.set(id, canvas);
1523
+ return canvas;
1524
+ }
1525
+ function clearTextureMaskCache() {
1526
+ cache.clear();
1527
+ }
1528
+
1529
+ // src/mask-presets/manager.ts
1530
+ function isMaskPresetId(value) {
1531
+ return isShapeMaskId(value) || isTextureMaskId(value);
1532
+ }
1533
+ var MaskPresetManager = class {
1534
+ constructor(canvas, layers, history, events) {
1535
+ this.canvas = canvas;
1536
+ this.layers = layers;
1537
+ this.history = history;
1538
+ this.events = events;
1539
+ }
1540
+ canvas;
1541
+ layers;
1542
+ history;
1543
+ events;
1544
+ get(layerId) {
1545
+ return this.layers.get(layerId)?.meta.maskPreset ?? null;
1546
+ }
1547
+ /** Clip the layer to `id`. Passing null (or an unknown id) clears the clip. */
1548
+ apply(layerId, id, save = true) {
1549
+ const layer = this.layers.get(layerId);
1550
+ if (!layer) return false;
1551
+ const object = layer.fabricObject;
1552
+ if (id === null) {
1553
+ if (!layer.meta.maskPreset) return false;
1554
+ object.clipPath = void 0;
1555
+ delete layer.meta.maskPreset;
1556
+ } else {
1557
+ if (!isMaskPresetId(id)) return false;
1558
+ object.clipPath = this.buildClip(id, object);
1559
+ layer.meta.maskPreset = id;
1560
+ }
1561
+ object.dirty = true;
1562
+ object.setCoords();
1563
+ this.canvas.requestRenderAll();
1564
+ this.events.emit("layer:modified", { layerId });
1565
+ if (save) this.history.save();
1566
+ return true;
1567
+ }
1568
+ clear(layerId, save = true) {
1569
+ return this.apply(layerId, null, save);
1570
+ }
1571
+ /**
1572
+ * Re-fit the clip to the layer's current size. The clip is built for the
1573
+ * object's dimensions at the time it was applied; editing text or replacing an
1574
+ * image changes them, and a stale clip would crop the wrong region.
1575
+ */
1576
+ refresh(layerId, save = false) {
1577
+ const id = this.get(layerId);
1578
+ if (!id) return false;
1579
+ return this.apply(layerId, id, save);
1580
+ }
1581
+ /** Re-fit every masked layer — used after a state restore. */
1582
+ refreshAll() {
1583
+ for (const layer of this.layers.getAll()) {
1584
+ if (layer.meta.maskPreset) this.refresh(layer.id);
1585
+ }
1586
+ }
1587
+ buildClip(id, object) {
1588
+ const width = Math.max(1, object.width ?? 1);
1589
+ const height = Math.max(1, object.height ?? 1);
1590
+ const shared = {
1591
+ originX: "center",
1592
+ originY: "center",
1593
+ left: 0,
1594
+ top: 0,
1595
+ objectCaching: false
1596
+ };
1597
+ if (isShapeMaskId(id)) {
1598
+ return new import_fabric4.Path(shapeMaskPathData(id), {
1599
+ ...shared,
1600
+ scaleX: width / SHAPE_MASK_BOX,
1601
+ scaleY: height / SHAPE_MASK_BOX
1602
+ });
1603
+ }
1604
+ return new import_fabric4.FabricImage(renderTextureMask(id), {
1605
+ ...shared,
1606
+ scaleX: width / TEXTURE_MASK_SIZE,
1607
+ scaleY: height / TEXTURE_MASK_SIZE
1608
+ });
1609
+ }
1610
+ };
1611
+
1612
+ // src/shadow.ts
1613
+ var import_fabric5 = require("fabric");
1614
+
1615
+ // src/utils/color.ts
1616
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1617
+ function isCssColor(value, allowEmpty = false) {
1618
+ if (typeof value !== "string") return false;
1619
+ const trimmed = value.trim();
1620
+ if (!trimmed) return allowEmpty;
1621
+ return CSS_COLOR.test(trimmed);
1622
+ }
1623
+
1624
+ // src/shadow.ts
1625
+ var DEFAULT_LAYER_SHADOW = {
1626
+ enabled: false,
1627
+ color: "#000000",
1628
+ blur: 12,
1629
+ offsetX: 6,
1630
+ offsetY: 6
1631
+ };
1632
+ function readLayerShadow(object) {
1633
+ const shadow = object.shadow;
1634
+ if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
1635
+ return {
1636
+ enabled: true,
1637
+ color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
1638
+ blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
1639
+ offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
1640
+ offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
1641
+ };
1642
+ }
1643
+ function applyLayerShadow(object, config) {
1644
+ const next = { ...readLayerShadow(object), ...config };
1645
+ if (!next.enabled) {
1646
+ object.set({ shadow: null });
1647
+ return;
1648
+ }
1649
+ const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
1650
+ object.set({
1651
+ shadow: new import_fabric5.Shadow({
1652
+ color,
1653
+ blur: Math.max(0, next.blur),
1654
+ offsetX: next.offsetX,
1655
+ offsetY: next.offsetY
1656
+ })
1657
+ });
1658
+ }
1659
+
1660
+ // src/transform.ts
1661
+ var SIDE_CONTROLS = ["ml", "mr", "mt", "mb"];
1662
+ function applyAspectLock(object, locked) {
1663
+ for (const control of SIDE_CONTROLS) {
1664
+ object.setControlVisible(control, !locked);
1665
+ }
1666
+ }
1667
+ function resetTransform(object) {
1668
+ object.set({
1669
+ scaleX: 1,
1670
+ scaleY: 1,
1671
+ angle: 0,
1672
+ skewX: 0,
1673
+ skewY: 0,
1674
+ flipX: false,
1675
+ flipY: false
1676
+ });
1677
+ object.setCoords();
1678
+ }
1679
+
1056
1680
  // src/utils/units.ts
1057
1681
  var MM_PER_INCH = 25.4;
1058
1682
  var UnitConverter = class {
@@ -1101,18 +1725,7 @@ var UnitConverter = class {
1101
1725
  };
1102
1726
 
1103
1727
  // src/serialization.ts
1104
- var import_fabric3 = require("fabric");
1105
-
1106
- // src/utils/color.ts
1107
- var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1108
- function isCssColor(value, allowEmpty = false) {
1109
- if (typeof value !== "string") return false;
1110
- const trimmed = value.trim();
1111
- if (!trimmed) return allowEmpty;
1112
- return CSS_COLOR.test(trimmed);
1113
- }
1114
-
1115
- // src/serialization.ts
1728
+ var import_fabric6 = require("fabric");
1116
1729
  var VERSION = "2.0.0";
1117
1730
  function serializeEditor(editor) {
1118
1731
  return {
@@ -1128,6 +1741,7 @@ function serializeEditor(editor) {
1128
1741
  // forced transparent while a mockup preview is active).
1129
1742
  background: editor.getDesignBackground(),
1130
1743
  backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
1744
+ backgroundImageOptions: editor.getBackgroundImageOptions(),
1131
1745
  mockup: editor.getMockup()
1132
1746
  };
1133
1747
  }
@@ -1146,13 +1760,28 @@ async function deserializeEditor(editor, state) {
1146
1760
  throw new Error("Invalid editor background color");
1147
1761
  }
1148
1762
  const staged = await Promise.all(
1149
- state.layers.map(async (serialized) => ({
1150
- serialized,
1151
- fabricObject: (await import_fabric3.util.enlivenObjects([serialized.fabricObject]))[0]
1152
- }))
1763
+ state.layers.map(async (serialized) => {
1764
+ const fabricObject = (await import_fabric6.util.enlivenObjects([serialized.fabricObject]))[0];
1765
+ if (!fabricObject) {
1766
+ const source = serialized.fabricObject.src;
1767
+ if (typeof source === "string" && source.startsWith("blob:")) {
1768
+ throw new Error(`Failed to restore expired object URL: ${source}`);
1769
+ }
1770
+ throw new Error(`Failed to restore layer: ${serialized.id}`);
1771
+ }
1772
+ return { serialized, fabricObject };
1773
+ })
1153
1774
  );
1154
- const stagedBackground = state.backgroundImage ? (await import_fabric3.util.enlivenObjects([state.backgroundImage]))[0] : null;
1775
+ const stagedBackground = state.backgroundImage ? (await import_fabric6.util.enlivenObjects([state.backgroundImage]))[0] : null;
1776
+ if (state.backgroundImage && !stagedBackground) {
1777
+ const source = state.backgroundImage.src;
1778
+ if (typeof source === "string" && source.startsWith("blob:")) {
1779
+ throw new Error(`Failed to restore expired background object URL: ${source}`);
1780
+ }
1781
+ throw new Error("Failed to restore background image");
1782
+ }
1155
1783
  editor.crop.cancel();
1784
+ editor.masks.detach();
1156
1785
  editor.layers.clear();
1157
1786
  if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
1158
1787
  if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
@@ -1160,7 +1789,11 @@ async function deserializeEditor(editor, state) {
1160
1789
  if (state.background !== void 0) {
1161
1790
  editor.setBackground(state.background);
1162
1791
  }
1163
- editor.setBackgroundImageObject(stagedBackground, false);
1792
+ editor.setBackgroundImageObject(
1793
+ stagedBackground ?? null,
1794
+ false,
1795
+ state.backgroundImageOptions ?? null
1796
+ );
1164
1797
  editor.setMockup(state.mockup ?? null);
1165
1798
  for (const item of staged) {
1166
1799
  restoreLayer(editor, item.serialized, item.fabricObject);
@@ -1171,6 +1804,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1171
1804
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
1172
1805
  if (serialized.meta) {
1173
1806
  layer.meta = serialized.meta;
1807
+ if (layer.meta.lockAspect) applyAspectLock(fabricObject, true);
1174
1808
  }
1175
1809
  if (!serialized.visible) {
1176
1810
  editor.layers.setVisibility(layer.id, false);
@@ -1184,6 +1818,62 @@ function restoreLayer(editor, serialized, fabricObject) {
1184
1818
  return layer;
1185
1819
  }
1186
1820
 
1821
+ // src/export.ts
1822
+ var import_fabric7 = require("fabric");
1823
+
1824
+ // src/displacement.ts
1825
+ var CHANNEL_INDEX = {
1826
+ red: 0,
1827
+ green: 1,
1828
+ blue: 2,
1829
+ alpha: 3
1830
+ };
1831
+ function finiteScale(value, fallback, label) {
1832
+ const resolved = value ?? fallback;
1833
+ if (!Number.isFinite(resolved)) throw new Error(`${label} must be finite`);
1834
+ return resolved;
1835
+ }
1836
+ function sample(source, width, height, x, y, channel) {
1837
+ const clampedX = Math.max(0, Math.min(width - 1, x));
1838
+ const clampedY = Math.max(0, Math.min(height - 1, y));
1839
+ const x0 = Math.floor(clampedX);
1840
+ const y0 = Math.floor(clampedY);
1841
+ const x1 = Math.min(width - 1, x0 + 1);
1842
+ const y1 = Math.min(height - 1, y0 + 1);
1843
+ const tx = clampedX - x0;
1844
+ const ty = clampedY - y0;
1845
+ const top = source[(y0 * width + x0) * 4 + channel] * (1 - tx) + source[(y0 * width + x1) * 4 + channel] * tx;
1846
+ const bottom = source[(y1 * width + x0) * 4 + channel] * (1 - tx) + source[(y1 * width + x1) * 4 + channel] * tx;
1847
+ return top * (1 - ty) + bottom * ty;
1848
+ }
1849
+ function displaceRgba(source, map, width, height, options) {
1850
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
1851
+ throw new Error("Displacement dimensions must be positive integers");
1852
+ }
1853
+ const expectedLength = width * height * 4;
1854
+ if (source.length !== expectedLength || map.length !== expectedLength) {
1855
+ throw new Error("Displacement source and map must match the requested dimensions");
1856
+ }
1857
+ const scaleX = finiteScale(options.scaleX, 10, "Displacement scaleX");
1858
+ const scaleY = finiteScale(options.scaleY, 10, "Displacement scaleY");
1859
+ const channelX = CHANNEL_INDEX[options.channelX ?? "red"];
1860
+ const channelY = CHANNEL_INDEX[options.channelY ?? "green"];
1861
+ const output = new Uint8ClampedArray(expectedLength);
1862
+ for (let y = 0; y < height; y += 1) {
1863
+ for (let x = 0; x < width; x += 1) {
1864
+ const offset = (y * width + x) * 4;
1865
+ const sourceX = x + (map[offset + channelX] - 128) / 127 * scaleX;
1866
+ const sourceY = y + (map[offset + channelY] - 128) / 127 * scaleY;
1867
+ for (let channel = 0; channel < 4; channel += 1) {
1868
+ output[offset + channel] = Math.round(
1869
+ sample(source, width, height, sourceX, sourceY, channel)
1870
+ );
1871
+ }
1872
+ }
1873
+ }
1874
+ return output;
1875
+ }
1876
+
1187
1877
  // src/export.ts
1188
1878
  function computePrintAreaClip(area, scaleX, scaleY, targetWidth, targetHeight) {
1189
1879
  const left = Math.max(0, Math.min(targetWidth, area.left * scaleX));
@@ -1221,6 +1911,55 @@ async function exportPNG(canvas, options = {}) {
1221
1911
  const output = canvas.toCanvasElement(multiplier);
1222
1912
  return canvasElementToBlob(output, format, quality);
1223
1913
  }
1914
+ async function exportIsolatedPNG(source, objects, options = {}) {
1915
+ const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
1916
+ const canvas = new import_fabric7.StaticCanvas(element, {
1917
+ width: options.width ?? source.getWidth(),
1918
+ height: options.height ?? source.getHeight(),
1919
+ backgroundColor: options.backgroundColor || void 0
1920
+ });
1921
+ try {
1922
+ const clones = options.cloneObjects === false ? objects : await Promise.all(objects.map((object) => object.clone()));
1923
+ if (clones.length) canvas.add(...clones);
1924
+ if (options.backgroundImage) canvas.backgroundImage = await options.backgroundImage.clone();
1925
+ canvas.requestRenderAll();
1926
+ return await exportPNG(canvas, options);
1927
+ } finally {
1928
+ canvas.dispose();
1929
+ }
1930
+ }
1931
+ async function exportPrintArea(source, area, options = {}) {
1932
+ const { multiplier = 1, format = "png", quality = 1 } = options;
1933
+ const width = source.getWidth();
1934
+ const height = source.getHeight();
1935
+ const clip = computePrintAreaClip(
1936
+ area,
1937
+ multiplier,
1938
+ multiplier,
1939
+ width * multiplier,
1940
+ height * multiplier
1941
+ );
1942
+ if (clip.width <= 0 || clip.height <= 0) {
1943
+ throw new Error("Print area does not overlap the canvas");
1944
+ }
1945
+ const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
1946
+ const canvas = new import_fabric7.StaticCanvas(element, { width, height });
1947
+ try {
1948
+ const clones = await Promise.all(source.getObjects().map((object) => object.clone()));
1949
+ if (clones.length) canvas.add(...clones);
1950
+ canvas.requestRenderAll();
1951
+ const rendered = canvas.toCanvasElement(multiplier);
1952
+ const output = rendered.ownerDocument.createElement("canvas");
1953
+ output.width = Math.max(1, Math.round(clip.width));
1954
+ output.height = Math.max(1, Math.round(clip.height));
1955
+ const context = output.getContext("2d");
1956
+ if (!context) throw new Error("2D canvas context is unavailable");
1957
+ context.drawImage(rendered, -clip.left, -clip.top);
1958
+ return await canvasElementToBlob(output, format, quality);
1959
+ } finally {
1960
+ canvas.dispose();
1961
+ }
1962
+ }
1224
1963
  async function exportMockup(canvas, mockup, options = {}) {
1225
1964
  const { multiplier = 1, format = "png", quality = 1 } = options;
1226
1965
  const design = canvas.toCanvasElement(multiplier);
@@ -1236,41 +1975,92 @@ async function exportMockup(canvas, mockup, options = {}) {
1236
1975
  element.onerror = () => reject(new Error(`Failed to load mockup image: ${url}`));
1237
1976
  element.src = url;
1238
1977
  });
1239
- const drawCover = (image) => {
1978
+ const drawCover = (image, targetContext = context) => {
1240
1979
  const placement = computeCoverPlacement(
1241
1980
  image.naturalWidth || image.width,
1242
1981
  image.naturalHeight || image.height,
1243
1982
  output.width,
1244
1983
  output.height
1245
1984
  );
1246
- context.drawImage(image, placement.left, placement.top, placement.width, placement.height);
1247
- };
1248
- drawCover(await loadImage(mockup.image));
1249
- context.save();
1250
- if (mockup.printArea && mockup.clipToPrintArea !== false) {
1251
- const clip = computePrintAreaClip(
1252
- mockup.printArea,
1253
- output.width / canvas.getWidth(),
1254
- output.height / canvas.getHeight(),
1255
- output.width,
1256
- output.height
1985
+ targetContext.drawImage(
1986
+ image,
1987
+ placement.left,
1988
+ placement.top,
1989
+ placement.width,
1990
+ placement.height
1257
1991
  );
1258
- context.beginPath();
1259
- context.rect(clip.left, clip.top, clip.width, clip.height);
1260
- context.clip();
1261
- }
1262
- context.globalAlpha = Math.max(0, Math.min(1, mockup.designOpacity ?? 1));
1263
- context.globalCompositeOperation = !mockup.designBlendMode || mockup.designBlendMode === "normal" ? "source-over" : mockup.designBlendMode;
1264
- context.drawImage(design, 0, 0);
1265
- context.restore();
1266
- if (mockup.overlay) {
1992
+ };
1993
+ const scratch = [design];
1994
+ try {
1995
+ drawCover(await loadImage(mockup.image));
1996
+ let compositedDesign = design;
1997
+ if (mockup.displacement) {
1998
+ const sourceContext = design.getContext("2d");
1999
+ if (!sourceContext) throw new Error("2D design context is unavailable");
2000
+ const mapCanvas = design.ownerDocument.createElement("canvas");
2001
+ scratch.push(mapCanvas);
2002
+ mapCanvas.width = design.width;
2003
+ mapCanvas.height = design.height;
2004
+ const mapContext = mapCanvas.getContext("2d");
2005
+ if (!mapContext) throw new Error("2D displacement-map context is unavailable");
2006
+ drawCover(await loadImage(mockup.displacement.image), mapContext);
2007
+ const warped = design.ownerDocument.createElement("canvas");
2008
+ scratch.push(warped);
2009
+ warped.width = design.width;
2010
+ warped.height = design.height;
2011
+ const warpedContext = warped.getContext("2d");
2012
+ if (!warpedContext) throw new Error("2D displaced-design context is unavailable");
2013
+ let sourcePixels;
2014
+ let mapPixels;
2015
+ try {
2016
+ sourcePixels = sourceContext.getImageData(0, 0, design.width, design.height).data;
2017
+ mapPixels = mapContext.getImageData(0, 0, design.width, design.height).data;
2018
+ } catch (error) {
2019
+ throw new Error("Failed to apply mockup displacement map; verify image CORS access", {
2020
+ cause: error
2021
+ });
2022
+ }
2023
+ const pixels = displaceRgba(sourcePixels, mapPixels, design.width, design.height, {
2024
+ ...mockup.displacement,
2025
+ scaleX: (mockup.displacement.scaleX ?? 10) * multiplier,
2026
+ scaleY: (mockup.displacement.scaleY ?? 10) * multiplier
2027
+ });
2028
+ const imageData = warpedContext.createImageData(design.width, design.height);
2029
+ imageData.data.set(pixels);
2030
+ warpedContext.putImageData(imageData, 0, 0);
2031
+ compositedDesign = warped;
2032
+ }
1267
2033
  context.save();
1268
- context.globalAlpha = Math.max(0, Math.min(1, mockup.overlay.opacity ?? 1));
1269
- context.globalCompositeOperation = mockup.overlay.blendMode === "normal" ? "source-over" : mockup.overlay.blendMode ?? "multiply";
1270
- drawCover(await loadImage(mockup.overlay.image));
2034
+ if (mockup.printArea && mockup.clipToPrintArea !== false) {
2035
+ const clip = computePrintAreaClip(
2036
+ mockup.printArea,
2037
+ output.width / canvas.getWidth(),
2038
+ output.height / canvas.getHeight(),
2039
+ output.width,
2040
+ output.height
2041
+ );
2042
+ context.beginPath();
2043
+ context.rect(clip.left, clip.top, clip.width, clip.height);
2044
+ context.clip();
2045
+ }
2046
+ context.globalAlpha = Math.max(0, Math.min(1, mockup.designOpacity ?? 1));
2047
+ context.globalCompositeOperation = !mockup.designBlendMode || mockup.designBlendMode === "normal" ? "source-over" : mockup.designBlendMode;
2048
+ context.drawImage(compositedDesign, 0, 0);
1271
2049
  context.restore();
2050
+ if (mockup.overlay) {
2051
+ context.save();
2052
+ context.globalAlpha = Math.max(0, Math.min(1, mockup.overlay.opacity ?? 1));
2053
+ context.globalCompositeOperation = mockup.overlay.blendMode === "normal" ? "source-over" : mockup.overlay.blendMode ?? "multiply";
2054
+ drawCover(await loadImage(mockup.overlay.image));
2055
+ context.restore();
2056
+ }
2057
+ return await canvasElementToBlob(output, format, quality);
2058
+ } finally {
2059
+ for (const element of scratch) {
2060
+ element.width = 0;
2061
+ element.height = 0;
2062
+ }
1272
2063
  }
1273
- return canvasElementToBlob(output, format, quality);
1274
2064
  }
1275
2065
  function exportSVG(canvas) {
1276
2066
  return canvas.toSVG();
@@ -1592,9 +2382,270 @@ var ProjectManager = class {
1592
2382
  }
1593
2383
  };
1594
2384
 
2385
+ // src/mask.ts
2386
+ var import_fabric8 = require("fabric");
2387
+ var MaskRefinementError = class extends Error {
2388
+ constructor(code, message, cause) {
2389
+ super(message);
2390
+ this.code = code;
2391
+ this.cause = cause;
2392
+ this.name = "MaskRefinementError";
2393
+ }
2394
+ code;
2395
+ cause;
2396
+ };
2397
+ var MaskController = class {
2398
+ constructor(editor) {
2399
+ this.editor = editor;
2400
+ }
2401
+ editor;
2402
+ backing = null;
2403
+ context = null;
2404
+ layerId = null;
2405
+ brush = null;
2406
+ previousPoint = null;
2407
+ strokeBackup = null;
2408
+ strokeStartedAt = null;
2409
+ lastInteractionLatencyMs = 0;
2410
+ refinement = null;
2411
+ disposed = false;
2412
+ async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
2413
+ this.assertActive();
2414
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
2415
+ throw new Error("Mask dimensions must be positive integers");
2416
+ }
2417
+ const backing = this.makeCanvas(width, height);
2418
+ const image = new import_fabric8.FabricImage(backing, {
2419
+ left: 0,
2420
+ top: 0,
2421
+ originX: "left",
2422
+ originY: "top",
2423
+ selectable: false
2424
+ });
2425
+ const layer = this.editor.layers.add("mask", image, "Mask");
2426
+ layer.meta.mask = { width, height, revision: 0 };
2427
+ this.editor.history.saveImmediate();
2428
+ this.attachBacking(layer.id, backing);
2429
+ return layer;
2430
+ }
2431
+ attach(layerId) {
2432
+ this.assertActive();
2433
+ this.cancelStroke();
2434
+ const layer = this.requireMask(layerId);
2435
+ const image = layer.fabricObject;
2436
+ const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
2437
+ const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
2438
+ const backing = this.makeCanvas(width, height);
2439
+ const context = backing.getContext("2d");
2440
+ if (!context) throw new Error("2D mask context is unavailable");
2441
+ const element = image.getElement();
2442
+ if (element) context.drawImage(element, 0, 0, width, height);
2443
+ this.attachBacking(layerId, backing);
2444
+ }
2445
+ beginStroke(options) {
2446
+ this.assertActive();
2447
+ if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
2448
+ if (this.brush) throw new Error("A mask stroke is already active");
2449
+ if (!Number.isFinite(options.size) || options.size <= 0) {
2450
+ throw new Error("Mask brush size must be positive");
2451
+ }
2452
+ this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
2453
+ this.previousPoint = null;
2454
+ this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
2455
+ this.strokeStartedAt = performance.now();
2456
+ }
2457
+ addPoint(point) {
2458
+ if (!this.brush || !this.context || !this.backing || !this.layerId) {
2459
+ throw new Error("No active mask stroke");
2460
+ }
2461
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
2462
+ const previous = this.previousPoint ?? point;
2463
+ const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
2464
+ const step = Math.max(1, this.brush.size / 4);
2465
+ const samples = Math.max(1, Math.ceil(distance / step));
2466
+ for (let index = 0; index <= samples; index += 1) {
2467
+ const ratio = index / samples;
2468
+ this.drawDot({
2469
+ x: previous.x + (point.x - previous.x) * ratio,
2470
+ y: previous.y + (point.y - previous.y) * ratio
2471
+ });
2472
+ }
2473
+ this.previousPoint = point;
2474
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
2475
+ this.editor.canvas.requestRenderAll();
2476
+ }
2477
+ async endStroke() {
2478
+ if (!this.brush || !this.backing || !this.layerId) return;
2479
+ const layerId = this.layerId;
2480
+ this.brush = null;
2481
+ this.previousPoint = null;
2482
+ this.strokeBackup = null;
2483
+ const dataUrl = this.backing.toDataURL("image/png");
2484
+ await this.editor.history.transaction(async () => {
2485
+ await this.editor.replaceImageSource(layerId, dataUrl);
2486
+ const layer = this.requireMask(layerId);
2487
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
2488
+ });
2489
+ this.attach(layerId);
2490
+ if (this.strokeStartedAt !== null) {
2491
+ this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
2492
+ this.strokeStartedAt = null;
2493
+ }
2494
+ }
2495
+ cancelStroke() {
2496
+ if (this.strokeBackup && this.context && this.backing && this.layerId) {
2497
+ this.context.putImageData(this.strokeBackup, 0, 0);
2498
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
2499
+ this.editor.canvas.requestRenderAll();
2500
+ }
2501
+ this.brush = null;
2502
+ this.previousPoint = null;
2503
+ this.strokeBackup = null;
2504
+ this.strokeStartedAt = null;
2505
+ }
2506
+ isStrokeActive() {
2507
+ return this.brush !== null;
2508
+ }
2509
+ activeLayerId() {
2510
+ return this.layerId;
2511
+ }
2512
+ detach(layerId) {
2513
+ if (layerId && this.layerId !== layerId) return;
2514
+ this.cancelStroke();
2515
+ this.cancelRefinement();
2516
+ this.backing = null;
2517
+ this.context = null;
2518
+ this.layerId = null;
2519
+ }
2520
+ async refine(layerId, provider, prompts, options = {}) {
2521
+ this.assertActive();
2522
+ const layer = this.editor.layers.get(layerId);
2523
+ if (!layer || layer.type !== "mask") {
2524
+ throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
2525
+ }
2526
+ this.cancelRefinement();
2527
+ const controller = new AbortController();
2528
+ this.refinement = controller;
2529
+ const abort = () => controller.abort(options.signal?.reason);
2530
+ options.signal?.addEventListener("abort", abort, { once: true });
2531
+ if (options.signal?.aborted) abort();
2532
+ try {
2533
+ const image = layer.fabricObject;
2534
+ const result = await provider.refine(
2535
+ {
2536
+ mask: image.getSrc(),
2537
+ width: layer.meta.mask?.width ?? image.width ?? 1,
2538
+ height: layer.meta.mask?.height ?? image.height ?? 1,
2539
+ prompts: structuredClone(prompts)
2540
+ },
2541
+ { signal: controller.signal, onProgress: options.onProgress }
2542
+ );
2543
+ if (controller.signal.aborted)
2544
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
2545
+ if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
2546
+ throw new MaskRefinementError(
2547
+ "invalid-result",
2548
+ "Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
2549
+ );
2550
+ }
2551
+ await this.editor.history.transaction(async () => {
2552
+ await this.editor.replaceImageSource(layerId, result.dataUrl);
2553
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
2554
+ });
2555
+ this.attach(layerId);
2556
+ return result;
2557
+ } catch (error) {
2558
+ if (error instanceof MaskRefinementError) throw error;
2559
+ if (controller.signal.aborted) {
2560
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
2561
+ }
2562
+ throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
2563
+ } finally {
2564
+ options.signal?.removeEventListener("abort", abort);
2565
+ if (this.refinement === controller) this.refinement = null;
2566
+ }
2567
+ }
2568
+ cancelRefinement() {
2569
+ this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
2570
+ this.refinement = null;
2571
+ }
2572
+ measure() {
2573
+ if (!this.backing) return null;
2574
+ const started = performance.now();
2575
+ this.context?.getImageData(0, 0, 1, 1);
2576
+ const backingBytes = this.backing.width * this.backing.height * 4;
2577
+ const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
2578
+ const memory = performance;
2579
+ return {
2580
+ width: this.backing.width,
2581
+ height: this.backing.height,
2582
+ backingBytes,
2583
+ strokeBackupBytes,
2584
+ // The backing store and rollback ImageData dominate interactive mask
2585
+ // memory. Encoded historical rasters are reported separately below.
2586
+ estimatedPeakBytes: backingBytes + strokeBackupBytes,
2587
+ historyBytes: this.editor.history.getSnapshotBytes(),
2588
+ interactionLatencyMs: this.lastInteractionLatencyMs,
2589
+ ...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
2590
+ elapsedMs: performance.now() - started
2591
+ };
2592
+ }
2593
+ dispose() {
2594
+ this.detach();
2595
+ this.disposed = true;
2596
+ }
2597
+ drawDot(point) {
2598
+ const context = this.context;
2599
+ const brush = this.brush;
2600
+ const radius = brush.size / 2;
2601
+ context.save();
2602
+ context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
2603
+ const gradient = context.createRadialGradient(
2604
+ point.x,
2605
+ point.y,
2606
+ radius * brush.hardness,
2607
+ point.x,
2608
+ point.y,
2609
+ radius
2610
+ );
2611
+ const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2612
+ gradient.addColorStop(0, color);
2613
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
2614
+ context.fillStyle = gradient;
2615
+ context.beginPath();
2616
+ context.arc(point.x, point.y, radius, 0, Math.PI * 2);
2617
+ context.fill();
2618
+ context.restore();
2619
+ }
2620
+ makeCanvas(width, height) {
2621
+ const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
2622
+ canvas.width = width;
2623
+ canvas.height = height;
2624
+ return canvas;
2625
+ }
2626
+ attachBacking(layerId, backing) {
2627
+ const context = backing.getContext("2d");
2628
+ if (!context) throw new Error("2D mask context is unavailable");
2629
+ this.layerId = layerId;
2630
+ this.backing = backing;
2631
+ this.context = context;
2632
+ }
2633
+ requireMask(layerId) {
2634
+ const layer = this.editor.layers.get(layerId);
2635
+ if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
2636
+ return layer;
2637
+ }
2638
+ assertActive() {
2639
+ if (this.disposed) throw new Error("Mask controller has been disposed");
2640
+ }
2641
+ };
2642
+
1595
2643
  // src/editor.ts
1596
2644
  var MIN_ZOOM = 0.1;
1597
2645
  var MAX_ZOOM = 8;
2646
+ function isTaintedCanvasError(error) {
2647
+ return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
2648
+ }
1598
2649
  var CanvasEditor = class {
1599
2650
  canvas;
1600
2651
  layers;
@@ -1604,9 +2655,12 @@ var CanvasEditor = class {
1604
2655
  snapping;
1605
2656
  crop;
1606
2657
  patterns;
2658
+ curves;
2659
+ maskPresets;
1607
2660
  fonts;
1608
2661
  licensing;
1609
2662
  pages;
2663
+ masks;
1610
2664
  fileAdapter;
1611
2665
  imageProvider;
1612
2666
  zoomLevel = 1;
@@ -1616,6 +2670,7 @@ var CanvasEditor = class {
1616
2670
  // for serialization and export — not the (possibly transient) canvas value.
1617
2671
  designBackground;
1618
2672
  designBackgroundImage = null;
2673
+ backgroundImageOptions = null;
1619
2674
  constructor(canvasElement, config) {
1620
2675
  this.events = new EventEmitter();
1621
2676
  this.fonts = new FontRegistry();
@@ -1625,7 +2680,7 @@ var CanvasEditor = class {
1625
2680
  const widthPx = this.units.toPixels(config.width);
1626
2681
  const heightPx = this.units.toPixels(config.height);
1627
2682
  this.designBackground = config.backgroundColor ?? "#ffffff";
1628
- this.canvas = new import_fabric4.Canvas(canvasElement, {
2683
+ this.canvas = new import_fabric9.Canvas(canvasElement, {
1629
2684
  width: widthPx,
1630
2685
  height: heightPx,
1631
2686
  backgroundColor: this.designBackground,
@@ -1653,14 +2708,17 @@ var CanvasEditor = class {
1653
2708
  this.events,
1654
2709
  config.patternSourceResolver
1655
2710
  );
2711
+ this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2712
+ this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
1656
2713
  this.setupCanvasEvents();
1657
2714
  this.history.saveImmediate();
1658
2715
  this.pages = new ProjectManager(this);
2716
+ this.masks = new MaskController(this);
1659
2717
  }
1660
2718
  // ─── Layer Operations ────────────────────────────────
1661
2719
  async addImage(url, options) {
1662
2720
  try {
1663
- const img = await import_fabric4.FabricImage.fromURL(
2721
+ const img = await import_fabric9.FabricImage.fromURL(
1664
2722
  url,
1665
2723
  {},
1666
2724
  { originX: "left", originY: "top", ...options }
@@ -1676,14 +2734,16 @@ var CanvasEditor = class {
1676
2734
  /** Replace an image source without changing its layer identity or visual transform. */
1677
2735
  async replaceImageSource(layerId, url) {
1678
2736
  const layer = this.layers.get(layerId);
1679
- if (!layer || layer.type !== "image") throw new Error(`Image layer not found: ${layerId}`);
2737
+ if (!layer || layer.type !== "image" && layer.type !== "mask") {
2738
+ throw new Error(`Image or mask layer not found: ${layerId}`);
2739
+ }
1680
2740
  if (layer.meta.pattern) {
1681
2741
  throw new Error("Clear the pattern before replacing the image source");
1682
2742
  }
1683
2743
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1684
2744
  const previous = layer.fabricObject;
1685
2745
  try {
1686
- const replacement = await import_fabric4.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2746
+ const replacement = await import_fabric9.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
1687
2747
  replacement.set({
1688
2748
  left: previous.left,
1689
2749
  top: previous.top,
@@ -1712,7 +2772,7 @@ var CanvasEditor = class {
1712
2772
  }
1713
2773
  }
1714
2774
  addText(text, options) {
1715
- const textbox = new import_fabric4.Textbox(text, {
2775
+ const textbox = new import_fabric9.Textbox(text, {
1716
2776
  fontSize: 32,
1717
2777
  fontFamily: "Arial",
1718
2778
  fill: "#000000",
@@ -1751,10 +2811,10 @@ var CanvasEditor = class {
1751
2811
  return value === void 0 ? token : escapeXml(value);
1752
2812
  })
1753
2813
  );
1754
- const { objects, options } = await (0, import_fabric4.loadSVGFromString)(resolved);
2814
+ const { objects, options } = await (0, import_fabric9.loadSVGFromString)(resolved);
1755
2815
  const validObjects = objects.filter((object) => object !== null);
1756
2816
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
1757
- const group = import_fabric4.util.groupSVGElements(validObjects, options);
2817
+ const group = import_fabric9.util.groupSVGElements(validObjects, options);
1758
2818
  group.set({
1759
2819
  left: this.canvas.getWidth() / 2,
1760
2820
  top: this.canvas.getHeight() / 2,
@@ -1767,6 +2827,9 @@ var CanvasEditor = class {
1767
2827
  }
1768
2828
  removeLayer(id) {
1769
2829
  if (this.crop.activeLayerId() === id) this.crop.cancel();
2830
+ if (this.masks.activeLayerId() === id) {
2831
+ this.masks.detach(id);
2832
+ }
1770
2833
  if (this.layers.remove(id)) this.history.save();
1771
2834
  }
1772
2835
  selectLayer(id) {
@@ -1824,10 +2887,10 @@ var CanvasEditor = class {
1824
2887
  const next = { ...previous, ...adjustments };
1825
2888
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
1826
2889
  image.filters = [
1827
- new import_fabric4.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
1828
- new import_fabric4.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
1829
- new import_fabric4.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
1830
- new import_fabric4.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
2890
+ new import_fabric9.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
2891
+ new import_fabric9.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
2892
+ new import_fabric9.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
2893
+ new import_fabric9.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
1831
2894
  ];
1832
2895
  layer.meta.imageAdjustments = next;
1833
2896
  image.applyFilters();
@@ -1844,7 +2907,7 @@ var CanvasEditor = class {
1844
2907
  const childData = children.map((layer) => structuredClone(layer.toData()));
1845
2908
  const objects = children.map((layer) => layer.fabricObject);
1846
2909
  for (const layer of children) this.layers.remove(layer.id);
1847
- const group = new import_fabric4.Group(objects);
2910
+ const group = new import_fabric9.Group(objects);
1848
2911
  const grouped = this.layers.add("group", group, name);
1849
2912
  grouped.meta.groupChildren = childData;
1850
2913
  this.layers.select(grouped.id);
@@ -1863,7 +2926,7 @@ var CanvasEditor = class {
1863
2926
  const objects = group.removeAll();
1864
2927
  this.layers.remove(id);
1865
2928
  const restored = objects.map((object, index) => {
1866
- import_fabric4.util.addTransformToObject(object, transform);
2929
+ import_fabric9.util.addTransformToObject(object, transform);
1867
2930
  object.setCoords();
1868
2931
  const data = childData[index];
1869
2932
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -1914,6 +2977,50 @@ var CanvasEditor = class {
1914
2977
  async toWebP(options) {
1915
2978
  return this.toRaster("webp", options);
1916
2979
  }
2980
+ /** Export one layer in document coordinates or at its native image resolution. */
2981
+ async exportLayer(id, options = {}) {
2982
+ const layer = this.layers.get(id);
2983
+ if (!layer) throw new Error(`Layer not found: ${id}`);
2984
+ try {
2985
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric9.FabricImage) {
2986
+ const image = await layer.fabricObject.clone();
2987
+ image.set({
2988
+ left: 0,
2989
+ top: 0,
2990
+ originX: "left",
2991
+ originY: "top",
2992
+ scaleX: 1,
2993
+ scaleY: 1,
2994
+ angle: 0,
2995
+ flipX: false,
2996
+ flipY: false
2997
+ });
2998
+ return await exportIsolatedPNG(this.canvas, [image], {
2999
+ ...options,
3000
+ width: image.width || 1,
3001
+ height: image.height || 1,
3002
+ cloneObjects: false
3003
+ });
3004
+ }
3005
+ return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
3006
+ } catch (error) {
3007
+ this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
3008
+ throw error;
3009
+ }
3010
+ }
3011
+ /** Export only the configured document background, excluding design layers. */
3012
+ async exportBackground(options = {}) {
3013
+ try {
3014
+ return await exportIsolatedPNG(this.canvas, [], {
3015
+ ...options,
3016
+ backgroundColor: this.designBackground,
3017
+ backgroundImage: this.designBackgroundImage
3018
+ });
3019
+ } catch (error) {
3020
+ this.events.emit("error", { message: "Failed to export background", error });
3021
+ throw error;
3022
+ }
3023
+ }
1917
3024
  async toRaster(format, options) {
1918
3025
  this.events.emit("export:start", { format });
1919
3026
  try {
@@ -1925,7 +3032,10 @@ var CanvasEditor = class {
1925
3032
  this.licensing.track(`export:${format}`);
1926
3033
  return blob;
1927
3034
  } catch (error) {
1928
- this.events.emit("error", { message: `Failed to export ${format.toUpperCase()}`, error });
3035
+ this.events.emit("error", {
3036
+ message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
3037
+ error
3038
+ });
1929
3039
  throw error;
1930
3040
  }
1931
3041
  }
@@ -1956,6 +3066,23 @@ var CanvasEditor = class {
1956
3066
  toDataURL(format = "png", multiplier = 1) {
1957
3067
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1958
3068
  }
3069
+ /**
3070
+ * Export the print file: the design cropped to the mockup's print area, on
3071
+ * transparency. Without a print area this is the whole canvas, still
3072
+ * transparent — a print file never carries the design background.
3073
+ */
3074
+ async toPrintFile(options = {}) {
3075
+ await this.fonts.ready();
3076
+ try {
3077
+ const area = this.mockup?.printArea;
3078
+ const blob = area ? await exportPrintArea(this.canvas, area, options) : await exportIsolatedPNG(this.canvas, this.canvas.getObjects(), options);
3079
+ this.licensing.track(`export:print:${options.format ?? "png"}`);
3080
+ return blob;
3081
+ } catch (error) {
3082
+ this.events.emit("error", { message: "Failed to export print file", error });
3083
+ throw error;
3084
+ }
3085
+ }
1959
3086
  /** Export the current product-preview composite. Advanced warping is host-defined. */
1960
3087
  async toMockupImage(options = {}) {
1961
3088
  if (!this.mockup) throw new Error("No mockup is configured");
@@ -2118,13 +3245,20 @@ var CanvasEditor = class {
2118
3245
  getDesignBackgroundImage() {
2119
3246
  return this.designBackgroundImage;
2120
3247
  }
3248
+ getBackgroundImageOptions() {
3249
+ return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
3250
+ }
2121
3251
  async setBackgroundImage(url, options = {}) {
2122
3252
  if (url === null) {
2123
3253
  this.setBackgroundImageObject(null);
2124
3254
  return;
2125
3255
  }
2126
3256
  try {
2127
- const image = await import_fabric4.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
3257
+ const image = await import_fabric9.FabricImage.fromURL(
3258
+ url,
3259
+ { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
3260
+ { originX: "left", originY: "top" }
3261
+ );
2128
3262
  const width = image.width || 1;
2129
3263
  const height = image.height || 1;
2130
3264
  const canvasWidth = this.canvas.getWidth();
@@ -2143,15 +3277,18 @@ var CanvasEditor = class {
2143
3277
  selectable: false,
2144
3278
  evented: false
2145
3279
  });
2146
- this.setBackgroundImageObject(image);
3280
+ const serializableOptions = { ...options };
3281
+ delete serializableOptions.signal;
3282
+ this.setBackgroundImageObject(image, true, serializableOptions);
2147
3283
  } catch (error) {
2148
3284
  this.events.emit("error", { message: "Failed to set background image", error });
2149
3285
  throw error;
2150
3286
  }
2151
3287
  }
2152
3288
  /** Used by state restoration and advanced integrations with an existing Fabric object. */
2153
- setBackgroundImageObject(image, save = true) {
3289
+ setBackgroundImageObject(image, save = true, options = null) {
2154
3290
  this.designBackgroundImage = image;
3291
+ this.backgroundImageOptions = image ? options : null;
2155
3292
  this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
2156
3293
  this.canvas.requestRenderAll();
2157
3294
  if (save) this.history.save();
@@ -2292,14 +3429,89 @@ var CanvasEditor = class {
2292
3429
  clearPattern(layerId) {
2293
3430
  return this.patterns.disable(layerId);
2294
3431
  }
3432
+ // ─── Text curve ─────────────────────────────────────
3433
+ applyTextCurve(layerId, config) {
3434
+ return this.curves.apply(layerId, config);
3435
+ }
3436
+ clearTextCurve(layerId) {
3437
+ return this.curves.clear(layerId);
3438
+ }
3439
+ getTextCurve(layerId) {
3440
+ return this.curves.get(layerId);
3441
+ }
3442
+ // ─── Mask presets ───────────────────────────────────
3443
+ applyMaskPreset(layerId, id) {
3444
+ return this.maskPresets.apply(layerId, id);
3445
+ }
3446
+ clearMaskPreset(layerId) {
3447
+ return this.maskPresets.clear(layerId);
3448
+ }
3449
+ getMaskPreset(layerId) {
3450
+ return this.maskPresets.get(layerId);
3451
+ }
3452
+ // ─── Layer transform ────────────────────────────────
3453
+ /**
3454
+ * Constrain a layer to its current proportions. Persisted on the layer so a
3455
+ * reopened design still resizes the way it was set up to.
3456
+ */
3457
+ setLayerAspectLock(layerId, locked) {
3458
+ const layer = this.layers.get(layerId);
3459
+ if (!layer) return false;
3460
+ applyAspectLock(layer.fabricObject, locked);
3461
+ if (locked) layer.meta.lockAspect = true;
3462
+ else delete layer.meta.lockAspect;
3463
+ this.canvas.requestRenderAll();
3464
+ this.events.emit("layer:modified", { layerId });
3465
+ this.history.save();
3466
+ return true;
3467
+ }
3468
+ getLayerAspectLock(layerId) {
3469
+ return this.layers.get(layerId)?.meta.lockAspect === true;
3470
+ }
3471
+ /** Re-apply every stored aspect lock — control visibility is not serialized. */
3472
+ restoreAspectLocks() {
3473
+ for (const layer of this.layers.getAll()) {
3474
+ if (layer.meta.lockAspect) applyAspectLock(layer.fabricObject, true);
3475
+ }
3476
+ }
3477
+ /** Drop scale, rotation, skew and flips; the layer stays where it is. */
3478
+ resetLayerTransform(layerId) {
3479
+ const layer = this.layers.get(layerId);
3480
+ if (!layer) return false;
3481
+ resetTransform(layer.fabricObject);
3482
+ this.canvas.requestRenderAll();
3483
+ this.events.emit("layer:modified", { layerId });
3484
+ this.history.save();
3485
+ return true;
3486
+ }
3487
+ // ─── Shadow ─────────────────────────────────────────
3488
+ setLayerShadow(layerId, config) {
3489
+ const layer = this.layers.get(layerId);
3490
+ if (!layer) return false;
3491
+ applyLayerShadow(layer.fabricObject, config);
3492
+ layer.fabricObject.dirty = true;
3493
+ this.canvas.requestRenderAll();
3494
+ this.events.emit("layer:modified", { layerId });
3495
+ this.history.save();
3496
+ return true;
3497
+ }
3498
+ getLayerShadow(layerId) {
3499
+ const layer = this.layers.get(layerId);
3500
+ return layer ? readLayerShadow(layer.fabricObject) : null;
3501
+ }
2295
3502
  // ─── Mockup (preview-only) ──────────────────────────
2296
- setMockup(mockup) {
3503
+ /**
3504
+ * Show (or clear) the product preview. Pass `history: false` for preview-only
3505
+ * changes such as swapping a colourway — those are not design edits and
3506
+ * should not fill the undo stack.
3507
+ */
3508
+ setMockup(mockup, options = {}) {
2297
3509
  this.mockup = mockup;
2298
3510
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2299
3511
  this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
2300
3512
  this.canvas.requestRenderAll();
2301
3513
  this.events.emit("mockup:changed", { mockup });
2302
- this.history.save();
3514
+ if (options.history !== false) this.history.save();
2303
3515
  }
2304
3516
  clearMockup() {
2305
3517
  this.setMockup(null);
@@ -2309,6 +3521,7 @@ var CanvasEditor = class {
2309
3521
  }
2310
3522
  // ─── Cleanup ────────────────────────────────────────
2311
3523
  dispose() {
3524
+ this.masks.dispose();
2312
3525
  this.snapping.dispose();
2313
3526
  this.crop.dispose();
2314
3527
  this.history.dispose();
@@ -2363,43 +3576,108 @@ var CANVAS_SIZE_PRESETS = [
2363
3576
  { id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
2364
3577
  { id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
2365
3578
  ];
3579
+
3580
+ // src/annotations.ts
3581
+ var AnnotationOverlay = class {
3582
+ items = /* @__PURE__ */ new Map();
3583
+ transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
3584
+ set(annotation) {
3585
+ this.items.set(annotation.id, structuredClone(annotation));
3586
+ }
3587
+ remove(id) {
3588
+ return this.items.delete(id);
3589
+ }
3590
+ clear() {
3591
+ this.items.clear();
3592
+ }
3593
+ getAll() {
3594
+ return [...this.items.values()].map((item) => structuredClone(item));
3595
+ }
3596
+ setTransform(transform) {
3597
+ if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
3598
+ throw new Error("Annotation zoom must be positive");
3599
+ }
3600
+ this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
3601
+ }
3602
+ documentToViewport(point) {
3603
+ return {
3604
+ x: point.x * this.transform.zoom + this.transform.panX,
3605
+ y: point.y * this.transform.zoom + this.transform.panY
3606
+ };
3607
+ }
3608
+ viewportToDocument(point) {
3609
+ return {
3610
+ x: (point.x - this.transform.panX) / this.transform.zoom,
3611
+ y: (point.y - this.transform.panY) / this.transform.zoom
3612
+ };
3613
+ }
3614
+ documentToDevice(point) {
3615
+ const viewport = this.documentToViewport(point);
3616
+ const ratio = this.transform.devicePixelRatio ?? 1;
3617
+ return { x: viewport.x * ratio, y: viewport.y * ratio };
3618
+ }
3619
+ };
2366
3620
  // Annotate the CommonJS export names for ESM import in node:
2367
3621
  0 && (module.exports = {
3622
+ AnnotationOverlay,
2368
3623
  CANVAS_SIZE_PRESETS,
2369
3624
  CanvasEditor,
2370
3625
  CropController,
3626
+ DEFAULT_LAYER_SHADOW,
2371
3627
  DEFAULT_PATTERN_CONFIG,
3628
+ DEFAULT_TEXT_CURVE,
2372
3629
  EventEmitter,
2373
3630
  FontRegistry,
2374
3631
  HistoryManager,
2375
3632
  Layer,
2376
3633
  LayerManager,
2377
3634
  LicenseManager,
3635
+ MaskController,
3636
+ MaskPresetManager,
3637
+ MaskRefinementError,
2378
3638
  PatternManager,
2379
3639
  ProjectManager,
3640
+ SHAPE_MASK_BOX,
3641
+ SHAPE_MASK_IDS,
2380
3642
  SnapManager,
3643
+ TEXTURE_MASK_IDS,
3644
+ TEXTURE_MASK_SIZE,
3645
+ TextCurveManager,
2381
3646
  UnitConverter,
3647
+ applyAspectLock,
3648
+ applyLayerShadow,
2382
3649
  applyPatternLocks,
3650
+ buildCurvePathData,
2383
3651
  buildPatternDataURL,
2384
3652
  captureLocks,
2385
3653
  clamp,
2386
3654
  clearPatternImageCache,
3655
+ clearTextureMaskCache,
2387
3656
  computeCoverPlacement,
2388
3657
  computePrintAreaClip,
2389
3658
  computeTilePositions,
2390
3659
  deserializeEditor,
3660
+ displaceRgba,
2391
3661
  drawTiles,
2392
3662
  escapeXml,
2393
3663
  exportDataURL,
2394
3664
  exportMockup,
2395
3665
  exportPNG,
3666
+ exportPrintArea,
2396
3667
  exportSVG,
2397
3668
  generateId,
2398
3669
  isCssColor,
3670
+ isMaskPresetId,
3671
+ isShapeMaskId,
3672
+ isTextureMaskId,
2399
3673
  loadPatternImage,
3674
+ readLayerShadow,
3675
+ renderTextureMask,
3676
+ resetTransform,
2400
3677
  restoreLocks,
2401
3678
  round2,
2402
3679
  sanitizeSvg,
2403
- serializeEditor
3680
+ serializeEditor,
3681
+ shapeMaskPathData
2404
3682
  });
2405
3683
  //# sourceMappingURL=index.js.map