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

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
@@ -6,11 +6,12 @@ import {
6
6
  exportIsolatedPNG,
7
7
  exportMockup,
8
8
  exportPNG,
9
+ exportPrintArea,
9
10
  exportSVG
10
- } from "./chunk-ORZZ6MGQ.mjs";
11
+ } from "./chunk-MCBRZQ4M.mjs";
11
12
 
12
13
  // src/editor.ts
13
- import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
14
+ import { Canvas, FabricImage as FabricImage4, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
14
15
 
15
16
  // src/events.ts
16
17
  var EventEmitter = class {
@@ -230,6 +231,38 @@ var LayerManager = class {
230
231
  this.emitChanged();
231
232
  this.onPropertyChanged?.();
232
233
  }
234
+ /**
235
+ * Patch a layer's host metadata. A key whose value is `undefined` is removed.
236
+ *
237
+ * `meta` is part of `LayerData` and is serialized, so a host that assigns
238
+ * `layer.meta.x` directly gets neither of the two things every other setter
239
+ * here provides: `layers:changed` (so `useLayers()` keeps returning the old
240
+ * meta) and the history checkpoint (so an undo silently reverts the write —
241
+ * the state IS versioned, it was just never committed at the moment it
242
+ * changed). Going through this method is what makes metadata behave like
243
+ * every other layer property.
244
+ */
245
+ setMeta(id, patch) {
246
+ const layer = this.get(id);
247
+ if (!layer) return;
248
+ const next = { ...layer.meta };
249
+ let changed = false;
250
+ for (const [key, value] of Object.entries(patch)) {
251
+ if (value === void 0) {
252
+ if (key in next) {
253
+ delete next[key];
254
+ changed = true;
255
+ }
256
+ continue;
257
+ }
258
+ if (next[key] !== value) changed = true;
259
+ next[key] = value;
260
+ }
261
+ if (!changed) return;
262
+ layer.meta = next;
263
+ this.emitChanged();
264
+ this.onPropertyChanged?.();
265
+ }
233
266
  clear() {
234
267
  for (const layer of this.layers) {
235
268
  this.canvas.remove(layer.fabricObject);
@@ -1088,6 +1121,521 @@ function mod2(n) {
1088
1121
  return (n % 2 + 2) % 2;
1089
1122
  }
1090
1123
 
1124
+ // src/text-curve.ts
1125
+ import { Path } from "fabric";
1126
+ var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1127
+ var MIN_ARC = 0.5;
1128
+ var FULL_CIRCLE_ARC = 99.5;
1129
+ var MIN_SWEEP = 0.12;
1130
+ var WAVE_PERIOD_EM = 4.1;
1131
+ var WAVE_AMPLITUDE_EM = 0.9;
1132
+ var WAVE_STEP = 6;
1133
+ var MEASURE_WIDTH = 1e5;
1134
+ var PATH_SLACK = 0.06;
1135
+ function isCurvable(object) {
1136
+ return !!object && typeof object.text === "string";
1137
+ }
1138
+ function measureText(text) {
1139
+ const authored = text.width;
1140
+ try {
1141
+ text.set({ width: MEASURE_WIDTH });
1142
+ text.initDimensions?.();
1143
+ return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1144
+ } finally {
1145
+ if (authored !== void 0) text.set({ width: authored });
1146
+ text.initDimensions?.();
1147
+ }
1148
+ }
1149
+ function arcPathData(width, arc) {
1150
+ const magnitude = Math.min(100, Math.abs(arc));
1151
+ const direction = arc < 0 ? -1 : 1;
1152
+ const full = magnitude >= FULL_CIRCLE_ARC;
1153
+ const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1154
+ const radius = width / sweep;
1155
+ const centerX = width / 2;
1156
+ const point = (angle) => [
1157
+ centerX + radius * Math.sin(angle),
1158
+ direction * radius * (1 - Math.cos(angle))
1159
+ ];
1160
+ const sweepFlag = direction > 0 ? 1 : 0;
1161
+ const format = ([x, y]) => `${round(x)} ${round(y)}`;
1162
+ if (full) {
1163
+ const start2 = point(-Math.PI);
1164
+ const top = point(0);
1165
+ return {
1166
+ data: [
1167
+ `M ${format(start2)}`,
1168
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
1169
+ `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
1170
+ ].join(" "),
1171
+ length: width
1172
+ };
1173
+ }
1174
+ const start = point(-sweep / 2);
1175
+ const end = point(sweep / 2);
1176
+ const largeArc = sweep > Math.PI ? 1 : 0;
1177
+ return {
1178
+ data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1179
+ // radius = width / sweep, so the arc is exactly `width` long.
1180
+ length: width
1181
+ };
1182
+ }
1183
+ function wavePathData(width, fontSize, wave) {
1184
+ const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
1185
+ const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
1186
+ const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
1187
+ const commands = [];
1188
+ let length = 0;
1189
+ let previous = null;
1190
+ for (let index = 0; index <= steps; index++) {
1191
+ const x = width * index / steps;
1192
+ const y = amplitude * Math.sin(x / period * Math.PI * 2);
1193
+ if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
1194
+ previous = [x, y];
1195
+ commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
1196
+ }
1197
+ return { data: commands.join(" "), length };
1198
+ }
1199
+ function round(value) {
1200
+ return Math.round(value * 100) / 100;
1201
+ }
1202
+ function buildCurvePathData(config, width, fontSize) {
1203
+ if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
1204
+ if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
1205
+ return null;
1206
+ }
1207
+ function normalize(config) {
1208
+ const arc = clamp(config.arc ?? 0, -100, 100);
1209
+ return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
1210
+ }
1211
+ var TextCurveManager = class {
1212
+ constructor(canvas, layers, history, events) {
1213
+ this.canvas = canvas;
1214
+ this.layers = layers;
1215
+ this.history = history;
1216
+ this.events = events;
1217
+ }
1218
+ canvas;
1219
+ layers;
1220
+ history;
1221
+ events;
1222
+ /** Curve parameters for a layer, or null when it is not curved text. */
1223
+ get(layerId) {
1224
+ const layer = this.layers.get(layerId);
1225
+ if (!layer || !isCurvable(layer.fabricObject)) return null;
1226
+ return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
1227
+ }
1228
+ isCurved(layerId) {
1229
+ const curve = this.get(layerId);
1230
+ return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
1231
+ }
1232
+ /** Apply (or update) the curve on a text layer. Zeroed config clears it. */
1233
+ apply(layerId, config, save = true) {
1234
+ const layer = this.layers.get(layerId);
1235
+ if (!layer || !isCurvable(layer.fabricObject)) return false;
1236
+ const next = normalize(config);
1237
+ const text = layer.fabricObject;
1238
+ const run = measureText(text);
1239
+ const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
1240
+ if (!curve) {
1241
+ this.detach(text, layer.meta.curveWidth);
1242
+ delete layer.meta.curve;
1243
+ delete layer.meta.curveWidth;
1244
+ } else {
1245
+ if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1246
+ text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1247
+ text.set({
1248
+ path: new Path(curve.data, { visible: false, objectCaching: false }),
1249
+ pathAlign: "center",
1250
+ pathSide: "left",
1251
+ pathStartOffset: Math.max(0, (curve.length - run) / 2)
1252
+ });
1253
+ layer.meta.curve = next;
1254
+ }
1255
+ text.initDimensions?.();
1256
+ text.setCoords();
1257
+ text.dirty = true;
1258
+ this.canvas.requestRenderAll();
1259
+ this.events.emit("layer:modified", { layerId });
1260
+ if (save) this.history.save();
1261
+ return true;
1262
+ }
1263
+ /** Remove the curve, restoring the authored text box width. */
1264
+ clear(layerId, save = true) {
1265
+ return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
1266
+ }
1267
+ /**
1268
+ * Rebuild the path from the stored parameters. Text content, font family and
1269
+ * font size all change the run's width, and the path is sized to that width —
1270
+ * without this the curve keeps the geometry of the text it was created from.
1271
+ */
1272
+ refresh(layerId, save = false) {
1273
+ const curve = this.layers.get(layerId)?.meta.curve;
1274
+ if (!curve) return false;
1275
+ return this.apply(layerId, curve, save);
1276
+ }
1277
+ /** Rebuild every curved layer — used after a state restore. */
1278
+ refreshAll() {
1279
+ for (const layer of this.layers.getAll()) {
1280
+ if (layer.meta.curve) this.refresh(layer.id);
1281
+ }
1282
+ }
1283
+ detach(text, authoredWidth) {
1284
+ text.set({ path: null, pathStartOffset: 0 });
1285
+ if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1286
+ }
1287
+ };
1288
+
1289
+ // src/mask-presets/manager.ts
1290
+ import { FabricImage as FabricImage2, Path as Path2 } from "fabric";
1291
+
1292
+ // src/mask-presets/shapes.ts
1293
+ var SHAPE_MASK_IDS = [
1294
+ "circle",
1295
+ "square",
1296
+ "triangle",
1297
+ "star",
1298
+ "heart",
1299
+ "octagram",
1300
+ "arch",
1301
+ "zigzag"
1302
+ ];
1303
+ function isShapeMaskId(value) {
1304
+ return typeof value === "string" && SHAPE_MASK_IDS.includes(value);
1305
+ }
1306
+ function starPoints(points, outer, inner, start = -Math.PI / 2) {
1307
+ const step = Math.PI / points;
1308
+ const coordinates = [];
1309
+ for (let index = 0; index < points * 2; index++) {
1310
+ const radius = index % 2 === 0 ? outer : inner;
1311
+ const angle = start + index * step;
1312
+ const x = 50 + Math.cos(angle) * radius;
1313
+ const y = 50 + Math.sin(angle) * radius;
1314
+ coordinates.push(`${index === 0 ? "M" : "L"} ${round3(x)} ${round3(y)}`);
1315
+ }
1316
+ return `${coordinates.join(" ")} Z`;
1317
+ }
1318
+ function round3(value) {
1319
+ return Math.round(value * 100) / 100;
1320
+ }
1321
+ var SHAPE_PATHS = {
1322
+ circle: "M 96 50 A 46 46 0 1 1 4 50 A 46 46 0 1 1 96 50 Z",
1323
+ square: "M 4 4 H 96 V 96 H 4 Z",
1324
+ triangle: "M 50 4 L 96 96 L 4 96 Z",
1325
+ star: starPoints(5, 46, 22),
1326
+ heart: "M 50 96 C -8 55 12 4 50 28 C 88 4 108 55 50 96 Z",
1327
+ octagram: starPoints(8, 46, 27),
1328
+ arch: "M 4 96 V 50 A 46 46 0 0 1 96 50 V 96 Z",
1329
+ zigzag: starPoints(16, 46, 39)
1330
+ };
1331
+ function shapeMaskPathData(id) {
1332
+ return SHAPE_PATHS[id];
1333
+ }
1334
+ var SHAPE_MASK_BOX = 100;
1335
+
1336
+ // src/mask-presets/textures.ts
1337
+ var TEXTURE_MASK_IDS = [
1338
+ "vignette",
1339
+ "halftone",
1340
+ "spray",
1341
+ "grunge",
1342
+ "torn",
1343
+ "band"
1344
+ ];
1345
+ function isTextureMaskId(value) {
1346
+ return typeof value === "string" && TEXTURE_MASK_IDS.includes(value);
1347
+ }
1348
+ var TEXTURE_MASK_SIZE = 320;
1349
+ var cache = /* @__PURE__ */ new Map();
1350
+ function seeded(seed) {
1351
+ let state = seed;
1352
+ return () => {
1353
+ state = state * 16807 % 2147483647;
1354
+ return (state - 1) / 2147483646;
1355
+ };
1356
+ }
1357
+ function traceRoughEdge(context, random, jitter, inset) {
1358
+ const size = TEXTURE_MASK_SIZE;
1359
+ const corners = [
1360
+ [inset, inset],
1361
+ [size - inset, inset],
1362
+ [size - inset, size - inset],
1363
+ [inset, size - inset]
1364
+ ];
1365
+ context.beginPath();
1366
+ for (let corner = 0; corner < corners.length; corner++) {
1367
+ const [fromX, fromY] = corners[corner];
1368
+ const [toX, toY] = corners[(corner + 1) % corners.length];
1369
+ for (let step = 0; step <= 26; step++) {
1370
+ const ratio = step / 26;
1371
+ const x = fromX + (toX - fromX) * ratio + (random() - 0.5) * jitter;
1372
+ const y = fromY + (toY - fromY) * ratio + (random() - 0.5) * jitter;
1373
+ if (corner === 0 && step === 0) context.moveTo(x, y);
1374
+ else context.lineTo(x, y);
1375
+ }
1376
+ }
1377
+ context.closePath();
1378
+ context.fill();
1379
+ }
1380
+ function paint(id, context) {
1381
+ const size = TEXTURE_MASK_SIZE;
1382
+ const center = size / 2;
1383
+ context.fillStyle = "#ffffff";
1384
+ switch (id) {
1385
+ case "vignette": {
1386
+ const gradient = context.createRadialGradient(
1387
+ center,
1388
+ center,
1389
+ size * 0.18,
1390
+ center,
1391
+ center,
1392
+ size * 0.52
1393
+ );
1394
+ gradient.addColorStop(0, "rgba(255,255,255,1)");
1395
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1396
+ context.fillStyle = gradient;
1397
+ context.fillRect(0, 0, size, size);
1398
+ return;
1399
+ }
1400
+ case "band": {
1401
+ const gradient = context.createLinearGradient(0, 0, 0, size);
1402
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
1403
+ gradient.addColorStop(0.25, "rgba(255,255,255,1)");
1404
+ gradient.addColorStop(0.75, "rgba(255,255,255,1)");
1405
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
1406
+ context.fillStyle = gradient;
1407
+ context.fillRect(0, 0, size, size);
1408
+ return;
1409
+ }
1410
+ case "halftone": {
1411
+ const pitch = 16;
1412
+ for (let y = pitch / 2; y < size; y += pitch) {
1413
+ for (let x = pitch / 2; x < size; x += pitch) {
1414
+ const distance = Math.hypot(x - center, y - center) / (size * 0.52);
1415
+ const radius = Math.max(0, pitch / 2 * (1 - distance) * 1.15);
1416
+ if (radius < 0.4) continue;
1417
+ context.beginPath();
1418
+ context.arc(x, y, radius, 0, Math.PI * 2);
1419
+ context.fill();
1420
+ }
1421
+ }
1422
+ return;
1423
+ }
1424
+ case "spray": {
1425
+ const random = seeded(42);
1426
+ const core = context.createRadialGradient(center, center, 10, center, center, size * 0.42);
1427
+ core.addColorStop(0, "rgba(255,255,255,1)");
1428
+ core.addColorStop(1, "rgba(255,255,255,0.85)");
1429
+ context.fillStyle = core;
1430
+ context.beginPath();
1431
+ context.arc(center, center, size * 0.42, 0, Math.PI * 2);
1432
+ context.fill();
1433
+ context.fillStyle = "rgba(255,255,255,0.9)";
1434
+ for (let dot = 0; dot < 900; dot++) {
1435
+ const angle = random() * Math.PI * 2;
1436
+ const distance = size * (0.3 + random() * 0.24);
1437
+ const x = center + Math.cos(angle) * distance;
1438
+ const y = center + Math.sin(angle) * distance;
1439
+ context.globalAlpha = 1 - (distance / size - 0.3) / 0.24;
1440
+ context.beginPath();
1441
+ context.arc(x, y, random() * 2.4, 0, Math.PI * 2);
1442
+ context.fill();
1443
+ }
1444
+ context.globalAlpha = 1;
1445
+ return;
1446
+ }
1447
+ case "grunge": {
1448
+ const random = seeded(7);
1449
+ traceRoughEdge(context, random, 14, size * 0.06);
1450
+ context.globalCompositeOperation = "destination-out";
1451
+ for (let speckle = 0; speckle < 240; speckle++) {
1452
+ const x = random() * size;
1453
+ const y = random() * size;
1454
+ const nearEdge = Math.min(x, y, size - x, size - y) < size * 0.12;
1455
+ if (!nearEdge && random() >= 0.12) continue;
1456
+ context.beginPath();
1457
+ context.arc(x, y, random() * 6 + 1, 0, Math.PI * 2);
1458
+ context.fill();
1459
+ }
1460
+ context.globalCompositeOperation = "source-over";
1461
+ return;
1462
+ }
1463
+ case "torn": {
1464
+ traceRoughEdge(context, seeded(99), 10, size * 0.08);
1465
+ return;
1466
+ }
1467
+ }
1468
+ }
1469
+ function renderTextureMask(id) {
1470
+ const cached = cache.get(id);
1471
+ if (cached) return cached;
1472
+ if (typeof document === "undefined") {
1473
+ throw new Error("Texture masks require a DOM canvas");
1474
+ }
1475
+ const canvas = document.createElement("canvas");
1476
+ canvas.width = TEXTURE_MASK_SIZE;
1477
+ canvas.height = TEXTURE_MASK_SIZE;
1478
+ const context = canvas.getContext("2d");
1479
+ if (!context) throw new Error("Texture masks require a 2D canvas context");
1480
+ paint(id, context);
1481
+ cache.set(id, canvas);
1482
+ return canvas;
1483
+ }
1484
+ function clearTextureMaskCache() {
1485
+ cache.clear();
1486
+ }
1487
+
1488
+ // src/mask-presets/manager.ts
1489
+ function isMaskPresetId(value) {
1490
+ return isShapeMaskId(value) || isTextureMaskId(value);
1491
+ }
1492
+ var MaskPresetManager = class {
1493
+ constructor(canvas, layers, history, events) {
1494
+ this.canvas = canvas;
1495
+ this.layers = layers;
1496
+ this.history = history;
1497
+ this.events = events;
1498
+ }
1499
+ canvas;
1500
+ layers;
1501
+ history;
1502
+ events;
1503
+ get(layerId) {
1504
+ return this.layers.get(layerId)?.meta.maskPreset ?? null;
1505
+ }
1506
+ /** Clip the layer to `id`. Passing null (or an unknown id) clears the clip. */
1507
+ apply(layerId, id, save = true) {
1508
+ const layer = this.layers.get(layerId);
1509
+ if (!layer) return false;
1510
+ const object = layer.fabricObject;
1511
+ if (id === null) {
1512
+ if (!layer.meta.maskPreset) return false;
1513
+ object.clipPath = void 0;
1514
+ delete layer.meta.maskPreset;
1515
+ } else {
1516
+ if (!isMaskPresetId(id)) return false;
1517
+ object.clipPath = this.buildClip(id, object);
1518
+ layer.meta.maskPreset = id;
1519
+ }
1520
+ object.dirty = true;
1521
+ object.setCoords();
1522
+ this.canvas.requestRenderAll();
1523
+ this.events.emit("layer:modified", { layerId });
1524
+ if (save) this.history.save();
1525
+ return true;
1526
+ }
1527
+ clear(layerId, save = true) {
1528
+ return this.apply(layerId, null, save);
1529
+ }
1530
+ /**
1531
+ * Re-fit the clip to the layer's current size. The clip is built for the
1532
+ * object's dimensions at the time it was applied; editing text or replacing an
1533
+ * image changes them, and a stale clip would crop the wrong region.
1534
+ */
1535
+ refresh(layerId, save = false) {
1536
+ const id = this.get(layerId);
1537
+ if (!id) return false;
1538
+ return this.apply(layerId, id, save);
1539
+ }
1540
+ /** Re-fit every masked layer — used after a state restore. */
1541
+ refreshAll() {
1542
+ for (const layer of this.layers.getAll()) {
1543
+ if (layer.meta.maskPreset) this.refresh(layer.id);
1544
+ }
1545
+ }
1546
+ buildClip(id, object) {
1547
+ const width = Math.max(1, object.width ?? 1);
1548
+ const height = Math.max(1, object.height ?? 1);
1549
+ const shared = {
1550
+ originX: "center",
1551
+ originY: "center",
1552
+ left: 0,
1553
+ top: 0,
1554
+ objectCaching: false
1555
+ };
1556
+ if (isShapeMaskId(id)) {
1557
+ return new Path2(shapeMaskPathData(id), {
1558
+ ...shared,
1559
+ scaleX: width / SHAPE_MASK_BOX,
1560
+ scaleY: height / SHAPE_MASK_BOX
1561
+ });
1562
+ }
1563
+ return new FabricImage2(renderTextureMask(id), {
1564
+ ...shared,
1565
+ scaleX: width / TEXTURE_MASK_SIZE,
1566
+ scaleY: height / TEXTURE_MASK_SIZE
1567
+ });
1568
+ }
1569
+ };
1570
+
1571
+ // src/shadow.ts
1572
+ import { Shadow } from "fabric";
1573
+
1574
+ // src/utils/color.ts
1575
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1576
+ function isCssColor(value, allowEmpty = false) {
1577
+ if (typeof value !== "string") return false;
1578
+ const trimmed = value.trim();
1579
+ if (!trimmed) return allowEmpty;
1580
+ return CSS_COLOR.test(trimmed);
1581
+ }
1582
+
1583
+ // src/shadow.ts
1584
+ var DEFAULT_LAYER_SHADOW = {
1585
+ enabled: false,
1586
+ color: "#000000",
1587
+ blur: 12,
1588
+ offsetX: 6,
1589
+ offsetY: 6
1590
+ };
1591
+ function readLayerShadow(object) {
1592
+ const shadow = object.shadow;
1593
+ if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
1594
+ return {
1595
+ enabled: true,
1596
+ color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
1597
+ blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
1598
+ offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
1599
+ offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
1600
+ };
1601
+ }
1602
+ function applyLayerShadow(object, config) {
1603
+ const next = { ...readLayerShadow(object), ...config };
1604
+ if (!next.enabled) {
1605
+ object.set({ shadow: null });
1606
+ return;
1607
+ }
1608
+ const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
1609
+ object.set({
1610
+ shadow: new Shadow({
1611
+ color,
1612
+ blur: Math.max(0, next.blur),
1613
+ offsetX: next.offsetX,
1614
+ offsetY: next.offsetY
1615
+ })
1616
+ });
1617
+ }
1618
+
1619
+ // src/transform.ts
1620
+ var SIDE_CONTROLS = ["ml", "mr", "mt", "mb"];
1621
+ function applyAspectLock(object, locked) {
1622
+ for (const control of SIDE_CONTROLS) {
1623
+ object.setControlVisible(control, !locked);
1624
+ }
1625
+ }
1626
+ function resetTransform(object) {
1627
+ object.set({
1628
+ scaleX: 1,
1629
+ scaleY: 1,
1630
+ angle: 0,
1631
+ skewX: 0,
1632
+ skewY: 0,
1633
+ flipX: false,
1634
+ flipY: false
1635
+ });
1636
+ object.setCoords();
1637
+ }
1638
+
1091
1639
  // src/utils/units.ts
1092
1640
  var MM_PER_INCH = 25.4;
1093
1641
  var UnitConverter = class {
@@ -1137,17 +1685,6 @@ var UnitConverter = class {
1137
1685
 
1138
1686
  // src/serialization.ts
1139
1687
  import { util as util2 } from "fabric";
1140
-
1141
- // src/utils/color.ts
1142
- var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
1143
- function isCssColor(value, allowEmpty = false) {
1144
- if (typeof value !== "string") return false;
1145
- const trimmed = value.trim();
1146
- if (!trimmed) return allowEmpty;
1147
- return CSS_COLOR.test(trimmed);
1148
- }
1149
-
1150
- // src/serialization.ts
1151
1688
  var VERSION = "2.0.0";
1152
1689
  function serializeEditor(editor) {
1153
1690
  return {
@@ -1226,6 +1763,7 @@ function restoreLayer(editor, serialized, fabricObject) {
1226
1763
  const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
1227
1764
  if (serialized.meta) {
1228
1765
  layer.meta = serialized.meta;
1766
+ if (layer.meta.lockAspect) applyAspectLock(fabricObject, true);
1229
1767
  }
1230
1768
  if (!serialized.visible) {
1231
1769
  editor.layers.setVisibility(layer.id, false);
@@ -1553,7 +2091,7 @@ var ProjectManager = class {
1553
2091
  };
1554
2092
 
1555
2093
  // src/mask.ts
1556
- import { FabricImage as FabricImage2 } from "fabric";
2094
+ import { FabricImage as FabricImage3 } from "fabric";
1557
2095
  var MaskRefinementError = class extends Error {
1558
2096
  constructor(code, message, cause) {
1559
2097
  super(message);
@@ -1585,7 +2123,7 @@ var MaskController = class {
1585
2123
  throw new Error("Mask dimensions must be positive integers");
1586
2124
  }
1587
2125
  const backing = this.makeCanvas(width, height);
1588
- const image = new FabricImage2(backing, {
2126
+ const image = new FabricImage3(backing, {
1589
2127
  left: 0,
1590
2128
  top: 0,
1591
2129
  originX: "left",
@@ -1825,6 +2363,8 @@ var CanvasEditor = class {
1825
2363
  snapping;
1826
2364
  crop;
1827
2365
  patterns;
2366
+ curves;
2367
+ maskPresets;
1828
2368
  fonts;
1829
2369
  licensing;
1830
2370
  pages;
@@ -1876,6 +2416,8 @@ var CanvasEditor = class {
1876
2416
  this.events,
1877
2417
  config.patternSourceResolver
1878
2418
  );
2419
+ this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2420
+ this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
1879
2421
  this.setupCanvasEvents();
1880
2422
  this.history.saveImmediate();
1881
2423
  this.pages = new ProjectManager(this);
@@ -1884,7 +2426,7 @@ var CanvasEditor = class {
1884
2426
  // ─── Layer Operations ────────────────────────────────
1885
2427
  async addImage(url, options) {
1886
2428
  try {
1887
- const img = await FabricImage3.fromURL(
2429
+ const img = await FabricImage4.fromURL(
1888
2430
  url,
1889
2431
  {},
1890
2432
  { originX: "left", originY: "top", ...options }
@@ -1909,7 +2451,7 @@ var CanvasEditor = class {
1909
2451
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
1910
2452
  const previous = layer.fabricObject;
1911
2453
  try {
1912
- const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
2454
+ const replacement = await FabricImage4.fromURL(url, {}, { originX: "left", originY: "top" });
1913
2455
  replacement.set({
1914
2456
  left: previous.left,
1915
2457
  top: previous.top,
@@ -2148,7 +2690,7 @@ var CanvasEditor = class {
2148
2690
  const layer = this.layers.get(id);
2149
2691
  if (!layer) throw new Error(`Layer not found: ${id}`);
2150
2692
  try {
2151
- if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
2693
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage4) {
2152
2694
  const image = await layer.fabricObject.clone();
2153
2695
  image.set({
2154
2696
  left: 0,
@@ -2232,6 +2774,23 @@ var CanvasEditor = class {
2232
2774
  toDataURL(format = "png", multiplier = 1) {
2233
2775
  return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
2234
2776
  }
2777
+ /**
2778
+ * Export the print file: the design cropped to the mockup's print area, on
2779
+ * transparency. Without a print area this is the whole canvas, still
2780
+ * transparent — a print file never carries the design background.
2781
+ */
2782
+ async toPrintFile(options = {}) {
2783
+ await this.fonts.ready();
2784
+ try {
2785
+ const area = this.mockup?.printArea;
2786
+ const blob = area ? await exportPrintArea(this.canvas, area, options) : await exportIsolatedPNG(this.canvas, this.canvas.getObjects(), options);
2787
+ this.licensing.track(`export:print:${options.format ?? "png"}`);
2788
+ return blob;
2789
+ } catch (error) {
2790
+ this.events.emit("error", { message: "Failed to export print file", error });
2791
+ throw error;
2792
+ }
2793
+ }
2235
2794
  /** Export the current product-preview composite. Advanced warping is host-defined. */
2236
2795
  async toMockupImage(options = {}) {
2237
2796
  if (!this.mockup) throw new Error("No mockup is configured");
@@ -2403,7 +2962,7 @@ var CanvasEditor = class {
2403
2962
  return;
2404
2963
  }
2405
2964
  try {
2406
- const image = await FabricImage3.fromURL(
2965
+ const image = await FabricImage4.fromURL(
2407
2966
  url,
2408
2967
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
2409
2968
  { originX: "left", originY: "top" }
@@ -2578,14 +3137,89 @@ var CanvasEditor = class {
2578
3137
  clearPattern(layerId) {
2579
3138
  return this.patterns.disable(layerId);
2580
3139
  }
3140
+ // ─── Text curve ─────────────────────────────────────
3141
+ applyTextCurve(layerId, config) {
3142
+ return this.curves.apply(layerId, config);
3143
+ }
3144
+ clearTextCurve(layerId) {
3145
+ return this.curves.clear(layerId);
3146
+ }
3147
+ getTextCurve(layerId) {
3148
+ return this.curves.get(layerId);
3149
+ }
3150
+ // ─── Mask presets ───────────────────────────────────
3151
+ applyMaskPreset(layerId, id) {
3152
+ return this.maskPresets.apply(layerId, id);
3153
+ }
3154
+ clearMaskPreset(layerId) {
3155
+ return this.maskPresets.clear(layerId);
3156
+ }
3157
+ getMaskPreset(layerId) {
3158
+ return this.maskPresets.get(layerId);
3159
+ }
3160
+ // ─── Layer transform ────────────────────────────────
3161
+ /**
3162
+ * Constrain a layer to its current proportions. Persisted on the layer so a
3163
+ * reopened design still resizes the way it was set up to.
3164
+ */
3165
+ setLayerAspectLock(layerId, locked) {
3166
+ const layer = this.layers.get(layerId);
3167
+ if (!layer) return false;
3168
+ applyAspectLock(layer.fabricObject, locked);
3169
+ if (locked) layer.meta.lockAspect = true;
3170
+ else delete layer.meta.lockAspect;
3171
+ this.canvas.requestRenderAll();
3172
+ this.events.emit("layer:modified", { layerId });
3173
+ this.history.save();
3174
+ return true;
3175
+ }
3176
+ getLayerAspectLock(layerId) {
3177
+ return this.layers.get(layerId)?.meta.lockAspect === true;
3178
+ }
3179
+ /** Re-apply every stored aspect lock — control visibility is not serialized. */
3180
+ restoreAspectLocks() {
3181
+ for (const layer of this.layers.getAll()) {
3182
+ if (layer.meta.lockAspect) applyAspectLock(layer.fabricObject, true);
3183
+ }
3184
+ }
3185
+ /** Drop scale, rotation, skew and flips; the layer stays where it is. */
3186
+ resetLayerTransform(layerId) {
3187
+ const layer = this.layers.get(layerId);
3188
+ if (!layer) return false;
3189
+ resetTransform(layer.fabricObject);
3190
+ this.canvas.requestRenderAll();
3191
+ this.events.emit("layer:modified", { layerId });
3192
+ this.history.save();
3193
+ return true;
3194
+ }
3195
+ // ─── Shadow ─────────────────────────────────────────
3196
+ setLayerShadow(layerId, config) {
3197
+ const layer = this.layers.get(layerId);
3198
+ if (!layer) return false;
3199
+ applyLayerShadow(layer.fabricObject, config);
3200
+ layer.fabricObject.dirty = true;
3201
+ this.canvas.requestRenderAll();
3202
+ this.events.emit("layer:modified", { layerId });
3203
+ this.history.save();
3204
+ return true;
3205
+ }
3206
+ getLayerShadow(layerId) {
3207
+ const layer = this.layers.get(layerId);
3208
+ return layer ? readLayerShadow(layer.fabricObject) : null;
3209
+ }
2581
3210
  // ─── Mockup (preview-only) ──────────────────────────
2582
- setMockup(mockup) {
3211
+ /**
3212
+ * Show (or clear) the product preview. Pass `history: false` for preview-only
3213
+ * changes such as swapping a colourway — those are not design edits and
3214
+ * should not fill the undo stack.
3215
+ */
3216
+ setMockup(mockup, options = {}) {
2583
3217
  this.mockup = mockup;
2584
3218
  this.canvas.backgroundColor = mockup ? "" : this.designBackground;
2585
3219
  this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
2586
3220
  this.canvas.requestRenderAll();
2587
3221
  this.events.emit("mockup:changed", { mockup });
2588
- this.history.save();
3222
+ if (options.history !== false) this.history.save();
2589
3223
  }
2590
3224
  clearMockup() {
2591
3225
  this.setMockup(null);
@@ -2696,7 +3330,9 @@ export {
2696
3330
  CANVAS_SIZE_PRESETS,
2697
3331
  CanvasEditor,
2698
3332
  CropController,
3333
+ DEFAULT_LAYER_SHADOW,
2699
3334
  DEFAULT_PATTERN_CONFIG,
3335
+ DEFAULT_TEXT_CURVE,
2700
3336
  EventEmitter,
2701
3337
  FontRegistry,
2702
3338
  HistoryManager,
@@ -2704,16 +3340,26 @@ export {
2704
3340
  LayerManager,
2705
3341
  LicenseManager,
2706
3342
  MaskController,
3343
+ MaskPresetManager,
2707
3344
  MaskRefinementError,
2708
3345
  PatternManager,
2709
3346
  ProjectManager,
3347
+ SHAPE_MASK_BOX,
3348
+ SHAPE_MASK_IDS,
2710
3349
  SnapManager,
3350
+ TEXTURE_MASK_IDS,
3351
+ TEXTURE_MASK_SIZE,
3352
+ TextCurveManager,
2711
3353
  UnitConverter,
3354
+ applyAspectLock,
3355
+ applyLayerShadow,
2712
3356
  applyPatternLocks,
3357
+ buildCurvePathData,
2713
3358
  buildPatternDataURL,
2714
3359
  captureLocks,
2715
3360
  clamp,
2716
3361
  clearPatternImageCache,
3362
+ clearTextureMaskCache,
2717
3363
  computeCoverPlacement,
2718
3364
  computePrintAreaClip,
2719
3365
  computeTilePositions,
@@ -2724,13 +3370,21 @@ export {
2724
3370
  exportDataURL,
2725
3371
  exportMockup,
2726
3372
  exportPNG,
3373
+ exportPrintArea,
2727
3374
  exportSVG,
2728
3375
  generateId,
2729
3376
  isCssColor,
3377
+ isMaskPresetId,
3378
+ isShapeMaskId,
3379
+ isTextureMaskId,
2730
3380
  loadPatternImage,
3381
+ readLayerShadow,
3382
+ renderTextureMask,
3383
+ resetTransform,
2731
3384
  restoreLocks,
2732
3385
  round2,
2733
3386
  sanitizeSvg,
2734
- serializeEditor
3387
+ serializeEditor,
3388
+ shapeMaskPathData
2735
3389
  };
2736
3390
  //# sourceMappingURL=index.mjs.map