@overtone-art/canvas-editor-core 0.2.8 → 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
@@ -24,7 +24,9 @@ __export(index_exports, {
24
24
  CANVAS_SIZE_PRESETS: () => CANVAS_SIZE_PRESETS,
25
25
  CanvasEditor: () => CanvasEditor,
26
26
  CropController: () => CropController,
27
+ DEFAULT_LAYER_SHADOW: () => DEFAULT_LAYER_SHADOW,
27
28
  DEFAULT_PATTERN_CONFIG: () => DEFAULT_PATTERN_CONFIG,
29
+ DEFAULT_TEXT_CURVE: () => DEFAULT_TEXT_CURVE,
28
30
  EventEmitter: () => EventEmitter,
29
31
  FontRegistry: () => FontRegistry,
30
32
  HistoryManager: () => HistoryManager,
@@ -32,16 +34,26 @@ __export(index_exports, {
32
34
  LayerManager: () => LayerManager,
33
35
  LicenseManager: () => LicenseManager,
34
36
  MaskController: () => MaskController,
37
+ MaskPresetManager: () => MaskPresetManager,
35
38
  MaskRefinementError: () => MaskRefinementError,
36
39
  PatternManager: () => PatternManager,
37
40
  ProjectManager: () => ProjectManager,
41
+ SHAPE_MASK_BOX: () => SHAPE_MASK_BOX,
42
+ SHAPE_MASK_IDS: () => SHAPE_MASK_IDS,
38
43
  SnapManager: () => SnapManager,
44
+ TEXTURE_MASK_IDS: () => TEXTURE_MASK_IDS,
45
+ TEXTURE_MASK_SIZE: () => TEXTURE_MASK_SIZE,
46
+ TextCurveManager: () => TextCurveManager,
39
47
  UnitConverter: () => UnitConverter,
48
+ applyAspectLock: () => applyAspectLock,
49
+ applyLayerShadow: () => applyLayerShadow,
40
50
  applyPatternLocks: () => applyPatternLocks,
51
+ buildCurvePathData: () => buildCurvePathData,
41
52
  buildPatternDataURL: () => buildPatternDataURL,
42
53
  captureLocks: () => captureLocks,
43
54
  clamp: () => clamp,
44
55
  clearPatternImageCache: () => clearPatternImageCache,
56
+ clearTextureMaskCache: () => clearTextureMaskCache,
45
57
  computeCoverPlacement: () => computeCoverPlacement,
46
58
  computePrintAreaClip: () => computePrintAreaClip,
47
59
  computeTilePositions: () => computeTilePositions,
@@ -52,19 +64,27 @@ __export(index_exports, {
52
64
  exportDataURL: () => exportDataURL,
53
65
  exportMockup: () => exportMockup,
54
66
  exportPNG: () => exportPNG,
67
+ exportPrintArea: () => exportPrintArea,
55
68
  exportSVG: () => exportSVG,
56
69
  generateId: () => generateId,
57
70
  isCssColor: () => isCssColor,
71
+ isMaskPresetId: () => isMaskPresetId,
72
+ isShapeMaskId: () => isShapeMaskId,
73
+ isTextureMaskId: () => isTextureMaskId,
58
74
  loadPatternImage: () => loadPatternImage,
75
+ readLayerShadow: () => readLayerShadow,
76
+ renderTextureMask: () => renderTextureMask,
77
+ resetTransform: () => resetTransform,
59
78
  restoreLocks: () => restoreLocks,
60
79
  round2: () => round2,
61
80
  sanitizeSvg: () => sanitizeSvg,
62
- serializeEditor: () => serializeEditor
81
+ serializeEditor: () => serializeEditor,
82
+ shapeMaskPathData: () => shapeMaskPathData
63
83
  });
64
84
  module.exports = __toCommonJS(index_exports);
65
85
 
66
86
  // src/editor.ts
67
- var import_fabric6 = require("fabric");
87
+ var import_fabric9 = require("fabric");
68
88
 
69
89
  // src/events.ts
70
90
  var EventEmitter = class {
@@ -1142,6 +1162,521 @@ function mod2(n) {
1142
1162
  return (n % 2 + 2) % 2;
1143
1163
  }
1144
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
+
1145
1680
  // src/utils/units.ts
1146
1681
  var MM_PER_INCH = 25.4;
1147
1682
  var UnitConverter = class {
@@ -1190,18 +1725,7 @@ var UnitConverter = class {
1190
1725
  };
1191
1726
 
1192
1727
  // src/serialization.ts
1193
- var import_fabric3 = require("fabric");
1194
-
1195
- // src/utils/color.ts
1196
- var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1197
- function isCssColor(value, allowEmpty = false) {
1198
- if (typeof value !== "string") return false;
1199
- const trimmed = value.trim();
1200
- if (!trimmed) return allowEmpty;
1201
- return CSS_COLOR.test(trimmed);
1202
- }
1203
-
1204
- // src/serialization.ts
1728
+ var import_fabric6 = require("fabric");
1205
1729
  var VERSION = "2.0.0";
1206
1730
  function serializeEditor(editor) {
1207
1731
  return {
@@ -1237,7 +1761,7 @@ async function deserializeEditor(editor, state) {
1237
1761
  }
1238
1762
  const staged = await Promise.all(
1239
1763
  state.layers.map(async (serialized) => {
1240
- const fabricObject = (await import_fabric3.util.enlivenObjects([serialized.fabricObject]))[0];
1764
+ const fabricObject = (await import_fabric6.util.enlivenObjects([serialized.fabricObject]))[0];
1241
1765
  if (!fabricObject) {
1242
1766
  const source = serialized.fabricObject.src;
1243
1767
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -1248,7 +1772,7 @@ async function deserializeEditor(editor, state) {
1248
1772
  return { serialized, fabricObject };
1249
1773
  })
1250
1774
  );
1251
- 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;
1252
1776
  if (state.backgroundImage && !stagedBackground) {
1253
1777
  const source = state.backgroundImage.src;
1254
1778
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -1280,6 +1804,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1280
1804
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
1281
1805
  if (serialized.meta) {
1282
1806
  layer.meta = serialized.meta;
1807
+ if (layer.meta.lockAspect) applyAspectLock(fabricObject, true);
1283
1808
  }
1284
1809
  if (!serialized.visible) {
1285
1810
  editor.layers.setVisibility(layer.id, false);
@@ -1294,7 +1819,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1294
1819
  }
1295
1820
 
1296
1821
  // src/export.ts
1297
- var import_fabric4 = require("fabric");
1822
+ var import_fabric7 = require("fabric");
1298
1823
 
1299
1824
  // src/displacement.ts
1300
1825
  var CHANNEL_INDEX = {
@@ -1388,7 +1913,7 @@ async function exportPNG(canvas, options = {}) {
1388
1913
  }
1389
1914
  async function exportIsolatedPNG(source, objects, options = {}) {
1390
1915
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
1391
- const canvas = new import_fabric4.StaticCanvas(element, {
1916
+ const canvas = new import_fabric7.StaticCanvas(element, {
1392
1917
  width: options.width ?? source.getWidth(),
1393
1918
  height: options.height ?? source.getHeight(),
1394
1919
  backgroundColor: options.backgroundColor || void 0
@@ -1403,6 +1928,38 @@ async function exportIsolatedPNG(source, objects, options = {}) {
1403
1928
  canvas.dispose();
1404
1929
  }
1405
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
+ }
1406
1963
  async function exportMockup(canvas, mockup, options = {}) {
1407
1964
  const { multiplier = 1, format = "png", quality = 1 } = options;
1408
1965
  const design = canvas.toCanvasElement(multiplier);
@@ -1826,7 +2383,7 @@ var ProjectManager = class {
1826
2383
  };
1827
2384
 
1828
2385
  // src/mask.ts
1829
- var import_fabric5 = require("fabric");
2386
+ var import_fabric8 = require("fabric");
1830
2387
  var MaskRefinementError = class extends Error {
1831
2388
  constructor(code, message, cause) {
1832
2389
  super(message);
@@ -1858,7 +2415,7 @@ var MaskController = class {
1858
2415
  throw new Error("Mask dimensions must be positive integers");
1859
2416
  }
1860
2417
  const backing = this.makeCanvas(width, height);
1861
- const image = new import_fabric5.FabricImage(backing, {
2418
+ const image = new import_fabric8.FabricImage(backing, {
1862
2419
  left: 0,
1863
2420
  top: 0,
1864
2421
  originX: "left",
@@ -2098,6 +2655,8 @@ var CanvasEditor = class {
2098
2655
  snapping;
2099
2656
  crop;
2100
2657
  patterns;
2658
+ curves;
2659
+ maskPresets;
2101
2660
  fonts;
2102
2661
  licensing;
2103
2662
  pages;
@@ -2121,7 +2680,7 @@ var CanvasEditor = class {
2121
2680
  const widthPx = this.units.toPixels(config.width);
2122
2681
  const heightPx = this.units.toPixels(config.height);
2123
2682
  this.designBackground = config.backgroundColor ?? "#ffffff";
2124
- this.canvas = new import_fabric6.Canvas(canvasElement, {
2683
+ this.canvas = new import_fabric9.Canvas(canvasElement, {
2125
2684
  width: widthPx,
2126
2685
  height: heightPx,
2127
2686
  backgroundColor: this.designBackground,
@@ -2149,6 +2708,8 @@ var CanvasEditor = class {
2149
2708
  this.events,
2150
2709
  config.patternSourceResolver
2151
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);
2152
2713
  this.setupCanvasEvents();
2153
2714
  this.history.saveImmediate();
2154
2715
  this.pages = new ProjectManager(this);
@@ -2157,7 +2718,7 @@ var CanvasEditor = class {
2157
2718
  // ─── Layer Operations ────────────────────────────────
2158
2719
  async addImage(url, options) {
2159
2720
  try {
2160
- const img = await import_fabric6.FabricImage.fromURL(
2721
+ const img = await import_fabric9.FabricImage.fromURL(
2161
2722
  url,
2162
2723
  {},
2163
2724
  { originX: "left", originY: "top", ...options }
@@ -2182,7 +2743,7 @@ var CanvasEditor = class {
2182
2743
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
2183
2744
  const previous = layer.fabricObject;
2184
2745
  try {
2185
- const replacement = await import_fabric6.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2746
+ const replacement = await import_fabric9.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
2186
2747
  replacement.set({
2187
2748
  left: previous.left,
2188
2749
  top: previous.top,
@@ -2211,7 +2772,7 @@ var CanvasEditor = class {
2211
2772
  }
2212
2773
  }
2213
2774
  addText(text, options) {
2214
- const textbox = new import_fabric6.Textbox(text, {
2775
+ const textbox = new import_fabric9.Textbox(text, {
2215
2776
  fontSize: 32,
2216
2777
  fontFamily: "Arial",
2217
2778
  fill: "#000000",
@@ -2250,10 +2811,10 @@ var CanvasEditor = class {
2250
2811
  return value === void 0 ? token : escapeXml(value);
2251
2812
  })
2252
2813
  );
2253
- const { objects, options } = await (0, import_fabric6.loadSVGFromString)(resolved);
2814
+ const { objects, options } = await (0, import_fabric9.loadSVGFromString)(resolved);
2254
2815
  const validObjects = objects.filter((object) => object !== null);
2255
2816
  if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
2256
- const group = import_fabric6.util.groupSVGElements(validObjects, options);
2817
+ const group = import_fabric9.util.groupSVGElements(validObjects, options);
2257
2818
  group.set({
2258
2819
  left: this.canvas.getWidth() / 2,
2259
2820
  top: this.canvas.getHeight() / 2,
@@ -2326,10 +2887,10 @@ var CanvasEditor = class {
2326
2887
  const next = { ...previous, ...adjustments };
2327
2888
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
2328
2889
  image.filters = [
2329
- new import_fabric6.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
2330
- new import_fabric6.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
2331
- new import_fabric6.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
2332
- new import_fabric6.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) })
2333
2894
  ];
2334
2895
  layer.meta.imageAdjustments = next;
2335
2896
  image.applyFilters();
@@ -2346,7 +2907,7 @@ var CanvasEditor = class {
2346
2907
  const childData = children.map((layer) => structuredClone(layer.toData()));
2347
2908
  const objects = children.map((layer) => layer.fabricObject);
2348
2909
  for (const layer of children) this.layers.remove(layer.id);
2349
- const group = new import_fabric6.Group(objects);
2910
+ const group = new import_fabric9.Group(objects);
2350
2911
  const grouped = this.layers.add("group", group, name);
2351
2912
  grouped.meta.groupChildren = childData;
2352
2913
  this.layers.select(grouped.id);
@@ -2365,7 +2926,7 @@ var CanvasEditor = class {
2365
2926
  const objects = group.removeAll();
2366
2927
  this.layers.remove(id);
2367
2928
  const restored = objects.map((object, index) => {
2368
- import_fabric6.util.addTransformToObject(object, transform);
2929
+ import_fabric9.util.addTransformToObject(object, transform);
2369
2930
  object.setCoords();
2370
2931
  const data = childData[index];
2371
2932
  const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
@@ -2421,7 +2982,7 @@ var CanvasEditor = class {
2421
2982
  const layer = this.layers.get(id);
2422
2983
  if (!layer) throw new Error(`Layer not found: ${id}`);
2423
2984
  try {
2424
- if (options.resolution === "source" && layer.fabricObject instanceof import_fabric6.FabricImage) {
2985
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric9.FabricImage) {
2425
2986
  const image = await layer.fabricObject.clone();
2426
2987
  image.set({
2427
2988
  left: 0,
@@ -2505,6 +3066,23 @@ var CanvasEditor = class {
2505
3066
  toDataURL(format = "png", multiplier = 1) {
2506
3067
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
2507
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
+ }
2508
3086
  /** Export the current product-preview composite. Advanced warping is host-defined. */
2509
3087
  async toMockupImage(options = {}) {
2510
3088
  if (!this.mockup) throw new Error("No mockup is configured");
@@ -2676,7 +3254,7 @@ var CanvasEditor = class {
2676
3254
  return;
2677
3255
  }
2678
3256
  try {
2679
- const image = await import_fabric6.FabricImage.fromURL(
3257
+ const image = await import_fabric9.FabricImage.fromURL(
2680
3258
  url,
2681
3259
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
2682
3260
  { originX: "left", originY: "top" }
@@ -2851,14 +3429,89 @@ var CanvasEditor = class {
2851
3429
  clearPattern(layerId) {
2852
3430
  return this.patterns.disable(layerId);
2853
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
+ }
2854
3502
  // ─── Mockup (preview-only) ──────────────────────────
2855
- 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 = {}) {
2856
3509
  this.mockup = mockup;
2857
3510
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2858
3511
  this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
2859
3512
  this.canvas.requestRenderAll();
2860
3513
  this.events.emit("mockup:changed", { mockup });
2861
- this.history.save();
3514
+ if (options.history !== false) this.history.save();
2862
3515
  }
2863
3516
  clearMockup() {
2864
3517
  this.setMockup(null);
@@ -2970,7 +3623,9 @@ var AnnotationOverlay = class {
2970
3623
  CANVAS_SIZE_PRESETS,
2971
3624
  CanvasEditor,
2972
3625
  CropController,
3626
+ DEFAULT_LAYER_SHADOW,
2973
3627
  DEFAULT_PATTERN_CONFIG,
3628
+ DEFAULT_TEXT_CURVE,
2974
3629
  EventEmitter,
2975
3630
  FontRegistry,
2976
3631
  HistoryManager,
@@ -2978,16 +3633,26 @@ var AnnotationOverlay = class {
2978
3633
  LayerManager,
2979
3634
  LicenseManager,
2980
3635
  MaskController,
3636
+ MaskPresetManager,
2981
3637
  MaskRefinementError,
2982
3638
  PatternManager,
2983
3639
  ProjectManager,
3640
+ SHAPE_MASK_BOX,
3641
+ SHAPE_MASK_IDS,
2984
3642
  SnapManager,
3643
+ TEXTURE_MASK_IDS,
3644
+ TEXTURE_MASK_SIZE,
3645
+ TextCurveManager,
2985
3646
  UnitConverter,
3647
+ applyAspectLock,
3648
+ applyLayerShadow,
2986
3649
  applyPatternLocks,
3650
+ buildCurvePathData,
2987
3651
  buildPatternDataURL,
2988
3652
  captureLocks,
2989
3653
  clamp,
2990
3654
  clearPatternImageCache,
3655
+ clearTextureMaskCache,
2991
3656
  computeCoverPlacement,
2992
3657
  computePrintAreaClip,
2993
3658
  computeTilePositions,
@@ -2998,13 +3663,21 @@ var AnnotationOverlay = class {
2998
3663
  exportDataURL,
2999
3664
  exportMockup,
3000
3665
  exportPNG,
3666
+ exportPrintArea,
3001
3667
  exportSVG,
3002
3668
  generateId,
3003
3669
  isCssColor,
3670
+ isMaskPresetId,
3671
+ isShapeMaskId,
3672
+ isTextureMaskId,
3004
3673
  loadPatternImage,
3674
+ readLayerShadow,
3675
+ renderTextureMask,
3676
+ resetTransform,
3005
3677
  restoreLocks,
3006
3678
  round2,
3007
3679
  sanitizeSvg,
3008
- serializeEditor
3680
+ serializeEditor,
3681
+ shapeMaskPathData
3009
3682
  });
3010
3683
  //# sourceMappingURL=index.js.map