@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.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  import {
2
2
  computeCoverPlacement,
3
3
  computePrintAreaClip,
4
+ displaceRgba,
4
5
  exportDataURL,
6
+ exportIsolatedPNG,
5
7
  exportMockup,
6
8
  exportPNG,
9
+ exportPrintArea,
7
10
  exportSVG
8
- } from "./chunk-NINRTPOJ.mjs";
11
+ } from "./chunk-MCBRZQ4M.mjs";
9
12
 
10
13
  // src/editor.ts
11
- import { Canvas, FabricImage as FabricImage2, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
14
+ import { Canvas, FabricImage as FabricImage4, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
12
15
 
13
16
  // src/events.ts
14
17
  var EventEmitter = class {
@@ -246,9 +249,13 @@ var LayerManager = class {
246
249
  };
247
250
 
248
251
  // src/history.ts
249
- var HistoryManager = class {
252
+ var HistoryManager = class _HistoryManager {
253
+ static ASSET_KEY = "__canvasEditorHistoryAsset";
250
254
  undoStack = [];
251
255
  redoStack = [];
256
+ assets = /* @__PURE__ */ new Map();
257
+ assetIds = /* @__PURE__ */ new Map();
258
+ nextAssetId = 1;
252
259
  maxSize;
253
260
  maxBytes;
254
261
  paused = false;
@@ -291,7 +298,8 @@ var HistoryManager = class {
291
298
  return;
292
299
  }
293
300
  this.cancelPending();
294
- const state = this.getState();
301
+ const rawState = this.getState();
302
+ const state = this.compactState(rawState);
295
303
  if (this.undoStack.at(-1) === state) {
296
304
  this.emitChanged();
297
305
  return;
@@ -303,7 +311,7 @@ var HistoryManager = class {
303
311
  this.redoStack = [];
304
312
  this.trimToBudget();
305
313
  this.events.emit("history:snapshot", {
306
- bytes: state.length * 2,
314
+ bytes: rawState.length * 2,
307
315
  totalBytes: this.snapshotBytes(),
308
316
  entries: this.undoStack.length
309
317
  });
@@ -331,15 +339,15 @@ var HistoryManager = class {
331
339
  }
332
340
  async undo() {
333
341
  this.cancelPending();
334
- const current = this.getState();
335
342
  const committed = this.undoStack.at(-1);
336
343
  if (!committed) return;
344
+ const current = this.compactState(this.getState());
337
345
  const currentIsCommitted = current === committed;
338
346
  if (currentIsCommitted && this.undoStack.length < 2) return;
339
347
  const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
340
348
  this.paused = true;
341
349
  try {
342
- await this.restoreState(target);
350
+ await this.restoreState(this.expandState(target));
343
351
  if (currentIsCommitted) this.undoStack.pop();
344
352
  this.redoStack.push(current);
345
353
  this.trimToBudget();
@@ -357,7 +365,7 @@ var HistoryManager = class {
357
365
  if (!state) return;
358
366
  this.paused = true;
359
367
  try {
360
- await this.restoreState(state);
368
+ await this.restoreState(this.expandState(state));
361
369
  this.redoStack.pop();
362
370
  if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
363
371
  this.trimToBudget();
@@ -390,6 +398,8 @@ var HistoryManager = class {
390
398
  this.cancelPending();
391
399
  this.undoStack = [];
392
400
  this.redoStack = [];
401
+ this.assets.clear();
402
+ this.assetIds.clear();
393
403
  this.emitChanged();
394
404
  }
395
405
  getSnapshotBytes() {
@@ -405,6 +415,10 @@ var HistoryManager = class {
405
415
  this.cancelPending();
406
416
  this.transactionDepth = 0;
407
417
  this.transactionDirty = false;
418
+ this.undoStack = [];
419
+ this.redoStack = [];
420
+ this.assets.clear();
421
+ this.assetIds.clear();
408
422
  }
409
423
  emitChanged() {
410
424
  this.events.emit("history:changed", {
@@ -413,17 +427,91 @@ var HistoryManager = class {
413
427
  });
414
428
  }
415
429
  snapshotBytes() {
416
- return [...this.undoStack, ...this.redoStack].reduce(
430
+ const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
417
431
  (total, state) => total + state.length * 2,
418
432
  0
419
433
  );
434
+ const assetBytes = [...this.assets.values()].reduce(
435
+ (total, asset) => total + asset.length * 2,
436
+ 0
437
+ );
438
+ return stackBytes + assetBytes;
420
439
  }
421
440
  trimToBudget() {
441
+ this.pruneAssets();
422
442
  while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
423
443
  this.undoStack.shift();
444
+ this.pruneAssets();
424
445
  }
425
446
  while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
426
447
  this.redoStack.shift();
448
+ this.pruneAssets();
449
+ }
450
+ }
451
+ /**
452
+ * History is internal and can content-address large raster strings without
453
+ * changing the public EditorState wire format. Unchanged images are retained
454
+ * once even when dozens of snapshots reference them.
455
+ */
456
+ compactState(state) {
457
+ let parsed;
458
+ try {
459
+ parsed = JSON.parse(state);
460
+ } catch {
461
+ return state;
462
+ }
463
+ const visit = (value) => {
464
+ if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
465
+ let id = this.assetIds.get(value);
466
+ if (!id) {
467
+ id = `a${this.nextAssetId++}`;
468
+ this.assetIds.set(value, id);
469
+ this.assets.set(id, value);
470
+ }
471
+ return { [_HistoryManager.ASSET_KEY]: id };
472
+ }
473
+ if (Array.isArray(value)) return value.map(visit);
474
+ if (!value || typeof value !== "object") return value;
475
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
476
+ };
477
+ return JSON.stringify(visit(parsed));
478
+ }
479
+ expandState(state) {
480
+ let parsed;
481
+ try {
482
+ parsed = JSON.parse(state);
483
+ } catch {
484
+ return state;
485
+ }
486
+ const visit = (value) => {
487
+ if (Array.isArray(value)) return value.map(visit);
488
+ if (!value || typeof value !== "object") return value;
489
+ const record = value;
490
+ const id = record[_HistoryManager.ASSET_KEY];
491
+ if (typeof id === "string" && Object.keys(record).length === 1) {
492
+ const asset = this.assets.get(id);
493
+ if (!asset) throw new Error(`Missing history raster asset: ${id}`);
494
+ return asset;
495
+ }
496
+ return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
497
+ };
498
+ return JSON.stringify(visit(parsed));
499
+ }
500
+ /**
501
+ * Runs on every commit, so it scans for the serialized reference marker
502
+ * instead of re-parsing every snapshot — parsing multi-megabyte raster states
503
+ * per save would cost more than the retention it reclaims. `JSON.stringify`
504
+ * emits the marker verbatim; the same text inside user data is escaped and
505
+ * therefore cannot match.
506
+ */
507
+ pruneAssets() {
508
+ if (this.assets.size === 0) return;
509
+ const states = [...this.undoStack, ...this.redoStack];
510
+ for (const [id, asset] of this.assets) {
511
+ const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
512
+ if (states.some((state) => state.includes(marker))) continue;
513
+ this.assets.delete(id);
514
+ this.assetIds.delete(asset);
427
515
  }
428
516
  }
429
517
  };
@@ -1001,6 +1089,521 @@ function mod2(n) {
1001
1089
  return (n % 2 + 2) % 2;
1002
1090
  }
1003
1091
 
1092
+ // src/text-curve.ts
1093
+ import { Path } from "fabric";
1094
+ var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1095
+ var MIN_ARC = 0.5;
1096
+ var FULL_CIRCLE_ARC = 99.5;
1097
+ var MIN_SWEEP = 0.12;
1098
+ var WAVE_PERIOD_EM = 4.1;
1099
+ var WAVE_AMPLITUDE_EM = 0.9;
1100
+ var WAVE_STEP = 6;
1101
+ var MEASURE_WIDTH = 1e5;
1102
+ var PATH_SLACK = 0.06;
1103
+ function isCurvable(object) {
1104
+ return !!object && typeof object.text === "string";
1105
+ }
1106
+ function measureText(text) {
1107
+ const authored = text.width;
1108
+ try {
1109
+ text.set({ width: MEASURE_WIDTH });
1110
+ text.initDimensions?.();
1111
+ return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1112
+ } finally {
1113
+ if (authored !== void 0) text.set({ width: authored });
1114
+ text.initDimensions?.();
1115
+ }
1116
+ }
1117
+ function arcPathData(width, arc) {
1118
+ const magnitude = Math.min(100, Math.abs(arc));
1119
+ const direction = arc < 0 ? -1 : 1;
1120
+ const full = magnitude >= FULL_CIRCLE_ARC;
1121
+ const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1122
+ const radius = width / sweep;
1123
+ const centerX = width / 2;
1124
+ const point = (angle) => [
1125
+ centerX + radius * Math.sin(angle),
1126
+ direction * radius * (1 - Math.cos(angle))
1127
+ ];
1128
+ const sweepFlag = direction > 0 ? 1 : 0;
1129
+ const format = ([x, y]) => `${round(x)} ${round(y)}`;
1130
+ if (full) {
1131
+ const start2 = point(-Math.PI);
1132
+ const top = point(0);
1133
+ return {
1134
+ data: [
1135
+ `M ${format(start2)}`,
1136
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
1137
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
1138
+ ].join(" "),
1139
+ length: width
1140
+ };
1141
+ }
1142
+ const start = point(-sweep / 2);
1143
+ const end = point(sweep / 2);
1144
+ const largeArc = sweep > Math.PI ? 1 : 0;
1145
+ return {
1146
+ data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1147
+ // radius = width / sweep, so the arc is exactly `width` long.
1148
+ length: width
1149
+ };
1150
+ }
1151
+ function wavePathData(width, fontSize, wave) {
1152
+ const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
1153
+ const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
1154
+ const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
1155
+ const commands = [];
1156
+ let length = 0;
1157
+ let previous = null;
1158
+ for (let index = 0; index <= steps; index++) {
1159
+ const x = width * index / steps;
1160
+ const y = amplitude * Math.sin(x / period * Math.PI * 2);
1161
+ if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
1162
+ previous = [x, y];
1163
+ commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
1164
+ }
1165
+ return { data: commands.join(" "), length };
1166
+ }
1167
+ function round(value) {
1168
+ return Math.round(value * 100) / 100;
1169
+ }
1170
+ function buildCurvePathData(config, width, fontSize) {
1171
+ if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
1172
+ if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
1173
+ return null;
1174
+ }
1175
+ function normalize(config) {
1176
+ const arc = clamp(config.arc ?? 0, -100, 100);
1177
+ return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
1178
+ }
1179
+ var TextCurveManager = class {
1180
+ constructor(canvas, layers, history, events) {
1181
+ this.canvas = canvas;
1182
+ this.layers = layers;
1183
+ this.history = history;
1184
+ this.events = events;
1185
+ }
1186
+ canvas;
1187
+ layers;
1188
+ history;
1189
+ events;
1190
+ /** Curve parameters for a layer, or null when it is not curved text. */
1191
+ get(layerId) {
1192
+ const layer = this.layers.get(layerId);
1193
+ if (!layer || !isCurvable(layer.fabricObject)) return null;
1194
+ return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
1195
+ }
1196
+ isCurved(layerId) {
1197
+ const curve = this.get(layerId);
1198
+ return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
1199
+ }
1200
+ /** Apply (or update) the curve on a text layer. Zeroed config clears it. */
1201
+ apply(layerId, config, save = true) {
1202
+ const layer = this.layers.get(layerId);
1203
+ if (!layer || !isCurvable(layer.fabricObject)) return false;
1204
+ const next = normalize(config);
1205
+ const text = layer.fabricObject;
1206
+ const run = measureText(text);
1207
+ const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
1208
+ if (!curve) {
1209
+ this.detach(text, layer.meta.curveWidth);
1210
+ delete layer.meta.curve;
1211
+ delete layer.meta.curveWidth;
1212
+ } else {
1213
+ if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1214
+ text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1215
+ text.set({
1216
+ path: new Path(curve.data, { visible: false, objectCaching: false }),
1217
+ pathAlign: "center",
1218
+ pathSide: "left",
1219
+ pathStartOffset: Math.max(0, (curve.length - run) / 2)
1220
+ });
1221
+ layer.meta.curve = next;
1222
+ }
1223
+ text.initDimensions?.();
1224
+ text.setCoords();
1225
+ text.dirty = true;
1226
+ this.canvas.requestRenderAll();
1227
+ this.events.emit("layer:modified", { layerId });
1228
+ if (save) this.history.save();
1229
+ return true;
1230
+ }
1231
+ /** Remove the curve, restoring the authored text box width. */
1232
+ clear(layerId, save = true) {
1233
+ return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
1234
+ }
1235
+ /**
1236
+ * Rebuild the path from the stored parameters. Text content, font family and
1237
+ * font size all change the run's width, and the path is sized to that width —
1238
+ * without this the curve keeps the geometry of the text it was created from.
1239
+ */
1240
+ refresh(layerId, save = false) {
1241
+ const curve = this.layers.get(layerId)?.meta.curve;
1242
+ if (!curve) return false;
1243
+ return this.apply(layerId, curve, save);
1244
+ }
1245
+ /** Rebuild every curved layer — used after a state restore. */
1246
+ refreshAll() {
1247
+ for (const layer of this.layers.getAll()) {
1248
+ if (layer.meta.curve) this.refresh(layer.id);
1249
+ }
1250
+ }
1251
+ detach(text, authoredWidth) {
1252
+ text.set({ path: null, pathStartOffset: 0 });
1253
+ if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1254
+ }
1255
+ };
1256
+
1257
+ // src/mask-presets/manager.ts
1258
+ import { FabricImage as FabricImage2, Path as Path2 } from "fabric";
1259
+
1260
+ // src/mask-presets/shapes.ts
1261
+ var SHAPE_MASK_IDS = [
1262
+ "circle",
1263
+ "square",
1264
+ "triangle",
1265
+ "star",
1266
+ "heart",
1267
+ "octagram",
1268
+ "arch",
1269
+ "zigzag"
1270
+ ];
1271
+ function isShapeMaskId(value) {
1272
+ return typeof value === "string" && SHAPE_MASK_IDS.includes(value);
1273
+ }
1274
+ function starPoints(points, outer, inner, start = -Math.PI / 2) {
1275
+ const step = Math.PI / points;
1276
+ const coordinates = [];
1277
+ for (let index = 0; index < points * 2; index++) {
1278
+ const radius = index % 2 === 0 ? outer : inner;
1279
+ const angle = start + index * step;
1280
+ const x = 50 + Math.cos(angle) * radius;
1281
+ const y = 50 + Math.sin(angle) * radius;
1282
+ coordinates.push(`${index === 0 ? "M" : "L"} ${round3(x)} ${round3(y)}`);
1283
+ }
1284
+ return `${coordinates.join(" ")} Z`;
1285
+ }
1286
+ function round3(value) {
1287
+ return Math.round(value * 100) / 100;
1288
+ }
1289
+ var SHAPE_PATHS = {
1290
+ circle: "M 96 50 A 46 46 0 1 1 4 50 A 46 46 0 1 1 96 50 Z",
1291
+ square: "M 4 4 H 96 V 96 H 4 Z",
1292
+ triangle: "M 50 4 L 96 96 L 4 96 Z",
1293
+ star: starPoints(5, 46, 22),
1294
+ heart: "M 50 96 C -8 55 12 4 50 28 C 88 4 108 55 50 96 Z",
1295
+ octagram: starPoints(8, 46, 27),
1296
+ arch: "M 4 96 V 50 A 46 46 0 0 1 96 50 V 96 Z",
1297
+ zigzag: starPoints(16, 46, 39)
1298
+ };
1299
+ function shapeMaskPathData(id) {
1300
+ return SHAPE_PATHS[id];
1301
+ }
1302
+ var SHAPE_MASK_BOX = 100;
1303
+
1304
+ // src/mask-presets/textures.ts
1305
+ var TEXTURE_MASK_IDS = [
1306
+ "vignette",
1307
+ "halftone",
1308
+ "spray",
1309
+ "grunge",
1310
+ "torn",
1311
+ "band"
1312
+ ];
1313
+ function isTextureMaskId(value) {
1314
+ return typeof value === "string" && TEXTURE_MASK_IDS.includes(value);
1315
+ }
1316
+ var TEXTURE_MASK_SIZE = 320;
1317
+ var cache = /* @__PURE__ */ new Map();
1318
+ function seeded(seed) {
1319
+ let state = seed;
1320
+ return () => {
1321
+ state = state * 16807 % 2147483647;
1322
+ return (state - 1) / 2147483646;
1323
+ };
1324
+ }
1325
+ function traceRoughEdge(context, random, jitter, inset) {
1326
+ const size = TEXTURE_MASK_SIZE;
1327
+ const corners = [
1328
+ [inset, inset],
1329
+ [size - inset, inset],
1330
+ [size - inset, size - inset],
1331
+ [inset, size - inset]
1332
+ ];
1333
+ context.beginPath();
1334
+ for (let corner = 0; corner < corners.length; corner++) {
1335
+ const [fromX, fromY] = corners[corner];
1336
+ const [toX, toY] = corners[(corner + 1) % corners.length];
1337
+ for (let step = 0; step <= 26; step++) {
1338
+ const ratio = step / 26;
1339
+ const x = fromX + (toX - fromX) * ratio + (random() - 0.5) * jitter;
1340
+ const y = fromY + (toY - fromY) * ratio + (random() - 0.5) * jitter;
1341
+ if (corner === 0 && step === 0) context.moveTo(x, y);
1342
+ else context.lineTo(x, y);
1343
+ }
1344
+ }
1345
+ context.closePath();
1346
+ context.fill();
1347
+ }
1348
+ function paint(id, context) {
1349
+ const size = TEXTURE_MASK_SIZE;
1350
+ const center = size / 2;
1351
+ context.fillStyle = "#ffffff";
1352
+ switch (id) {
1353
+ case "vignette": {
1354
+ const gradient = context.createRadialGradient(
1355
+ center,
1356
+ center,
1357
+ size * 0.18,
1358
+ center,
1359
+ center,
1360
+ size * 0.52
1361
+ );
1362
+ gradient.addColorStop(0, "rgba(255,255,255,1)");
1363
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1364
+ context.fillStyle = gradient;
1365
+ context.fillRect(0, 0, size, size);
1366
+ return;
1367
+ }
1368
+ case "band": {
1369
+ const gradient = context.createLinearGradient(0, 0, 0, size);
1370
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
1371
+ gradient.addColorStop(0.25, "rgba(255,255,255,1)");
1372
+ gradient.addColorStop(0.75, "rgba(255,255,255,1)");
1373
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1374
+ context.fillStyle = gradient;
1375
+ context.fillRect(0, 0, size, size);
1376
+ return;
1377
+ }
1378
+ case "halftone": {
1379
+ const pitch = 16;
1380
+ for (let y = pitch / 2; y < size; y += pitch) {
1381
+ for (let x = pitch / 2; x < size; x += pitch) {
1382
+ const distance = Math.hypot(x - center, y - center) / (size * 0.52);
1383
+ const radius = Math.max(0, pitch / 2 * (1 - distance) * 1.15);
1384
+ if (radius < 0.4) continue;
1385
+ context.beginPath();
1386
+ context.arc(x, y, radius, 0, Math.PI * 2);
1387
+ context.fill();
1388
+ }
1389
+ }
1390
+ return;
1391
+ }
1392
+ case "spray": {
1393
+ const random = seeded(42);
1394
+ const core = context.createRadialGradient(center, center, 10, center, center, size * 0.42);
1395
+ core.addColorStop(0, "rgba(255,255,255,1)");
1396
+ core.addColorStop(1, "rgba(255,255,255,0.85)");
1397
+ context.fillStyle = core;
1398
+ context.beginPath();
1399
+ context.arc(center, center, size * 0.42, 0, Math.PI * 2);
1400
+ context.fill();
1401
+ context.fillStyle = "rgba(255,255,255,0.9)";
1402
+ for (let dot = 0; dot < 900; dot++) {
1403
+ const angle = random() * Math.PI * 2;
1404
+ const distance = size * (0.3 + random() * 0.24);
1405
+ const x = center + Math.cos(angle) * distance;
1406
+ const y = center + Math.sin(angle) * distance;
1407
+ context.globalAlpha = 1 - (distance / size - 0.3) / 0.24;
1408
+ context.beginPath();
1409
+ context.arc(x, y, random() * 2.4, 0, Math.PI * 2);
1410
+ context.fill();
1411
+ }
1412
+ context.globalAlpha = 1;
1413
+ return;
1414
+ }
1415
+ case "grunge": {
1416
+ const random = seeded(7);
1417
+ traceRoughEdge(context, random, 14, size * 0.06);
1418
+ context.globalCompositeOperation = "destination-out";
1419
+ for (let speckle = 0; speckle < 240; speckle++) {
1420
+ const x = random() * size;
1421
+ const y = random() * size;
1422
+ const nearEdge = Math.min(x, y, size - x, size - y) < size * 0.12;
1423
+ if (!nearEdge && random() >= 0.12) continue;
1424
+ context.beginPath();
1425
+ context.arc(x, y, random() * 6 + 1, 0, Math.PI * 2);
1426
+ context.fill();
1427
+ }
1428
+ context.globalCompositeOperation = "source-over";
1429
+ return;
1430
+ }
1431
+ case "torn": {
1432
+ traceRoughEdge(context, seeded(99), 10, size * 0.08);
1433
+ return;
1434
+ }
1435
+ }
1436
+ }
1437
+ function renderTextureMask(id) {
1438
+ const cached = cache.get(id);
1439
+ if (cached) return cached;
1440
+ if (typeof document === "undefined") {
1441
+ throw new Error("Texture masks require a DOM canvas");
1442
+ }
1443
+ const canvas = document.createElement("canvas");
1444
+ canvas.width = TEXTURE_MASK_SIZE;
1445
+ canvas.height = TEXTURE_MASK_SIZE;
1446
+ const context = canvas.getContext("2d");
1447
+ if (!context) throw new Error("Texture masks require a 2D canvas context");
1448
+ paint(id, context);
1449
+ cache.set(id, canvas);
1450
+ return canvas;
1451
+ }
1452
+ function clearTextureMaskCache() {
1453
+ cache.clear();
1454
+ }
1455
+
1456
+ // src/mask-presets/manager.ts
1457
+ function isMaskPresetId(value) {
1458
+ return isShapeMaskId(value) || isTextureMaskId(value);
1459
+ }
1460
+ var MaskPresetManager = class {
1461
+ constructor(canvas, layers, history, events) {
1462
+ this.canvas = canvas;
1463
+ this.layers = layers;
1464
+ this.history = history;
1465
+ this.events = events;
1466
+ }
1467
+ canvas;
1468
+ layers;
1469
+ history;
1470
+ events;
1471
+ get(layerId) {
1472
+ return this.layers.get(layerId)?.meta.maskPreset ?? null;
1473
+ }
1474
+ /** Clip the layer to `id`. Passing null (or an unknown id) clears the clip. */
1475
+ apply(layerId, id, save = true) {
1476
+ const layer = this.layers.get(layerId);
1477
+ if (!layer) return false;
1478
+ const object = layer.fabricObject;
1479
+ if (id === null) {
1480
+ if (!layer.meta.maskPreset) return false;
1481
+ object.clipPath = void 0;
1482
+ delete layer.meta.maskPreset;
1483
+ } else {
1484
+ if (!isMaskPresetId(id)) return false;
1485
+ object.clipPath = this.buildClip(id, object);
1486
+ layer.meta.maskPreset = id;
1487
+ }
1488
+ object.dirty = true;
1489
+ object.setCoords();
1490
+ this.canvas.requestRenderAll();
1491
+ this.events.emit("layer:modified", { layerId });
1492
+ if (save) this.history.save();
1493
+ return true;
1494
+ }
1495
+ clear(layerId, save = true) {
1496
+ return this.apply(layerId, null, save);
1497
+ }
1498
+ /**
1499
+ * Re-fit the clip to the layer's current size. The clip is built for the
1500
+ * object's dimensions at the time it was applied; editing text or replacing an
1501
+ * image changes them, and a stale clip would crop the wrong region.
1502
+ */
1503
+ refresh(layerId, save = false) {
1504
+ const id = this.get(layerId);
1505
+ if (!id) return false;
1506
+ return this.apply(layerId, id, save);
1507
+ }
1508
+ /** Re-fit every masked layer — used after a state restore. */
1509
+ refreshAll() {
1510
+ for (const layer of this.layers.getAll()) {
1511
+ if (layer.meta.maskPreset) this.refresh(layer.id);
1512
+ }
1513
+ }
1514
+ buildClip(id, object) {
1515
+ const width = Math.max(1, object.width ?? 1);
1516
+ const height = Math.max(1, object.height ?? 1);
1517
+ const shared = {
1518
+ originX: "center",
1519
+ originY: "center",
1520
+ left: 0,
1521
+ top: 0,
1522
+ objectCaching: false
1523
+ };
1524
+ if (isShapeMaskId(id)) {
1525
+ return new Path2(shapeMaskPathData(id), {
1526
+ ...shared,
1527
+ scaleX: width / SHAPE_MASK_BOX,
1528
+ scaleY: height / SHAPE_MASK_BOX
1529
+ });
1530
+ }
1531
+ return new FabricImage2(renderTextureMask(id), {
1532
+ ...shared,
1533
+ scaleX: width / TEXTURE_MASK_SIZE,
1534
+ scaleY: height / TEXTURE_MASK_SIZE
1535
+ });
1536
+ }
1537
+ };
1538
+
1539
+ // src/shadow.ts
1540
+ import { Shadow } from "fabric";
1541
+
1542
+ // src/utils/color.ts
1543
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1544
+ function isCssColor(value, allowEmpty = false) {
1545
+ if (typeof value !== "string") return false;
1546
+ const trimmed = value.trim();
1547
+ if (!trimmed) return allowEmpty;
1548
+ return CSS_COLOR.test(trimmed);
1549
+ }
1550
+
1551
+ // src/shadow.ts
1552
+ var DEFAULT_LAYER_SHADOW = {
1553
+ enabled: false,
1554
+ color: "#000000",
1555
+ blur: 12,
1556
+ offsetX: 6,
1557
+ offsetY: 6
1558
+ };
1559
+ function readLayerShadow(object) {
1560
+ const shadow = object.shadow;
1561
+ if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
1562
+ return {
1563
+ enabled: true,
1564
+ color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
1565
+ blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
1566
+ offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
1567
+ offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
1568
+ };
1569
+ }
1570
+ function applyLayerShadow(object, config) {
1571
+ const next = { ...readLayerShadow(object), ...config };
1572
+ if (!next.enabled) {
1573
+ object.set({ shadow: null });
1574
+ return;
1575
+ }
1576
+ const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
1577
+ object.set({
1578
+ shadow: new Shadow({
1579
+ color,
1580
+ blur: Math.max(0, next.blur),
1581
+ offsetX: next.offsetX,
1582
+ offsetY: next.offsetY
1583
+ })
1584
+ });
1585
+ }
1586
+
1587
+ // src/transform.ts
1588
+ var SIDE_CONTROLS = ["ml", "mr", "mt", "mb"];
1589
+ function applyAspectLock(object, locked) {
1590
+ for (const control of SIDE_CONTROLS) {
1591
+ object.setControlVisible(control, !locked);
1592
+ }
1593
+ }
1594
+ function resetTransform(object) {
1595
+ object.set({
1596
+ scaleX: 1,
1597
+ scaleY: 1,
1598
+ angle: 0,
1599
+ skewX: 0,
1600
+ skewY: 0,
1601
+ flipX: false,
1602
+ flipY: false
1603
+ });
1604
+ object.setCoords();
1605
+ }
1606
+
1004
1607
  // src/utils/units.ts
1005
1608
  var MM_PER_INCH = 25.4;
1006
1609
  var UnitConverter = class {
@@ -1050,17 +1653,6 @@ var UnitConverter = class {
1050
1653
 
1051
1654
  // src/serialization.ts
1052
1655
  import { util as util2 } from "fabric";
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
1656
  var VERSION = "2.0.0";
1065
1657
  function serializeEditor(editor) {
1066
1658
  return {
@@ -1076,6 +1668,7 @@ function serializeEditor(editor) {
1076
1668
  // forced transparent while a mockup preview is active).
1077
1669
  background: editor.getDesignBackground(),
1078
1670
  backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
1671
+ backgroundImageOptions: editor.getBackgroundImageOptions(),
1079
1672
  mockup: editor.getMockup()
1080
1673
  };
1081
1674
  }
@@ -1094,13 +1687,28 @@ async function deserializeEditor(editor, state) {
1094
1687
  throw new Error("Invalid editor background color");
1095
1688
  }
1096
1689
  const staged = await Promise.all(
1097
- state.layers.map(async (serialized) => ({
1098
- serialized,
1099
- fabricObject: (await util2.enlivenObjects([serialized.fabricObject]))[0]
1100
- }))
1690
+ state.layers.map(async (serialized) => {
1691
+ const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
1692
+ if (!fabricObject) {
1693
+ const source = serialized.fabricObject.src;
1694
+ if (typeof source === "string" && source.startsWith("blob:")) {
1695
+ throw new Error(`Failed to restore expired object URL: ${source}`);
1696
+ }
1697
+ throw new Error(`Failed to restore layer: ${serialized.id}`);
1698
+ }
1699
+ return { serialized, fabricObject };
1700
+ })
1101
1701
  );
1102
1702
  const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
1703
+ if (state.backgroundImage && !stagedBackground) {
1704
+ const source = state.backgroundImage.src;
1705
+ if (typeof source === "string" && source.startsWith("blob:")) {
1706
+ throw new Error(`Failed to restore expired background object URL: ${source}`);
1707
+ }
1708
+ throw new Error("Failed to restore background image");
1709
+ }
1103
1710
  editor.crop.cancel();
1711
+ editor.masks.detach();
1104
1712
  editor.layers.clear();
1105
1713
  if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
1106
1714
  if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
@@ -1108,7 +1716,11 @@ async function deserializeEditor(editor, state) {
1108
1716
  if (state.background !== void 0) {
1109
1717
  editor.setBackground(state.background);
1110
1718
  }
1111
- editor.setBackgroundImageObject(stagedBackground, false);
1719
+ editor.setBackgroundImageObject(
1720
+ stagedBackground ?? null,
1721
+ false,
1722
+ state.backgroundImageOptions ?? null
1723
+ );
1112
1724
  editor.setMockup(state.mockup ?? null);
1113
1725
  for (const item of staged) {
1114
1726
  restoreLayer(editor, item.serialized, item.fabricObject);
@@ -1119,6 +1731,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1119
1731
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
1120
1732
  if (serialized.meta) {
1121
1733
  layer.meta = serialized.meta;
1734
+ if (layer.meta.lockAspect) applyAspectLock(fabricObject, true);
1122
1735
  }
1123
1736
  if (!serialized.visible) {
1124
1737
  editor.layers.setVisibility(layer.id, false);
@@ -1445,9 +2058,270 @@ var ProjectManager = class {
1445
2058
  }
1446
2059
  };
1447
2060
 
2061
+ // src/mask.ts
2062
+ import { FabricImage as FabricImage3 } from "fabric";
2063
+ var MaskRefinementError = class extends Error {
2064
+ constructor(code, message, cause) {
2065
+ super(message);
2066
+ this.code = code;
2067
+ this.cause = cause;
2068
+ this.name = "MaskRefinementError";
2069
+ }
2070
+ code;
2071
+ cause;
2072
+ };
2073
+ var MaskController = class {
2074
+ constructor(editor) {
2075
+ this.editor = editor;
2076
+ }
2077
+ editor;
2078
+ backing = null;
2079
+ context = null;
2080
+ layerId = null;
2081
+ brush = null;
2082
+ previousPoint = null;
2083
+ strokeBackup = null;
2084
+ strokeStartedAt = null;
2085
+ lastInteractionLatencyMs = 0;
2086
+ refinement = null;
2087
+ disposed = false;
2088
+ async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
2089
+ this.assertActive();
2090
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
2091
+ throw new Error("Mask dimensions must be positive integers");
2092
+ }
2093
+ const backing = this.makeCanvas(width, height);
2094
+ const image = new FabricImage3(backing, {
2095
+ left: 0,
2096
+ top: 0,
2097
+ originX: "left",
2098
+ originY: "top",
2099
+ selectable: false
2100
+ });
2101
+ const layer = this.editor.layers.add("mask", image, "Mask");
2102
+ layer.meta.mask = { width, height, revision: 0 };
2103
+ this.editor.history.saveImmediate();
2104
+ this.attachBacking(layer.id, backing);
2105
+ return layer;
2106
+ }
2107
+ attach(layerId) {
2108
+ this.assertActive();
2109
+ this.cancelStroke();
2110
+ const layer = this.requireMask(layerId);
2111
+ const image = layer.fabricObject;
2112
+ const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
2113
+ const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
2114
+ const backing = this.makeCanvas(width, height);
2115
+ const context = backing.getContext("2d");
2116
+ if (!context) throw new Error("2D mask context is unavailable");
2117
+ const element = image.getElement();
2118
+ if (element) context.drawImage(element, 0, 0, width, height);
2119
+ this.attachBacking(layerId, backing);
2120
+ }
2121
+ beginStroke(options) {
2122
+ this.assertActive();
2123
+ if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
2124
+ if (this.brush) throw new Error("A mask stroke is already active");
2125
+ if (!Number.isFinite(options.size) || options.size <= 0) {
2126
+ throw new Error("Mask brush size must be positive");
2127
+ }
2128
+ this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
2129
+ this.previousPoint = null;
2130
+ this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
2131
+ this.strokeStartedAt = performance.now();
2132
+ }
2133
+ addPoint(point) {
2134
+ if (!this.brush || !this.context || !this.backing || !this.layerId) {
2135
+ throw new Error("No active mask stroke");
2136
+ }
2137
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
2138
+ const previous = this.previousPoint ?? point;
2139
+ const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
2140
+ const step = Math.max(1, this.brush.size / 4);
2141
+ const samples = Math.max(1, Math.ceil(distance / step));
2142
+ for (let index = 0; index <= samples; index += 1) {
2143
+ const ratio = index / samples;
2144
+ this.drawDot({
2145
+ x: previous.x + (point.x - previous.x) * ratio,
2146
+ y: previous.y + (point.y - previous.y) * ratio
2147
+ });
2148
+ }
2149
+ this.previousPoint = point;
2150
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
2151
+ this.editor.canvas.requestRenderAll();
2152
+ }
2153
+ async endStroke() {
2154
+ if (!this.brush || !this.backing || !this.layerId) return;
2155
+ const layerId = this.layerId;
2156
+ this.brush = null;
2157
+ this.previousPoint = null;
2158
+ this.strokeBackup = null;
2159
+ const dataUrl = this.backing.toDataURL("image/png");
2160
+ await this.editor.history.transaction(async () => {
2161
+ await this.editor.replaceImageSource(layerId, dataUrl);
2162
+ const layer = this.requireMask(layerId);
2163
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
2164
+ });
2165
+ this.attach(layerId);
2166
+ if (this.strokeStartedAt !== null) {
2167
+ this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
2168
+ this.strokeStartedAt = null;
2169
+ }
2170
+ }
2171
+ cancelStroke() {
2172
+ if (this.strokeBackup && this.context && this.backing && this.layerId) {
2173
+ this.context.putImageData(this.strokeBackup, 0, 0);
2174
+ this.requireMask(this.layerId).fabricObject.setElement(this.backing);
2175
+ this.editor.canvas.requestRenderAll();
2176
+ }
2177
+ this.brush = null;
2178
+ this.previousPoint = null;
2179
+ this.strokeBackup = null;
2180
+ this.strokeStartedAt = null;
2181
+ }
2182
+ isStrokeActive() {
2183
+ return this.brush !== null;
2184
+ }
2185
+ activeLayerId() {
2186
+ return this.layerId;
2187
+ }
2188
+ detach(layerId) {
2189
+ if (layerId && this.layerId !== layerId) return;
2190
+ this.cancelStroke();
2191
+ this.cancelRefinement();
2192
+ this.backing = null;
2193
+ this.context = null;
2194
+ this.layerId = null;
2195
+ }
2196
+ async refine(layerId, provider, prompts, options = {}) {
2197
+ this.assertActive();
2198
+ const layer = this.editor.layers.get(layerId);
2199
+ if (!layer || layer.type !== "mask") {
2200
+ throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
2201
+ }
2202
+ this.cancelRefinement();
2203
+ const controller = new AbortController();
2204
+ this.refinement = controller;
2205
+ const abort = () => controller.abort(options.signal?.reason);
2206
+ options.signal?.addEventListener("abort", abort, { once: true });
2207
+ if (options.signal?.aborted) abort();
2208
+ try {
2209
+ const image = layer.fabricObject;
2210
+ const result = await provider.refine(
2211
+ {
2212
+ mask: image.getSrc(),
2213
+ width: layer.meta.mask?.width ?? image.width ?? 1,
2214
+ height: layer.meta.mask?.height ?? image.height ?? 1,
2215
+ prompts: structuredClone(prompts)
2216
+ },
2217
+ { signal: controller.signal, onProgress: options.onProgress }
2218
+ );
2219
+ if (controller.signal.aborted)
2220
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
2221
+ if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
2222
+ throw new MaskRefinementError(
2223
+ "invalid-result",
2224
+ "Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
2225
+ );
2226
+ }
2227
+ await this.editor.history.transaction(async () => {
2228
+ await this.editor.replaceImageSource(layerId, result.dataUrl);
2229
+ if (layer.meta.mask) layer.meta.mask.revision += 1;
2230
+ });
2231
+ this.attach(layerId);
2232
+ return result;
2233
+ } catch (error) {
2234
+ if (error instanceof MaskRefinementError) throw error;
2235
+ if (controller.signal.aborted) {
2236
+ throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
2237
+ }
2238
+ throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
2239
+ } finally {
2240
+ options.signal?.removeEventListener("abort", abort);
2241
+ if (this.refinement === controller) this.refinement = null;
2242
+ }
2243
+ }
2244
+ cancelRefinement() {
2245
+ this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
2246
+ this.refinement = null;
2247
+ }
2248
+ measure() {
2249
+ if (!this.backing) return null;
2250
+ const started = performance.now();
2251
+ this.context?.getImageData(0, 0, 1, 1);
2252
+ const backingBytes = this.backing.width * this.backing.height * 4;
2253
+ const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
2254
+ const memory = performance;
2255
+ return {
2256
+ width: this.backing.width,
2257
+ height: this.backing.height,
2258
+ backingBytes,
2259
+ strokeBackupBytes,
2260
+ // The backing store and rollback ImageData dominate interactive mask
2261
+ // memory. Encoded historical rasters are reported separately below.
2262
+ estimatedPeakBytes: backingBytes + strokeBackupBytes,
2263
+ historyBytes: this.editor.history.getSnapshotBytes(),
2264
+ interactionLatencyMs: this.lastInteractionLatencyMs,
2265
+ ...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
2266
+ elapsedMs: performance.now() - started
2267
+ };
2268
+ }
2269
+ dispose() {
2270
+ this.detach();
2271
+ this.disposed = true;
2272
+ }
2273
+ drawDot(point) {
2274
+ const context = this.context;
2275
+ const brush = this.brush;
2276
+ const radius = brush.size / 2;
2277
+ context.save();
2278
+ context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
2279
+ const gradient = context.createRadialGradient(
2280
+ point.x,
2281
+ point.y,
2282
+ radius * brush.hardness,
2283
+ point.x,
2284
+ point.y,
2285
+ radius
2286
+ );
2287
+ const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2288
+ gradient.addColorStop(0, color);
2289
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
2290
+ context.fillStyle = gradient;
2291
+ context.beginPath();
2292
+ context.arc(point.x, point.y, radius, 0, Math.PI * 2);
2293
+ context.fill();
2294
+ context.restore();
2295
+ }
2296
+ makeCanvas(width, height) {
2297
+ const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
2298
+ canvas.width = width;
2299
+ canvas.height = height;
2300
+ return canvas;
2301
+ }
2302
+ attachBacking(layerId, backing) {
2303
+ const context = backing.getContext("2d");
2304
+ if (!context) throw new Error("2D mask context is unavailable");
2305
+ this.layerId = layerId;
2306
+ this.backing = backing;
2307
+ this.context = context;
2308
+ }
2309
+ requireMask(layerId) {
2310
+ const layer = this.editor.layers.get(layerId);
2311
+ if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
2312
+ return layer;
2313
+ }
2314
+ assertActive() {
2315
+ if (this.disposed) throw new Error("Mask controller has been disposed");
2316
+ }
2317
+ };
2318
+
1448
2319
  // src/editor.ts
1449
2320
  var MIN_ZOOM = 0.1;
1450
2321
  var MAX_ZOOM = 8;
2322
+ function isTaintedCanvasError(error) {
2323
+ return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
2324
+ }
1451
2325
  var CanvasEditor = class {
1452
2326
  canvas;
1453
2327
  layers;
@@ -1457,9 +2331,12 @@ var CanvasEditor = class {
1457
2331
  snapping;
1458
2332
  crop;
1459
2333
  patterns;
2334
+ curves;
2335
+ maskPresets;
1460
2336
  fonts;
1461
2337
  licensing;
1462
2338
  pages;
2339
+ masks;
1463
2340
  fileAdapter;
1464
2341
  imageProvider;
1465
2342
  zoomLevel = 1;
@@ -1469,6 +2346,7 @@ var CanvasEditor = class {
1469
2346
  // for serialization and export — not the (possibly transient) canvas value.
1470
2347
  designBackground;
1471
2348
  designBackgroundImage = null;
2349
+ backgroundImageOptions = null;
1472
2350
  constructor(canvasElement, config) {
1473
2351
  this.events = new EventEmitter();
1474
2352
  this.fonts = new FontRegistry();
@@ -1506,14 +2384,17 @@ var CanvasEditor = class {
1506
2384
  this.events,
1507
2385
  config.patternSourceResolver
1508
2386
  );
2387
+ this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2388
+ this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
1509
2389
  this.setupCanvasEvents();
1510
2390
  this.history.saveImmediate();
1511
2391
  this.pages = new ProjectManager(this);
2392
+ this.masks = new MaskController(this);
1512
2393
  }
1513
2394
  // ─── Layer Operations ────────────────────────────────
1514
2395
  async addImage(url, options) {
1515
2396
  try {
1516
- const img = await FabricImage2.fromURL(
2397
+ const img = await FabricImage4.fromURL(
1517
2398
  url,
1518
2399
  {},
1519
2400
  { originX: "left", originY: "top", ...options }
@@ -1529,14 +2410,16 @@ var CanvasEditor = class {
1529
2410
  /** Replace an image source without changing its layer identity or visual transform. */
1530
2411
  async replaceImageSource(layerId, url) {
1531
2412
  const layer = this.layers.get(layerId);
1532
- if (!layer || layer.type !== "image") throw new Error(`Image layer not found: ${layerId}`);
2413
+ if (!layer || layer.type !== "image" && layer.type !== "mask") {
2414
+ throw new Error(`Image or mask layer not found: ${layerId}`);
2415
+ }
1533
2416
  if (layer.meta.pattern) {
1534
2417
  throw new Error("Clear the pattern before replacing the image source");
1535
2418
  }
1536
2419
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1537
2420
  const previous = layer.fabricObject;
1538
2421
  try {
1539
- const replacement = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
2422
+ const replacement = await FabricImage4.fromURL(url, {}, { originX: "left", originY: "top" });
1540
2423
  replacement.set({
1541
2424
  left: previous.left,
1542
2425
  top: previous.top,
@@ -1620,6 +2503,9 @@ var CanvasEditor = class {
1620
2503
  }
1621
2504
  removeLayer(id) {
1622
2505
  if (this.crop.activeLayerId() === id) this.crop.cancel();
2506
+ if (this.masks.activeLayerId() === id) {
2507
+ this.masks.detach(id);
2508
+ }
1623
2509
  if (this.layers.remove(id)) this.history.save();
1624
2510
  }
1625
2511
  selectLayer(id) {
@@ -1767,6 +2653,50 @@ var CanvasEditor = class {
1767
2653
  async toWebP(options) {
1768
2654
  return this.toRaster("webp", options);
1769
2655
  }
2656
+ /** Export one layer in document coordinates or at its native image resolution. */
2657
+ async exportLayer(id, options = {}) {
2658
+ const layer = this.layers.get(id);
2659
+ if (!layer) throw new Error(`Layer not found: ${id}`);
2660
+ try {
2661
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage4) {
2662
+ const image = await layer.fabricObject.clone();
2663
+ image.set({
2664
+ left: 0,
2665
+ top: 0,
2666
+ originX: "left",
2667
+ originY: "top",
2668
+ scaleX: 1,
2669
+ scaleY: 1,
2670
+ angle: 0,
2671
+ flipX: false,
2672
+ flipY: false
2673
+ });
2674
+ return await exportIsolatedPNG(this.canvas, [image], {
2675
+ ...options,
2676
+ width: image.width || 1,
2677
+ height: image.height || 1,
2678
+ cloneObjects: false
2679
+ });
2680
+ }
2681
+ return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
2682
+ } catch (error) {
2683
+ this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
2684
+ throw error;
2685
+ }
2686
+ }
2687
+ /** Export only the configured document background, excluding design layers. */
2688
+ async exportBackground(options = {}) {
2689
+ try {
2690
+ return await exportIsolatedPNG(this.canvas, [], {
2691
+ ...options,
2692
+ backgroundColor: this.designBackground,
2693
+ backgroundImage: this.designBackgroundImage
2694
+ });
2695
+ } catch (error) {
2696
+ this.events.emit("error", { message: "Failed to export background", error });
2697
+ throw error;
2698
+ }
2699
+ }
1770
2700
  async toRaster(format, options) {
1771
2701
  this.events.emit("export:start", { format });
1772
2702
  try {
@@ -1778,7 +2708,10 @@ var CanvasEditor = class {
1778
2708
  this.licensing.track(`export:${format}`);
1779
2709
  return blob;
1780
2710
  } catch (error) {
1781
- this.events.emit("error", { message: `Failed to export ${format.toUpperCase()}`, error });
2711
+ this.events.emit("error", {
2712
+ message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
2713
+ error
2714
+ });
1782
2715
  throw error;
1783
2716
  }
1784
2717
  }
@@ -1809,6 +2742,23 @@ var CanvasEditor = class {
1809
2742
  toDataURL(format = "png", multiplier = 1) {
1810
2743
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1811
2744
  }
2745
+ /**
2746
+ * Export the print file: the design cropped to the mockup's print area, on
2747
+ * transparency. Without a print area this is the whole canvas, still
2748
+ * transparent — a print file never carries the design background.
2749
+ */
2750
+ async toPrintFile(options = {}) {
2751
+ await this.fonts.ready();
2752
+ try {
2753
+ const area = this.mockup?.printArea;
2754
+ const blob = area ? await exportPrintArea(this.canvas, area, options) : await exportIsolatedPNG(this.canvas, this.canvas.getObjects(), options);
2755
+ this.licensing.track(`export:print:${options.format ?? "png"}`);
2756
+ return blob;
2757
+ } catch (error) {
2758
+ this.events.emit("error", { message: "Failed to export print file", error });
2759
+ throw error;
2760
+ }
2761
+ }
1812
2762
  /** Export the current product-preview composite. Advanced warping is host-defined. */
1813
2763
  async toMockupImage(options = {}) {
1814
2764
  if (!this.mockup) throw new Error("No mockup is configured");
@@ -1971,13 +2921,20 @@ var CanvasEditor = class {
1971
2921
  getDesignBackgroundImage() {
1972
2922
  return this.designBackgroundImage;
1973
2923
  }
2924
+ getBackgroundImageOptions() {
2925
+ return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
2926
+ }
1974
2927
  async setBackgroundImage(url, options = {}) {
1975
2928
  if (url === null) {
1976
2929
  this.setBackgroundImageObject(null);
1977
2930
  return;
1978
2931
  }
1979
2932
  try {
1980
- const image = await FabricImage2.fromURL(url, {}, { originX: "left", originY: "top" });
2933
+ const image = await FabricImage4.fromURL(
2934
+ url,
2935
+ { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
2936
+ { originX: "left", originY: "top" }
2937
+ );
1981
2938
  const width = image.width || 1;
1982
2939
  const height = image.height || 1;
1983
2940
  const canvasWidth = this.canvas.getWidth();
@@ -1996,15 +2953,18 @@ var CanvasEditor = class {
1996
2953
  selectable: false,
1997
2954
  evented: false
1998
2955
  });
1999
- this.setBackgroundImageObject(image);
2956
+ const serializableOptions = { ...options };
2957
+ delete serializableOptions.signal;
2958
+ this.setBackgroundImageObject(image, true, serializableOptions);
2000
2959
  } catch (error) {
2001
2960
  this.events.emit("error", { message: "Failed to set background image", error });
2002
2961
  throw error;
2003
2962
  }
2004
2963
  }
2005
2964
  /** Used by state restoration and advanced integrations with an existing Fabric object. */
2006
- setBackgroundImageObject(image, save = true) {
2965
+ setBackgroundImageObject(image, save = true, options = null) {
2007
2966
  this.designBackgroundImage = image;
2967
+ this.backgroundImageOptions = image ? options : null;
2008
2968
  this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
2009
2969
  this.canvas.requestRenderAll();
2010
2970
  if (save) this.history.save();
@@ -2145,14 +3105,89 @@ var CanvasEditor = class {
2145
3105
  clearPattern(layerId) {
2146
3106
  return this.patterns.disable(layerId);
2147
3107
  }
3108
+ // ─── Text curve ─────────────────────────────────────
3109
+ applyTextCurve(layerId, config) {
3110
+ return this.curves.apply(layerId, config);
3111
+ }
3112
+ clearTextCurve(layerId) {
3113
+ return this.curves.clear(layerId);
3114
+ }
3115
+ getTextCurve(layerId) {
3116
+ return this.curves.get(layerId);
3117
+ }
3118
+ // ─── Mask presets ───────────────────────────────────
3119
+ applyMaskPreset(layerId, id) {
3120
+ return this.maskPresets.apply(layerId, id);
3121
+ }
3122
+ clearMaskPreset(layerId) {
3123
+ return this.maskPresets.clear(layerId);
3124
+ }
3125
+ getMaskPreset(layerId) {
3126
+ return this.maskPresets.get(layerId);
3127
+ }
3128
+ // ─── Layer transform ────────────────────────────────
3129
+ /**
3130
+ * Constrain a layer to its current proportions. Persisted on the layer so a
3131
+ * reopened design still resizes the way it was set up to.
3132
+ */
3133
+ setLayerAspectLock(layerId, locked) {
3134
+ const layer = this.layers.get(layerId);
3135
+ if (!layer) return false;
3136
+ applyAspectLock(layer.fabricObject, locked);
3137
+ if (locked) layer.meta.lockAspect = true;
3138
+ else delete layer.meta.lockAspect;
3139
+ this.canvas.requestRenderAll();
3140
+ this.events.emit("layer:modified", { layerId });
3141
+ this.history.save();
3142
+ return true;
3143
+ }
3144
+ getLayerAspectLock(layerId) {
3145
+ return this.layers.get(layerId)?.meta.lockAspect === true;
3146
+ }
3147
+ /** Re-apply every stored aspect lock — control visibility is not serialized. */
3148
+ restoreAspectLocks() {
3149
+ for (const layer of this.layers.getAll()) {
3150
+ if (layer.meta.lockAspect) applyAspectLock(layer.fabricObject, true);
3151
+ }
3152
+ }
3153
+ /** Drop scale, rotation, skew and flips; the layer stays where it is. */
3154
+ resetLayerTransform(layerId) {
3155
+ const layer = this.layers.get(layerId);
3156
+ if (!layer) return false;
3157
+ resetTransform(layer.fabricObject);
3158
+ this.canvas.requestRenderAll();
3159
+ this.events.emit("layer:modified", { layerId });
3160
+ this.history.save();
3161
+ return true;
3162
+ }
3163
+ // ─── Shadow ─────────────────────────────────────────
3164
+ setLayerShadow(layerId, config) {
3165
+ const layer = this.layers.get(layerId);
3166
+ if (!layer) return false;
3167
+ applyLayerShadow(layer.fabricObject, config);
3168
+ layer.fabricObject.dirty = true;
3169
+ this.canvas.requestRenderAll();
3170
+ this.events.emit("layer:modified", { layerId });
3171
+ this.history.save();
3172
+ return true;
3173
+ }
3174
+ getLayerShadow(layerId) {
3175
+ const layer = this.layers.get(layerId);
3176
+ return layer ? readLayerShadow(layer.fabricObject) : null;
3177
+ }
2148
3178
  // ─── Mockup (preview-only) ──────────────────────────
2149
- setMockup(mockup) {
3179
+ /**
3180
+ * Show (or clear) the product preview. Pass `history: false` for preview-only
3181
+ * changes such as swapping a colourway — those are not design edits and
3182
+ * should not fill the undo stack.
3183
+ */
3184
+ setMockup(mockup, options = {}) {
2150
3185
  this.mockup = mockup;
2151
3186
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2152
3187
  this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
2153
3188
  this.canvas.requestRenderAll();
2154
3189
  this.events.emit("mockup:changed", { mockup });
2155
- this.history.save();
3190
+ if (options.history !== false) this.history.save();
2156
3191
  }
2157
3192
  clearMockup() {
2158
3193
  this.setMockup(null);
@@ -2162,6 +3197,7 @@ var CanvasEditor = class {
2162
3197
  }
2163
3198
  // ─── Cleanup ────────────────────────────────────────
2164
3199
  dispose() {
3200
+ this.masks.dispose();
2165
3201
  this.snapping.dispose();
2166
3202
  this.crop.dispose();
2167
3203
  this.history.dispose();
@@ -2216,42 +3252,107 @@ var CANVAS_SIZE_PRESETS = [
2216
3252
  { id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
2217
3253
  { id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
2218
3254
  ];
3255
+
3256
+ // src/annotations.ts
3257
+ var AnnotationOverlay = class {
3258
+ items = /* @__PURE__ */ new Map();
3259
+ transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
3260
+ set(annotation) {
3261
+ this.items.set(annotation.id, structuredClone(annotation));
3262
+ }
3263
+ remove(id) {
3264
+ return this.items.delete(id);
3265
+ }
3266
+ clear() {
3267
+ this.items.clear();
3268
+ }
3269
+ getAll() {
3270
+ return [...this.items.values()].map((item) => structuredClone(item));
3271
+ }
3272
+ setTransform(transform) {
3273
+ if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
3274
+ throw new Error("Annotation zoom must be positive");
3275
+ }
3276
+ this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
3277
+ }
3278
+ documentToViewport(point) {
3279
+ return {
3280
+ x: point.x * this.transform.zoom + this.transform.panX,
3281
+ y: point.y * this.transform.zoom + this.transform.panY
3282
+ };
3283
+ }
3284
+ viewportToDocument(point) {
3285
+ return {
3286
+ x: (point.x - this.transform.panX) / this.transform.zoom,
3287
+ y: (point.y - this.transform.panY) / this.transform.zoom
3288
+ };
3289
+ }
3290
+ documentToDevice(point) {
3291
+ const viewport = this.documentToViewport(point);
3292
+ const ratio = this.transform.devicePixelRatio ?? 1;
3293
+ return { x: viewport.x * ratio, y: viewport.y * ratio };
3294
+ }
3295
+ };
2219
3296
  export {
3297
+ AnnotationOverlay,
2220
3298
  CANVAS_SIZE_PRESETS,
2221
3299
  CanvasEditor,
2222
3300
  CropController,
3301
+ DEFAULT_LAYER_SHADOW,
2223
3302
  DEFAULT_PATTERN_CONFIG,
3303
+ DEFAULT_TEXT_CURVE,
2224
3304
  EventEmitter,
2225
3305
  FontRegistry,
2226
3306
  HistoryManager,
2227
3307
  Layer,
2228
3308
  LayerManager,
2229
3309
  LicenseManager,
3310
+ MaskController,
3311
+ MaskPresetManager,
3312
+ MaskRefinementError,
2230
3313
  PatternManager,
2231
3314
  ProjectManager,
3315
+ SHAPE_MASK_BOX,
3316
+ SHAPE_MASK_IDS,
2232
3317
  SnapManager,
3318
+ TEXTURE_MASK_IDS,
3319
+ TEXTURE_MASK_SIZE,
3320
+ TextCurveManager,
2233
3321
  UnitConverter,
3322
+ applyAspectLock,
3323
+ applyLayerShadow,
2234
3324
  applyPatternLocks,
3325
+ buildCurvePathData,
2235
3326
  buildPatternDataURL,
2236
3327
  captureLocks,
2237
3328
  clamp,
2238
3329
  clearPatternImageCache,
3330
+ clearTextureMaskCache,
2239
3331
  computeCoverPlacement,
2240
3332
  computePrintAreaClip,
2241
3333
  computeTilePositions,
2242
3334
  deserializeEditor,
3335
+ displaceRgba,
2243
3336
  drawTiles,
2244
3337
  escapeXml,
2245
3338
  exportDataURL,
2246
3339
  exportMockup,
2247
3340
  exportPNG,
3341
+ exportPrintArea,
2248
3342
  exportSVG,
2249
3343
  generateId,
2250
3344
  isCssColor,
3345
+ isMaskPresetId,
3346
+ isShapeMaskId,
3347
+ isTextureMaskId,
2251
3348
  loadPatternImage,
3349
+ readLayerShadow,
3350
+ renderTextureMask,
3351
+ resetTransform,
2252
3352
  restoreLocks,
2253
3353
  round2,
2254
3354
  sanitizeSvg,
2255
- serializeEditor
3355
+ serializeEditor,
3356
+ shapeMaskPathData
2256
3357
  };
2257
3358
  //# sourceMappingURL=index.mjs.map