@overtone-art/canvas-editor-core 0.5.1 → 0.6.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
@@ -53,6 +53,7 @@ __export(index_exports, {
53
53
  applyLayerShadow: () => applyLayerShadow,
54
54
  applyObjectSelectionStyle: () => applyObjectSelectionStyle,
55
55
  applySelectionStyle: () => applySelectionStyle,
56
+ buildCurveLinePaths: () => buildCurveLinePaths,
56
57
  buildCurvePathData: () => buildCurvePathData,
57
58
  clamp: () => clamp,
58
59
  clearTextureMaskCache: () => clearTextureMaskCache,
@@ -64,6 +65,7 @@ __export(index_exports, {
64
65
  displaceRgba: () => displaceRgba,
65
66
  drawTiles: () => drawTiles,
66
67
  escapeXml: () => escapeXml,
68
+ explodeTemplateSvg: () => explodeTemplateSvg,
67
69
  exportDataURL: () => exportDataURL,
68
70
  exportMockup: () => exportMockup,
69
71
  exportPNG: () => exportPNG,
@@ -76,6 +78,7 @@ __export(index_exports, {
76
78
  isShapeMaskId: () => isShapeMaskId,
77
79
  isTextureMaskId: () => isTextureMaskId,
78
80
  needsAbsoluteSpace: () => needsAbsoluteSpace,
81
+ normalizeTextCurve: () => normalizeTextCurve,
79
82
  readLayerShadow: () => readLayerShadow,
80
83
  renderTextureMask: () => renderTextureMask,
81
84
  resetTransform: () => resetTransform,
@@ -84,6 +87,8 @@ __export(index_exports, {
84
87
  sanitizeSvg: () => sanitizeSvg,
85
88
  serializeEditor: () => serializeEditor,
86
89
  shapeMaskPathData: () => shapeMaskPathData,
90
+ textPathCurve: () => textPathCurve,
91
+ textPathSpec: () => textPathSpec,
87
92
  toCanvasSpace: () => toCanvasSpace,
88
93
  toHostSpace: () => toHostSpace,
89
94
  unwrapGroup: () => unwrapGroup
@@ -91,7 +96,7 @@ __export(index_exports, {
91
96
  module.exports = __toCommonJS(index_exports);
92
97
 
93
98
  // src/editor.ts
94
- var import_fabric17 = require("fabric");
99
+ var import_fabric19 = require("fabric");
95
100
 
96
101
  // src/events.ts
97
102
  var EventEmitter = class {
@@ -128,6 +133,9 @@ var EventEmitter = class {
128
133
  }
129
134
  };
130
135
 
136
+ // src/layer.ts
137
+ var import_fabric = require("fabric");
138
+
131
139
  // src/utils/id.ts
132
140
  var import_nanoid = require("nanoid");
133
141
  var generateId = () => (0, import_nanoid.nanoid)(12);
@@ -149,6 +157,14 @@ var Layer = class {
149
157
  * the layer's real object stays the one every editor API talks to.
150
158
  */
151
159
  renderProxy;
160
+ /**
161
+ * Layers nested inside this one, in draw order. Only a `group` layer has any:
162
+ * their fabric objects live inside this layer's fabric `Group`, not on the
163
+ * canvas, which is why the manager keeps them off the canvas stack.
164
+ */
165
+ children;
166
+ /** Group this layer sits inside, or null when it is on the canvas stack. */
167
+ parentId;
152
168
  constructor(type, fabricObject, name, id) {
153
169
  this.id = id ?? generateId();
154
170
  this.type = type;
@@ -159,6 +175,8 @@ var Layer = class {
159
175
  this.meta = {};
160
176
  this.fabricObject = fabricObject;
161
177
  this.renderProxy = null;
178
+ this.children = [];
179
+ this.parentId = null;
162
180
  this.fabricObject._layerId = this.id;
163
181
  }
164
182
  hasMeta() {
@@ -172,12 +190,15 @@ var Layer = class {
172
190
  visible: this.visible,
173
191
  locked: this.locked,
174
192
  opacity: this.opacity,
175
- ...this.hasMeta() ? { meta: this.meta } : {}
193
+ ...this.hasMeta() ? { meta: this.meta } : {},
194
+ ...this.children.length > 0 ? { children: this.children.map((c) => c.toData()) } : {}
176
195
  };
177
196
  }
178
197
  serialize() {
179
198
  return {
180
199
  ...this.toData(),
200
+ // A group's own object already carries its children's geometry, so the
201
+ // child records in `toData` stay identity-only — nothing is duplicated.
181
202
  fabricObject: this.fabricObject.toObject()
182
203
  };
183
204
  }
@@ -203,9 +224,23 @@ var LayerManager = class {
203
224
  return layer;
204
225
  }
205
226
  remove(id) {
227
+ const layer = this.get(id);
228
+ if (!layer) return false;
229
+ if (layer.parentId) {
230
+ const parent = this.get(layer.parentId);
231
+ if (!parent) return false;
232
+ parent.fabricObject.remove(layer.fabricObject);
233
+ parent.children = parent.children.filter((c) => c.id !== id);
234
+ layer.parentId = null;
235
+ this.events.emit("layer:removed", { layerId: id });
236
+ if (parent.children.length < 2) this.dissolve(parent);
237
+ this.emitChanged();
238
+ this.onPropertyChanged?.();
239
+ this.canvas.requestRenderAll();
240
+ return true;
241
+ }
206
242
  const index = this.layers.findIndex((l) => l.id === id);
207
243
  if (index === -1) return false;
208
- const layer = this.layers[index];
209
244
  this.canvas.remove(layer.fabricObject);
210
245
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
211
246
  this.layers.splice(index, 1);
@@ -214,6 +249,81 @@ var LayerManager = class {
214
249
  this.onPropertyChanged?.();
215
250
  return true;
216
251
  }
252
+ /**
253
+ * Take a top-level layer off the canvas stack **without** disposing it — it is
254
+ * about to become a group's child, so its object has to stay alive.
255
+ */
256
+ detach(id) {
257
+ const index = this.layers.findIndex((l) => l.id === id);
258
+ if (index === -1) return void 0;
259
+ const [layer] = this.layers.splice(index, 1);
260
+ this.canvas.remove(layer.fabricObject);
261
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
262
+ return layer;
263
+ }
264
+ /** Put a group's children back on the canvas stack and drop the group. */
265
+ explode(group) {
266
+ const children = [...group.children];
267
+ this.dissolve(group);
268
+ this.events.emit("layer:removed", { layerId: group.id });
269
+ this.emitChanged();
270
+ this.onPropertyChanged?.();
271
+ this.canvas.requestRenderAll();
272
+ return children;
273
+ }
274
+ /**
275
+ * Empty a group onto the canvas stack, in the group's own place, and drop the
276
+ * group record. `removeAll` restores each child's absolute transform, so the
277
+ * group's matrix must NOT be re-applied on top of it.
278
+ */
279
+ dissolve(group) {
280
+ const objects = group.fabricObject.removeAll();
281
+ const at = this.layers.findIndex((l) => l.id === group.id);
282
+ const survivors = group.children;
283
+ group.children = [];
284
+ for (const [index, object] of objects.entries()) {
285
+ object.setCoords();
286
+ const child = survivors[index];
287
+ if (!child) continue;
288
+ child.parentId = null;
289
+ this.layers.splice(at + index, 0, child);
290
+ this.canvas.add(object);
291
+ if (child.renderProxy) this.canvas.add(child.renderProxy);
292
+ }
293
+ this.canvas.remove(group.fabricObject);
294
+ this.layers = this.layers.filter((l) => l.id !== group.id);
295
+ this.syncZOrder();
296
+ }
297
+ /**
298
+ * Re-attach child records to an enlivened group. `data[i]` describes
299
+ * `group._objects[i]` — the order fabric serializes and restores them in. A
300
+ * missing record still becomes a child, so a hand-edited state cannot drop
301
+ * artwork off the canvas.
302
+ */
303
+ adoptGroupChildren(group, data) {
304
+ const objects = group.fabricObject.getObjects();
305
+ group.children = objects.map((object, index) => {
306
+ const record = data[index];
307
+ const child = new Layer(record?.type ?? "shape", object, record?.name, record?.id);
308
+ child.parentId = group.id;
309
+ if (record?.meta) child.meta = structuredClone(record.meta);
310
+ if (record && !record.visible) {
311
+ child.visible = false;
312
+ object.visible = false;
313
+ }
314
+ if (record?.locked) {
315
+ child.locked = true;
316
+ object.selectable = false;
317
+ object.evented = false;
318
+ }
319
+ if (record && record.opacity !== 1) {
320
+ child.opacity = record.opacity;
321
+ object.opacity = record.opacity;
322
+ }
323
+ return child;
324
+ });
325
+ this.emitChanged();
326
+ }
217
327
  /** Replace a layer's render object while preserving its immutable ID and panel state. */
218
328
  replaceObject(id, fabricObject) {
219
329
  const layer = this.get(id);
@@ -278,16 +388,32 @@ var LayerManager = class {
278
388
  if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
279
389
  }
280
390
  }
391
+ /** The list a layer lives in: its group's children, or the canvas stack. */
392
+ siblingsOf(layer) {
393
+ if (!layer.parentId) return this.layers;
394
+ return this.get(layer.parentId)?.children ?? this.layers;
395
+ }
281
396
  reorder(id, newIndex) {
282
- const oldIndex = this.layers.findIndex((l) => l.id === id);
397
+ const target = this.get(id);
398
+ if (!target) return false;
399
+ const list = this.siblingsOf(target);
400
+ const oldIndex = list.indexOf(target);
283
401
  if (oldIndex === -1) return false;
284
402
  if (!Number.isFinite(newIndex)) return false;
285
- const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
403
+ const clamped = Math.max(0, Math.min(list.length - 1, Math.round(newIndex)));
286
404
  if (oldIndex === clamped) return false;
287
- const [layer] = this.layers.splice(oldIndex, 1);
288
- this.layers.splice(clamped, 0, layer);
289
- this.syncZOrder();
290
- this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
405
+ const [layer] = list.splice(oldIndex, 1);
406
+ list.splice(clamped, 0, layer);
407
+ if (layer.parentId) {
408
+ const group = this.get(layer.parentId)?.fabricObject;
409
+ if (group) {
410
+ group.remove(layer.fabricObject);
411
+ group.insertAt(clamped, layer.fabricObject);
412
+ }
413
+ } else {
414
+ this.syncZOrder();
415
+ }
416
+ this.events.emit("layer:reordered", { layerIds: list.map((l) => l.id) });
291
417
  this.emitChanged();
292
418
  this.onPropertyChanged?.();
293
419
  return true;
@@ -295,9 +421,12 @@ var LayerManager = class {
295
421
  select(id) {
296
422
  if (id === null) {
297
423
  this.canvas.discardActiveObject();
424
+ this.exitGroups();
298
425
  } else {
299
426
  const layer = this.get(id);
300
427
  if (layer) {
428
+ if (layer.parentId) this.enterGroup(layer.parentId);
429
+ else this.exitGroups();
301
430
  this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
302
431
  }
303
432
  }
@@ -306,16 +435,67 @@ var LayerManager = class {
306
435
  const selected = id && this.get(id) ? [id] : [];
307
436
  this.events.emit("selection:changed", { selected });
308
437
  }
438
+ /** Drill into a group so its children can be selected and clicked. */
439
+ enterGroup(groupId) {
440
+ const parent = this.get(groupId);
441
+ if (!parent) return;
442
+ const group = parent.fabricObject;
443
+ group.subTargetCheck = true;
444
+ group.interactive = true;
445
+ }
446
+ /** Leave every entered group — selecting anything else is done drilling in. */
447
+ exitGroups() {
448
+ for (const layer of this.layers) {
449
+ if (layer.type !== "group") continue;
450
+ const group = layer.fabricObject;
451
+ if (group.interactive) group.interactive = false;
452
+ }
453
+ }
454
+ /**
455
+ * Select several layers at once, as one fabric `ActiveSelection`. Children are
456
+ * skipped: a selection spanning in and out of a group has no single transform.
457
+ */
458
+ selectMany(ids) {
459
+ const layers = ids.flatMap((id) => {
460
+ const layer = this.get(id);
461
+ return layer && !layer.parentId ? [layer] : [];
462
+ });
463
+ if (layers.length === 0) return this.select(null);
464
+ if (layers.length === 1) return this.select(layers[0].id);
465
+ this.canvas.discardActiveObject();
466
+ this.exitGroups();
467
+ const selection = new import_fabric.ActiveSelection(
468
+ layers.map((l) => l.renderProxy ?? l.fabricObject),
469
+ { canvas: this.canvas }
470
+ );
471
+ this.canvas.setActiveObject(selection);
472
+ this.canvas.requestRenderAll();
473
+ this.events.emit("selection:changed", { selected: layers.map((l) => l.id) });
474
+ }
475
+ /** Depth-first over the whole tree, each layer before its children. */
476
+ *walk(layers = this.layers) {
477
+ for (const layer of layers) {
478
+ yield layer;
479
+ if (layer.children.length > 0) yield* this.walk(layer.children);
480
+ }
481
+ }
309
482
  get(id) {
310
- return this.layers.find((l) => l.id === id);
483
+ for (const layer of this.walk()) if (layer.id === id) return layer;
484
+ return void 0;
311
485
  }
486
+ /** Top-level layers only — the canvas stack. Children hang off their group. */
312
487
  getAll() {
313
488
  return [...this.layers];
314
489
  }
490
+ /** Top-level layers, each carrying its children: what the layer panel renders. */
491
+ getTree() {
492
+ return this.layers.map((l) => l.toData());
493
+ }
315
494
  findByObject(obj) {
316
495
  const layerId = obj._layerId;
317
496
  if (layerId) return this.get(layerId);
318
- return this.layers.find((l) => l.fabricObject === obj);
497
+ for (const layer of this.walk()) if (layer.fabricObject === obj) return layer;
498
+ return void 0;
319
499
  }
320
500
  setVisibility(id, visible) {
321
501
  const layer = this.get(id);
@@ -396,6 +576,7 @@ var LayerManager = class {
396
576
  for (const layer of this.layers) {
397
577
  this.canvas.remove(layer.fabricObject);
398
578
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
579
+ layer.children = [];
399
580
  }
400
581
  this.layers = [];
401
582
  this.emitChanged();
@@ -410,6 +591,107 @@ var LayerManager = class {
410
591
  }
411
592
  };
412
593
 
594
+ // src/template-explode.ts
595
+ var import_fabric2 = require("fabric");
596
+ var NAME_MAX = 24;
597
+ function isText(object) {
598
+ return typeof object.text === "string";
599
+ }
600
+ function labelsByRun(template, values) {
601
+ const labels = new Map(template.parameters.map((p) => [p.key, p.label || p.key]));
602
+ const byRun = /* @__PURE__ */ new Map();
603
+ for (const match of template.svg.matchAll(/<text\b[^>]*>([\s\S]*?)<\/text>/g)) {
604
+ const token = /\{\{(\w+)(?:[|:]([^}]*))?\}\}/.exec(match[1]);
605
+ if (!token) continue;
606
+ const label = labels.get(token[1]);
607
+ if (!label) continue;
608
+ const resolved = String(values[token[1]] ?? token[2] ?? "").trim();
609
+ if (resolved) byRun.set(resolved, label);
610
+ }
611
+ return byRun;
612
+ }
613
+ function textName(run, labels) {
614
+ const trimmed = run.trim();
615
+ const label = labels.get(trimmed);
616
+ if (label) return label;
617
+ if (!trimmed) return "Text";
618
+ return trimmed.length > NAME_MAX ? `${trimmed.slice(0, NAME_MAX - 1)}\u2026` : trimmed;
619
+ }
620
+ function shapeName(object) {
621
+ const id = object.id;
622
+ if (typeof id === "string" && id) return id;
623
+ const type = object.type || "shape";
624
+ return type.charAt(0).toUpperCase() + type.slice(1);
625
+ }
626
+ function toTextbox(text) {
627
+ const box = new import_fabric2.Textbox(text.text, {
628
+ left: text.left,
629
+ top: text.top,
630
+ originX: text.originX,
631
+ originY: text.originY,
632
+ angle: text.angle,
633
+ scaleX: text.scaleX,
634
+ scaleY: text.scaleY,
635
+ fontSize: text.fontSize,
636
+ fontFamily: text.fontFamily,
637
+ fontWeight: text.fontWeight,
638
+ fontStyle: text.fontStyle,
639
+ textAlign: text.textAlign,
640
+ charSpacing: text.charSpacing,
641
+ lineHeight: text.lineHeight,
642
+ fill: text.fill,
643
+ stroke: text.stroke,
644
+ strokeWidth: text.strokeWidth,
645
+ opacity: text.opacity,
646
+ // A Textbox wraps at its box width; size it to the run the template drew so
647
+ // the authored copy never comes in pre-wrapped.
648
+ width: Math.max(1, (text.width ?? 0) + 2)
649
+ });
650
+ return box;
651
+ }
652
+ function textPathCurve(template, values, runWidth) {
653
+ if (template.id === "circular-text") return { arc: 100, wave: 0 };
654
+ if (template.id === "wave-text") return { arc: 0, wave: 50 };
655
+ const radius = Number(values.radius ?? 200);
656
+ if (!Number.isFinite(radius) || radius <= 0) return { arc: 0, wave: 0 };
657
+ const arc = 100 * runWidth / (Math.PI * radius);
658
+ return { arc: Math.max(-100, Math.min(100, arc)), wave: 0 };
659
+ }
660
+ function textPathSpec(template, values) {
661
+ const content = String(values.text ?? "Text");
662
+ const fontSize = template.id === "circular-text" ? 28 : 40;
663
+ const box = new import_fabric2.Textbox(content, {
664
+ fontSize,
665
+ fontFamily: String(values.font ?? "Arial"),
666
+ fontWeight: "bold",
667
+ fill: String(values.color ?? "#000000"),
668
+ originX: "left",
669
+ originY: "top",
670
+ // Wide enough that the run stays on one line before the curve widens it.
671
+ width: Math.max(1, content.length * fontSize * 0.62)
672
+ });
673
+ return {
674
+ type: "text",
675
+ name: template.parameters.find((p) => p.key === "text")?.label || "Text",
676
+ object: box,
677
+ curve: textPathCurve(template, values, box.width ?? 0)
678
+ };
679
+ }
680
+ async function explodeTemplateSvg(template, resolvedSvg, values) {
681
+ const labels = labelsByRun(template, values);
682
+ const { objects } = await (0, import_fabric2.loadSVGFromString)(resolvedSvg);
683
+ const specs = [];
684
+ for (const object of objects) {
685
+ if (object === null) continue;
686
+ if (isText(object)) {
687
+ specs.push({ type: "text", name: textName(object.text, labels), object: toTextbox(object) });
688
+ continue;
689
+ }
690
+ specs.push({ type: "shape", name: shapeName(object), object });
691
+ }
692
+ return specs;
693
+ }
694
+
413
695
  // src/history.ts
414
696
  var HistoryManager = class _HistoryManager {
415
697
  static ASSET_KEY = "__canvasEditorHistoryAsset";
@@ -797,7 +1079,7 @@ function bestSnap(edges, candidates, threshold) {
797
1079
  }
798
1080
 
799
1081
  // src/crop.ts
800
- var import_fabric = require("fabric");
1082
+ var import_fabric3 = require("fabric");
801
1083
 
802
1084
  // src/utils/clamp.ts
803
1085
  function clamp(v, min, max) {
@@ -837,7 +1119,7 @@ var CropController = class {
837
1119
  if (prevAngle) image.rotate(0);
838
1120
  image.setCoords();
839
1121
  const b = image.getBoundingRect();
840
- const rect = new import_fabric.Rect({
1122
+ const rect = new import_fabric3.Rect({
841
1123
  left: b.left,
842
1124
  top: b.top,
843
1125
  width: b.width,
@@ -930,10 +1212,10 @@ var CropController = class {
930
1212
  var STROKE2 = "#22c55e";
931
1213
 
932
1214
  // src/pattern/pattern-manager.ts
933
- var import_fabric3 = require("fabric");
1215
+ var import_fabric5 = require("fabric");
934
1216
 
935
1217
  // src/pattern/tiled-pattern-object.ts
936
- var import_fabric2 = require("fabric");
1218
+ var import_fabric4 = require("fabric");
937
1219
 
938
1220
  // src/pattern/tile-geometry.ts
939
1221
  var MAX_TILES_PER_AXIS = 200;
@@ -1000,7 +1282,7 @@ var MAX_SNAPSHOT_PIXELS = 16e6;
1000
1282
  var MAX_SNAPSHOT_SCALE = 8;
1001
1283
  var SNAPSHOT_SHRINK_FACTOR = 2;
1002
1284
  var TILE_BLEED_DEVICE_PX = 2;
1003
- var TiledPatternObject = class _TiledPatternObject extends import_fabric2.FabricObject {
1285
+ var TiledPatternObject = class _TiledPatternObject extends import_fabric4.FabricObject {
1004
1286
  static type = "TiledPattern";
1005
1287
  /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
1006
1288
  source;
@@ -1083,7 +1365,7 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
1083
1365
  const { tileW, tileH } = this.liveTile();
1084
1366
  const centre = this.getCenterPoint();
1085
1367
  const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1086
- return new import_fabric2.Point(centre.x - shift.x, centre.y - shift.y);
1368
+ return new import_fabric4.Point(centre.x - shift.x, centre.y - shift.y);
1087
1369
  }
1088
1370
  _render(ctx) {
1089
1371
  const { width, height } = this.area;
@@ -1260,11 +1542,11 @@ var TiledPatternObject = class _TiledPatternObject extends import_fabric2.Fabric
1260
1542
  const radians = angle * Math.PI / 180;
1261
1543
  const cos = Math.cos(radians);
1262
1544
  const sin = Math.sin(radians);
1263
- return new import_fabric2.Point(dx * cos - dy * sin, dx * sin + dy * cos);
1545
+ return new import_fabric4.Point(dx * cos - dy * sin, dx * sin + dy * cos);
1264
1546
  }
1265
1547
  anchorFromOrigin(origin, tileW, tileH, angle) {
1266
1548
  const shift = this.shiftVector(tileW, tileH, angle);
1267
- return new import_fabric2.Point(origin.x + shift.x, origin.y + shift.y);
1549
+ return new import_fabric4.Point(origin.x + shift.x, origin.y + shift.y);
1268
1550
  }
1269
1551
  /**
1270
1552
  * Snapshot the source at (at least) `scale`, reusing the cached one while it
@@ -1524,7 +1806,7 @@ async function unbakeLegacyLayer(layer, state) {
1524
1806
  cropY: state.original.cropY,
1525
1807
  angle: state.original.angle
1526
1808
  });
1527
- image.clipPath = state.originalClip ? (await import_fabric3.util.enlivenObjects([state.originalClip]))[0] : void 0;
1809
+ image.clipPath = state.originalClip ? (await import_fabric5.util.enlivenObjects([state.originalClip]))[0] : void 0;
1528
1810
  restoreLocks(image, state.originalLocks);
1529
1811
  image.setCoords();
1530
1812
  }
@@ -1542,91 +1824,154 @@ function restoreLocks(obj, locks) {
1542
1824
  }
1543
1825
 
1544
1826
  // src/text-curve.ts
1545
- var import_fabric4 = require("fabric");
1546
- var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1827
+ var import_fabric6 = require("fabric");
1828
+
1829
+ // src/text-curve-geometry.ts
1830
+ var DEFAULT_TEXT_CURVE = {
1831
+ shape: "arc",
1832
+ arc: 0,
1833
+ wave: 0,
1834
+ waveLength: 4.1,
1835
+ offset: 0,
1836
+ centerOffset: 0
1837
+ };
1547
1838
  var MIN_ARC = 0.5;
1548
1839
  var FULL_CIRCLE_ARC = 99.5;
1549
1840
  var MIN_SWEEP = 0.12;
1550
- var WAVE_PERIOD_EM = 4.1;
1551
1841
  var WAVE_AMPLITUDE_EM = 0.9;
1842
+ var MIN_WAVE_LENGTH = 1;
1843
+ var MAX_WAVE_LENGTH = 12;
1552
1844
  var WAVE_STEP = 6;
1553
- var MEASURE_WIDTH = 1e5;
1554
- var PATH_SLACK = 0.06;
1555
- function isCurvable(object) {
1556
- return !!object && typeof object.text === "string";
1845
+ var MAX_WAVE_STEPS = 4e3;
1846
+ function round(value) {
1847
+ return Math.round(value * 100) / 100;
1557
1848
  }
1558
- function measureText(text) {
1559
- const authored = text.width;
1560
- try {
1561
- text.set({ width: MEASURE_WIDTH });
1562
- text.initDimensions?.();
1563
- return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1564
- } finally {
1565
- if (authored !== void 0) text.set({ width: authored });
1566
- text.initDimensions?.();
1567
- }
1849
+ function normalizeTextCurve(config) {
1850
+ const arc = clamp(config.arc ?? 0, -100, 100);
1851
+ const wave = clamp(config.wave ?? 0, 0, 100);
1852
+ const inferred = Math.abs(arc) < MIN_ARC && wave > 0 ? "wave" : "arc";
1853
+ return {
1854
+ shape: config.shape ?? inferred,
1855
+ arc,
1856
+ wave,
1857
+ waveLength: clamp(config.waveLength ?? DEFAULT_TEXT_CURVE.waveLength, MIN_WAVE_LENGTH, MAX_WAVE_LENGTH),
1858
+ offset: clamp(config.offset ?? 0, -100, 100),
1859
+ centerOffset: clamp(config.centerOffset ?? 0, -100, 100)
1860
+ };
1568
1861
  }
1569
- function arcPathData(width, arc) {
1570
- const magnitude = Math.min(100, Math.abs(arc));
1571
- const direction = arc < 0 ? -1 : 1;
1572
- const full = magnitude >= FULL_CIRCLE_ARC;
1573
- const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1574
- const radius = width / sweep;
1862
+ function bendsBaseline(config) {
1863
+ return config.shape === "arc" ? Math.abs(config.arc) >= MIN_ARC : config.wave > 0;
1864
+ }
1865
+ function arcGeometry(width, config) {
1866
+ const magnitude = Math.min(100, Math.abs(config.arc));
1867
+ const direction = config.arc < 0 ? -1 : 1;
1868
+ const baseSweep = magnitude >= FULL_CIRCLE_ARC ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1869
+ const baseRadius = width / baseSweep;
1870
+ const radius = baseRadius * Math.pow(2, config.centerOffset / 100);
1871
+ const sweep = radius >= baseRadius ? baseSweep : Math.min(Math.PI * 2, width / Math.max(radius, 1e-3));
1872
+ return { radius, sweep, direction, baseRadius };
1873
+ }
1874
+ function arcLinePath(width, geometry, radius, rotation) {
1875
+ const { sweep, direction, baseRadius } = geometry;
1876
+ const full = sweep >= Math.PI * 2 - 1e-6;
1575
1877
  const centerX = width / 2;
1576
1878
  const point = (angle) => [
1577
1879
  centerX + radius * Math.sin(angle),
1578
- direction * radius * (1 - Math.cos(angle))
1880
+ direction * (baseRadius - radius * Math.cos(angle))
1579
1881
  ];
1580
1882
  const sweepFlag = direction > 0 ? 1 : 0;
1581
1883
  const format = ([x, y]) => `${round(x)} ${round(y)}`;
1884
+ const r = round(radius);
1582
1885
  if (full) {
1583
- const start2 = point(-Math.PI);
1584
- const top = point(0);
1886
+ const start2 = point(rotation - Math.PI);
1887
+ const opposite = point(rotation);
1585
1888
  return {
1586
1889
  data: [
1587
1890
  `M ${format(start2)}`,
1588
- `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
1589
- `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
1891
+ `A ${r} ${r} 0 0 ${sweepFlag} ${format(opposite)}`,
1892
+ `A ${r} ${r} 0 0 ${sweepFlag} ${format(start2)}`
1590
1893
  ].join(" "),
1591
- length: width
1894
+ length: radius * Math.PI * 2
1592
1895
  };
1593
1896
  }
1594
- const start = point(-sweep / 2);
1595
- const end = point(sweep / 2);
1897
+ const start = point(rotation - sweep / 2);
1898
+ const end = point(rotation + sweep / 2);
1596
1899
  const largeArc = sweep > Math.PI ? 1 : 0;
1597
1900
  return {
1598
- data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1599
- // radius = width / sweep, so the arc is exactly `width` long.
1600
- length: width
1901
+ data: `M ${format(start)} A ${r} ${r} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1902
+ length: radius * sweep
1601
1903
  };
1602
1904
  }
1603
- function wavePathData(width, fontSize, wave) {
1604
- const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
1605
- const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
1606
- const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
1905
+ function waveLinePath(width, fontSize, config, dy) {
1906
+ const amplitude = config.wave / 100 * fontSize * WAVE_AMPLITUDE_EM;
1907
+ const period = Math.max(1, fontSize * config.waveLength);
1908
+ const phase = config.offset / 100 * Math.PI * 2;
1909
+ const step = Math.min(WAVE_STEP, period / 8);
1910
+ const steps = Math.min(MAX_WAVE_STEPS, Math.max(2, Math.ceil(width / step)));
1607
1911
  const commands = [];
1608
1912
  let length = 0;
1609
1913
  let previous = null;
1610
1914
  for (let index = 0; index <= steps; index++) {
1611
1915
  const x = width * index / steps;
1612
- const y = amplitude * Math.sin(x / period * Math.PI * 2);
1916
+ const y = dy + amplitude * Math.sin(x / period * Math.PI * 2 + phase);
1613
1917
  if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
1614
1918
  previous = [x, y];
1615
1919
  commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
1616
1920
  }
1617
1921
  return { data: commands.join(" "), length };
1618
1922
  }
1619
- function round(value) {
1620
- return Math.round(value * 100) / 100;
1923
+ function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight = 0) {
1924
+ const curve = normalizeTextCurve(config);
1925
+ if (!bendsBaseline(curve)) return null;
1926
+ const lines = Math.max(1, Math.floor(lineCount));
1927
+ const paths = [];
1928
+ if (curve.shape === "wave") {
1929
+ for (let index = 0; index < lines; index++) {
1930
+ paths.push(waveLinePath(width, fontSize, curve, (index - (lines - 1) / 2) * lineHeight));
1931
+ }
1932
+ return paths;
1933
+ }
1934
+ const geometry = arcGeometry(width, curve);
1935
+ const rotation = curve.offset / 100 * geometry.sweep;
1936
+ for (let index = 0; index < lines; index++) {
1937
+ const radius = Math.max(1, geometry.radius + (index - (lines - 1) / 2) * lineHeight);
1938
+ paths.push(arcLinePath(width, geometry, radius, rotation));
1939
+ }
1940
+ return paths;
1621
1941
  }
1622
1942
  function buildCurvePathData(config, width, fontSize) {
1623
- if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
1624
- if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
1625
- return null;
1943
+ return buildCurveLinePaths(config, width, fontSize)?.[0] ?? null;
1626
1944
  }
1627
- function normalize(config) {
1628
- const arc = clamp(config.arc ?? 0, -100, 100);
1629
- return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
1945
+
1946
+ // src/text-curve.ts
1947
+ var MEASURE_WIDTH = 1e5;
1948
+ var PATH_SLACK = 0.06;
1949
+ var FALLBACK_LINE_HEIGHT = 1.16;
1950
+ var patches = /* @__PURE__ */ new WeakMap();
1951
+ function isCurvable(object) {
1952
+ return !!object && typeof object.text === "string";
1953
+ }
1954
+ function measureText(text) {
1955
+ const authored = text.width;
1956
+ try {
1957
+ text.set({ width: MEASURE_WIDTH });
1958
+ text.initDimensions?.();
1959
+ return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1960
+ } finally {
1961
+ if (authored !== void 0) text.set({ width: authored });
1962
+ text.initDimensions?.();
1963
+ }
1964
+ }
1965
+ function lineCountOf(text) {
1966
+ return Math.max(1, text._textLines?.length ?? 1);
1967
+ }
1968
+ function lineHeightOf(text) {
1969
+ const measured = text.getHeightOfLine?.(0);
1970
+ if (typeof measured === "number" && Number.isFinite(measured) && measured > 0) return measured;
1971
+ return text.fontSize * (text.lineHeight ?? 1) * FALLBACK_LINE_HEIGHT;
1972
+ }
1973
+ function toFabricPath(curve) {
1974
+ return new import_fabric6.Path(curve.data, { visible: false, objectCaching: false });
1630
1975
  }
1631
1976
  var TextCurveManager = class {
1632
1977
  constructor(canvas, layers, history, events) {
@@ -1643,33 +1988,35 @@ var TextCurveManager = class {
1643
1988
  get(layerId) {
1644
1989
  const layer = this.layers.get(layerId);
1645
1990
  if (!layer || !isCurvable(layer.fabricObject)) return null;
1646
- return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
1991
+ return normalizeTextCurve(layer.meta.curve ?? {});
1647
1992
  }
1648
1993
  isCurved(layerId) {
1649
1994
  const curve = this.get(layerId);
1650
- return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
1995
+ return !!curve && bendsBaseline(curve);
1651
1996
  }
1652
- /** Apply (or update) the curve on a text layer. Zeroed config clears it. */
1997
+ /** Apply (or update) the curve on a text layer. A straight config clears it. */
1653
1998
  apply(layerId, config, save = true) {
1654
1999
  const layer = this.layers.get(layerId);
1655
2000
  if (!layer || !isCurvable(layer.fabricObject)) return false;
1656
- const next = normalize(config);
2001
+ const next = normalizeTextCurve(config);
1657
2002
  const text = layer.fabricObject;
1658
2003
  const run = measureText(text);
1659
- const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
1660
- if (!curve) {
2004
+ if (!bendsBaseline(next)) {
1661
2005
  this.detach(text, layer.meta.curveWidth);
1662
- delete layer.meta.curve;
1663
2006
  delete layer.meta.curveWidth;
2007
+ layer.meta.curve = next;
1664
2008
  } else {
1665
2009
  if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1666
2010
  text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1667
- text.set({
1668
- path: new import_fabric4.Path(curve.data, { visible: false, objectCaching: false }),
1669
- pathAlign: "center",
1670
- pathSide: "left",
1671
- pathStartOffset: Math.max(0, (curve.length - run) / 2)
1672
- });
2011
+ text.initDimensions?.();
2012
+ const curves = buildCurveLinePaths(
2013
+ next,
2014
+ run * (1 + PATH_SLACK),
2015
+ text.fontSize,
2016
+ lineCountOf(text),
2017
+ lineHeightOf(text)
2018
+ );
2019
+ if (curves) this.attach(text, curves);
1673
2020
  layer.meta.curve = next;
1674
2021
  }
1675
2022
  text.initDimensions?.();
@@ -1682,12 +2029,14 @@ var TextCurveManager = class {
1682
2029
  }
1683
2030
  /** Remove the curve, restoring the authored text box width. */
1684
2031
  clear(layerId, save = true) {
1685
- return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
2032
+ const layer = this.layers.get(layerId);
2033
+ const shape = layer?.meta.curve?.shape;
2034
+ return this.apply(layerId, { shape }, save);
1686
2035
  }
1687
2036
  /**
1688
- * Rebuild the path from the stored parameters. Text content, font family and
1689
- * font size all change the run's width, and the path is sized to that width
1690
- * without this the curve keeps the geometry of the text it was created from.
2037
+ * Rebuild the paths from the stored parameters. Text content, line count,
2038
+ * font family and font size all change the geometry the paths are sized to —
2039
+ * without this the curve keeps that of the text it was created from.
1691
2040
  */
1692
2041
  refresh(layerId, save = false) {
1693
2042
  const curve = this.layers.get(layerId)?.meta.curve;
@@ -1700,14 +2049,84 @@ var TextCurveManager = class {
1700
2049
  if (layer.meta.curve) this.refresh(layer.id);
1701
2050
  }
1702
2051
  }
2052
+ /**
2053
+ * Puts every line's path on the object as one shape, and hooks per-line
2054
+ * measuring so each line is laid out on its own.
2055
+ *
2056
+ * The object's own path is the union of the lines: fabric sizes a curved text
2057
+ * object from it, and a single line's path would leave the rest of the block
2058
+ * outside the layer's box — where object caching clips it away.
2059
+ */
2060
+ attach(text, curves) {
2061
+ const paths = curves.map(toFabricPath);
2062
+ const union = paths.length === 1 ? paths[0] : toFabricPath({ data: curves.map((c) => c.data).join(" "), length: 0 });
2063
+ text.set({
2064
+ path: union,
2065
+ pathAlign: "center",
2066
+ pathSide: "left",
2067
+ // Each line is centred on its own path by the measuring hook below, so
2068
+ // there is no single offset left to apply.
2069
+ pathStartOffset: 0
2070
+ });
2071
+ this.patchLineMeasure(text, paths, union);
2072
+ }
2073
+ /**
2074
+ * Fabric lays every line of a text object along `this.path`, from one
2075
+ * `pathStartOffset` — so a multi-line run piles all its lines on top of each
2076
+ * other. Each line is measured against its own path instead, by swapping the
2077
+ * path in around fabric's own per-line measure pass.
2078
+ *
2079
+ * The paths all share the first one's `pathOffset`, which is the origin
2080
+ * fabric subtracts to place a glyph in the object — without that each line
2081
+ * would be re-centred on its own bounding box and land back in the same spot.
2082
+ *
2083
+ * `textAlign` is switched to centre for the pass, which is how fabric centres
2084
+ * a line on its path; the authored value is restored before anything else
2085
+ * reads it.
2086
+ */
2087
+ patchLineMeasure(text, paths, union) {
2088
+ this.unpatchLineMeasure(text);
2089
+ const original = text._measureLine;
2090
+ if (typeof original !== "function") return;
2091
+ for (const path of paths) path.pathOffset = union.pathOffset;
2092
+ const installed = function(lineIndex) {
2093
+ const path = paths[lineIndex];
2094
+ if (!path) return original.call(this, lineIndex);
2095
+ const previousPath = this.path;
2096
+ const previousAlign = this.textAlign;
2097
+ this.path = path;
2098
+ this.textAlign = "center";
2099
+ this.setPathInfo?.();
2100
+ try {
2101
+ return original.call(this, lineIndex);
2102
+ } finally {
2103
+ this.path = previousPath;
2104
+ this.textAlign = previousAlign;
2105
+ }
2106
+ };
2107
+ const ownedOriginal = Object.prototype.hasOwnProperty.call(text, "_measureLine");
2108
+ text._measureLine = installed;
2109
+ patches.set(text, { original, installed, ownedOriginal });
2110
+ }
2111
+ /** Restore fabric's own per-line measuring, if this manager replaced it. */
2112
+ unpatchLineMeasure(text) {
2113
+ const patch = patches.get(text);
2114
+ if (!patch) return;
2115
+ if (text._measureLine === patch.installed) {
2116
+ if (patch.ownedOriginal) text._measureLine = patch.original;
2117
+ else delete text._measureLine;
2118
+ }
2119
+ patches.delete(text);
2120
+ }
1703
2121
  detach(text, authoredWidth) {
2122
+ this.unpatchLineMeasure(text);
1704
2123
  text.set({ path: null, pathStartOffset: 0 });
1705
2124
  if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1706
2125
  }
1707
2126
  };
1708
2127
 
1709
2128
  // src/mask-presets/manager.ts
1710
- var import_fabric5 = require("fabric");
2129
+ var import_fabric7 = require("fabric");
1711
2130
 
1712
2131
  // src/mask-presets/shapes.ts
1713
2132
  var SHAPE_MASK_IDS = [
@@ -1974,13 +2393,13 @@ var MaskPresetManager = class {
1974
2393
  objectCaching: false
1975
2394
  };
1976
2395
  if (isShapeMaskId(id)) {
1977
- return new import_fabric5.Path(shapeMaskPathData(id), {
2396
+ return new import_fabric7.Path(shapeMaskPathData(id), {
1978
2397
  ...shared,
1979
2398
  scaleX: width / SHAPE_MASK_BOX,
1980
2399
  scaleY: height / SHAPE_MASK_BOX
1981
2400
  });
1982
2401
  }
1983
- return new import_fabric5.FabricImage(renderTextureMask(id), {
2402
+ return new import_fabric7.FabricImage(renderTextureMask(id), {
1984
2403
  ...shared,
1985
2404
  scaleX: width / TEXTURE_MASK_SIZE,
1986
2405
  scaleY: height / TEXTURE_MASK_SIZE
@@ -1989,7 +2408,7 @@ var MaskPresetManager = class {
1989
2408
  };
1990
2409
 
1991
2410
  // src/shadow.ts
1992
- var import_fabric6 = require("fabric");
2411
+ var import_fabric8 = require("fabric");
1993
2412
 
1994
2413
  // src/utils/color.ts
1995
2414
  var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
@@ -2027,7 +2446,7 @@ function applyLayerShadow(object, config) {
2027
2446
  }
2028
2447
  const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
2029
2448
  object.set({
2030
- shadow: new import_fabric6.Shadow({
2449
+ shadow: new import_fabric8.Shadow({
2031
2450
  color,
2032
2451
  blur: Math.max(0, next.blur),
2033
2452
  offsetX: next.offsetX,
@@ -2141,7 +2560,7 @@ var UnitConverter = class {
2141
2560
  };
2142
2561
 
2143
2562
  // src/serialization.ts
2144
- var import_fabric7 = require("fabric");
2563
+ var import_fabric9 = require("fabric");
2145
2564
  var VERSION = "2.0.0";
2146
2565
  function serializeEditor(editor) {
2147
2566
  return {
@@ -2177,7 +2596,7 @@ async function deserializeEditor(editor, state) {
2177
2596
  }
2178
2597
  const staged = await Promise.all(
2179
2598
  state.layers.map(async (serialized) => {
2180
- const fabricObject = (await import_fabric7.util.enlivenObjects([serialized.fabricObject]))[0];
2599
+ const fabricObject = (await import_fabric9.util.enlivenObjects([serialized.fabricObject]))[0];
2181
2600
  if (!fabricObject) {
2182
2601
  const source = serialized.fabricObject.src;
2183
2602
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -2188,7 +2607,7 @@ async function deserializeEditor(editor, state) {
2188
2607
  return { serialized, fabricObject };
2189
2608
  })
2190
2609
  );
2191
- const stagedBackground = state.backgroundImage ? (await import_fabric7.util.enlivenObjects([state.backgroundImage]))[0] : null;
2610
+ const stagedBackground = state.backgroundImage ? (await import_fabric9.util.enlivenObjects([state.backgroundImage]))[0] : null;
2192
2611
  if (state.backgroundImage && !stagedBackground) {
2193
2612
  const source = state.backgroundImage.src;
2194
2613
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -2231,11 +2650,16 @@ function restoreLayer(editor, serialized, fabricObject) {
2231
2650
  if (serialized.opacity !== 1) {
2232
2651
  editor.layers.setOpacity(layer.id, serialized.opacity);
2233
2652
  }
2653
+ const childData = serialized.children ?? serialized.meta?.groupChildren;
2654
+ if (layer.type === "group" && Array.isArray(childData)) {
2655
+ editor.layers.adoptGroupChildren(layer, childData);
2656
+ if (layer.meta.groupChildren) delete layer.meta.groupChildren;
2657
+ }
2234
2658
  return layer;
2235
2659
  }
2236
2660
 
2237
2661
  // src/export.ts
2238
- var import_fabric8 = require("fabric");
2662
+ var import_fabric10 = require("fabric");
2239
2663
 
2240
2664
  // src/displacement.ts
2241
2665
  var CHANNEL_INDEX = {
@@ -2329,7 +2753,7 @@ async function exportPNG(canvas, options = {}) {
2329
2753
  }
2330
2754
  async function exportIsolatedPNG(source, objects, options = {}) {
2331
2755
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
2332
- const canvas = new import_fabric8.StaticCanvas(element, {
2756
+ const canvas = new import_fabric10.StaticCanvas(element, {
2333
2757
  width: options.width ?? source.getWidth(),
2334
2758
  height: options.height ?? source.getHeight(),
2335
2759
  backgroundColor: options.backgroundColor || void 0
@@ -2359,7 +2783,7 @@ async function exportPrintArea(source, area, options = {}) {
2359
2783
  throw new Error("Print area does not overlap the canvas");
2360
2784
  }
2361
2785
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
2362
- const canvas = new import_fabric8.StaticCanvas(element, { width, height });
2786
+ const canvas = new import_fabric10.StaticCanvas(element, { width, height });
2363
2787
  try {
2364
2788
  const clones = await Promise.all(source.getObjects().map((object) => object.clone()));
2365
2789
  if (clones.length) canvas.add(...clones);
@@ -2799,7 +3223,7 @@ var ProjectManager = class {
2799
3223
  };
2800
3224
 
2801
3225
  // src/mask.ts
2802
- var import_fabric9 = require("fabric");
3226
+ var import_fabric11 = require("fabric");
2803
3227
  var MaskRefinementError = class extends Error {
2804
3228
  constructor(code, message, cause) {
2805
3229
  super(message);
@@ -2831,7 +3255,7 @@ var MaskController = class {
2831
3255
  throw new Error("Mask dimensions must be positive integers");
2832
3256
  }
2833
3257
  const backing = this.makeCanvas(width, height);
2834
- const image = new import_fabric9.FabricImage(backing, {
3258
+ const image = new import_fabric11.FabricImage(backing, {
2835
3259
  left: 0,
2836
3260
  top: 0,
2837
3261
  originX: "left",
@@ -3057,10 +3481,10 @@ var MaskController = class {
3057
3481
  };
3058
3482
 
3059
3483
  // src/masks/manager.ts
3060
- var import_fabric16 = require("fabric");
3484
+ var import_fabric18 = require("fabric");
3061
3485
 
3062
3486
  // src/masks/compose.ts
3063
- var import_fabric10 = require("fabric");
3487
+ var import_fabric12 = require("fabric");
3064
3488
  var MODE_OPERATION = {
3065
3489
  add: "source-over",
3066
3490
  subtract: "destination-out",
@@ -3070,7 +3494,7 @@ function neutralize(child) {
3070
3494
  child.set({ opacity: 0, globalCompositeOperation: "source-over" });
3071
3495
  }
3072
3496
  function baseRect(box) {
3073
- return new import_fabric10.Rect({
3497
+ return new import_fabric12.Rect({
3074
3498
  left: box.left,
3075
3499
  top: box.top,
3076
3500
  width: Math.max(1, box.width),
@@ -3097,7 +3521,7 @@ function composeMaskGroup(children, entries, options) {
3097
3521
  });
3098
3522
  const first = entries.find((entry) => entry.visible);
3099
3523
  const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
3100
- return new import_fabric10.Group(withBase, {
3524
+ return new import_fabric12.Group(withBase, {
3101
3525
  absolutePositioned: options.absolute,
3102
3526
  // Cached, so the children's compositing operations resolve against each
3103
3527
  // other instead of against the page underneath the mask.
@@ -3111,15 +3535,15 @@ function needsAbsoluteSpace(entries) {
3111
3535
  }
3112
3536
 
3113
3537
  // src/masks/edit.ts
3114
- var import_fabric12 = require("fabric");
3538
+ var import_fabric14 = require("fabric");
3115
3539
 
3116
3540
  // src/masks/space.ts
3117
- var import_fabric11 = require("fabric");
3541
+ var import_fabric13 = require("fabric");
3118
3542
  function matrixOf(object) {
3119
3543
  return object.calcTransformMatrix();
3120
3544
  }
3121
3545
  function applyMatrix(object, matrix) {
3122
- const decomposed = import_fabric11.util.qrDecompose(matrix);
3546
+ const decomposed = import_fabric13.util.qrDecompose(matrix);
3123
3547
  object.set({
3124
3548
  flipX: false,
3125
3549
  flipY: false,
@@ -3136,19 +3560,19 @@ function applyMatrix(object, matrix) {
3136
3560
  object.setCoords();
3137
3561
  }
3138
3562
  function toCanvasSpace(object, host) {
3139
- applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
3563
+ applyMatrix(object, import_fabric13.util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
3140
3564
  }
3141
3565
  function toHostSpace(object, host) {
3142
3566
  applyMatrix(
3143
3567
  object,
3144
- import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object))
3568
+ import_fabric13.util.multiplyTransformMatrices(import_fabric13.util.invertTransform(matrixOf(host)), matrixOf(object))
3145
3569
  );
3146
3570
  }
3147
3571
  function relativeMatrix(object, host) {
3148
- return import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object));
3572
+ return import_fabric13.util.multiplyTransformMatrices(import_fabric13.util.invertTransform(matrixOf(host)), matrixOf(object));
3149
3573
  }
3150
3574
  function applyRelativeMatrix(object, host, rel) {
3151
- applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), rel));
3575
+ applyMatrix(object, import_fabric13.util.multiplyTransformMatrices(matrixOf(host), rel));
3152
3576
  }
3153
3577
  function asObject(clip) {
3154
3578
  return clip;
@@ -3261,8 +3685,8 @@ var MaskEditController = class {
3261
3685
  if (!this.handle || !this.child || !this.group) return;
3262
3686
  applyMatrix(
3263
3687
  this.child,
3264
- import_fabric12.util.multiplyTransformMatrices(
3265
- import_fabric12.util.invertTransform(matrixOf(this.group)),
3688
+ import_fabric14.util.multiplyTransformMatrices(
3689
+ import_fabric14.util.invertTransform(matrixOf(this.group)),
3266
3690
  matrixOf(this.handle)
3267
3691
  )
3268
3692
  );
@@ -3273,15 +3697,15 @@ var MaskEditController = class {
3273
3697
  };
3274
3698
 
3275
3699
  // src/masks/store.ts
3276
- var import_fabric15 = require("fabric");
3700
+ var import_fabric17 = require("fabric");
3277
3701
 
3278
3702
  // src/masks/host.ts
3279
- var import_fabric13 = require("fabric");
3703
+ var import_fabric15 = require("fabric");
3280
3704
  function findCanvasHost(layers) {
3281
3705
  return layers.getAll().find((layer) => layer.meta.canvasMask);
3282
3706
  }
3283
3707
  function createCanvasHost(canvas, layers) {
3284
- const rect = new import_fabric13.Rect({
3708
+ const rect = new import_fabric15.Rect({
3285
3709
  left: 0,
3286
3710
  top: 0,
3287
3711
  width: canvas.getWidth(),
@@ -3330,9 +3754,9 @@ function hostBoxOf(canvas, host, absolute) {
3330
3754
  }
3331
3755
 
3332
3756
  // src/masks/install.ts
3333
- var import_fabric14 = require("fabric");
3757
+ var import_fabric16 = require("fabric");
3334
3758
  function convertSpace(host, sources, absolute) {
3335
- const wasAbsolute = host.clipPath instanceof import_fabric14.Group ? host.clipPath.absolutePositioned : absolute;
3759
+ const wasAbsolute = host.clipPath instanceof import_fabric16.Group ? host.clipPath.absolutePositioned : absolute;
3336
3760
  if (absolute === wasAbsolute) return;
3337
3761
  for (const source of sources) {
3338
3762
  if (absolute) toCanvasSpace(source, host);
@@ -3424,7 +3848,7 @@ var MaskStackStore = class {
3424
3848
  const clip = host.clipPath;
3425
3849
  if (!clip) return [];
3426
3850
  const entries = this.list(target);
3427
- if (entries.length === 0 || !(clip instanceof import_fabric15.Group)) return [asObject(clip)];
3851
+ if (entries.length === 0 || !(clip instanceof import_fabric17.Group)) return [asObject(clip)];
3428
3852
  const children = unwrapGroup(clip);
3429
3853
  const extra = children.length - entries.length;
3430
3854
  return extra > 0 ? children.slice(extra) : children;
@@ -3524,7 +3948,7 @@ var LayerMaskManager = class extends MaskStackStore {
3524
3948
  if (index === -1) return false;
3525
3949
  this.endEdit(false);
3526
3950
  this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3527
- const group = host.clipPath instanceof import_fabric16.Group ? host.clipPath : null;
3951
+ const group = host.clipPath instanceof import_fabric18.Group ? host.clipPath : null;
3528
3952
  if (!group) return false;
3529
3953
  const children = group.getObjects();
3530
3954
  const child = children[children.length - entries.length + index];
@@ -3830,7 +4254,7 @@ var CanvasEditor = class {
3830
4254
  const widthPx = this.units.toPixels(config.width);
3831
4255
  const heightPx = this.units.toPixels(config.height);
3832
4256
  this.designBackground = config.backgroundColor ?? "#ffffff";
3833
- this.canvas = new import_fabric17.Canvas(canvasElement, {
4257
+ this.canvas = new import_fabric19.Canvas(canvasElement, {
3834
4258
  width: widthPx,
3835
4259
  height: heightPx,
3836
4260
  backgroundColor: this.designBackground,
@@ -3864,7 +4288,7 @@ var CanvasEditor = class {
3864
4288
  // ─── Layer Operations ────────────────────────────────
3865
4289
  async addImage(url, options) {
3866
4290
  try {
3867
- const img = await import_fabric17.FabricImage.fromURL(
4291
+ const img = await import_fabric19.FabricImage.fromURL(
3868
4292
  url,
3869
4293
  {},
3870
4294
  { originX: "left", originY: "top", ...options }
@@ -3890,7 +4314,7 @@ var CanvasEditor = class {
3890
4314
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
3891
4315
  const previous = layer.fabricObject;
3892
4316
  try {
3893
- const replacement = await import_fabric17.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
4317
+ const replacement = await import_fabric19.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
3894
4318
  replacement.set({
3895
4319
  left: previous.left,
3896
4320
  top: previous.top,
@@ -3919,7 +4343,7 @@ var CanvasEditor = class {
3919
4343
  }
3920
4344
  }
3921
4345
  addText(text, options) {
3922
- const textbox = new import_fabric17.Textbox(text, {
4346
+ const textbox = new import_fabric19.Textbox(text, {
3923
4347
  fontSize: 32,
3924
4348
  fontFamily: "Arial",
3925
4349
  fill: "#000000",
@@ -3960,20 +4384,30 @@ var CanvasEditor = class {
3960
4384
  return value === void 0 ? token : escapeXml(value);
3961
4385
  })
3962
4386
  );
3963
- const { objects, options } = await (0, import_fabric17.loadSVGFromString)(resolved);
3964
- const validObjects = objects.filter((object) => object !== null);
3965
- if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
3966
- const group = import_fabric17.util.groupSVGElements(validObjects, options);
3967
- group.set({
3968
- left: this.canvas.getWidth() / 2,
3969
- top: this.canvas.getHeight() / 2,
3970
- originX: "center",
3971
- originY: "center"
4387
+ const specs = resolved.includes("<textPath") ? [textPathSpec(template, values)] : await explodeTemplateSvg(template, resolved, values);
4388
+ if (specs.length === 0) throw new Error("Template SVG contains no renderable objects");
4389
+ return this.history.transaction(async () => {
4390
+ const created = specs.map((spec) => {
4391
+ const layer2 = this.layers.add(spec.type, spec.object, spec.name);
4392
+ if (spec.curve) this.curves.apply(layer2.id, spec.curve, false);
4393
+ return layer2;
4394
+ });
4395
+ const layer = created.length === 1 ? created[0] : await this.groupLayers(
4396
+ created.map((l) => l.id),
4397
+ template.name
4398
+ ) ?? created[0];
4399
+ layer.fabricObject.set({
4400
+ left: this.canvas.getWidth() / 2,
4401
+ top: this.canvas.getHeight() / 2,
4402
+ originX: "center",
4403
+ originY: "center"
4404
+ });
4405
+ layer.fabricObject.setCoords();
4406
+ this.layers.select(layer.id);
4407
+ this.canvas.requestRenderAll();
4408
+ this.history.save();
4409
+ return layer;
3972
4410
  });
3973
- const layer = this.layers.add("template", group, template.name);
3974
- this.layers.select(layer.id);
3975
- this.history.save();
3976
- return layer;
3977
4411
  }
3978
4412
  removeLayer(id) {
3979
4413
  if (this.crop.activeLayerId() === id) this.crop.cancel();
@@ -3985,6 +4419,10 @@ var CanvasEditor = class {
3985
4419
  selectLayer(id) {
3986
4420
  this.layers.select(id);
3987
4421
  }
4422
+ /** Select several layers at once — what a panel's shift/⌘ click drives. */
4423
+ selectLayers(ids) {
4424
+ this.layers.selectMany(ids);
4425
+ }
3988
4426
  getSelectedLayer() {
3989
4427
  const active = this.canvas.getActiveObject();
3990
4428
  if (!active) return null;
@@ -4036,10 +4474,10 @@ var CanvasEditor = class {
4036
4474
  const next = { ...previous, ...adjustments };
4037
4475
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
4038
4476
  image.filters = [
4039
- new import_fabric17.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
4040
- new import_fabric17.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
4041
- new import_fabric17.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
4042
- new import_fabric17.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
4477
+ new import_fabric19.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
4478
+ new import_fabric19.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
4479
+ new import_fabric19.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
4480
+ new import_fabric19.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
4043
4481
  ];
4044
4482
  layer.meta.imageAdjustments = next;
4045
4483
  image.applyFilters();
@@ -4047,42 +4485,55 @@ var CanvasEditor = class {
4047
4485
  this.history.save();
4048
4486
  return true;
4049
4487
  }
4050
- /** Combine two or more layers into a single editable group layer. */
4488
+ /**
4489
+ * Combine two or more layers into one group layer. Picking a group among them
4490
+ * **absorbs** it — its children join the new group and the old group record is
4491
+ * dropped — because groups are exactly one level deep.
4492
+ */
4051
4493
  async groupLayers(ids, name = "Group") {
4052
4494
  const uniqueIds = [...new Set(ids)];
4053
- const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
4054
- if (children.length < 2 || children.length !== uniqueIds.length) return null;
4495
+ const picked = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0 && layer.parentId === null);
4496
+ if (picked.length < 2 || picked.length !== uniqueIds.length) return null;
4497
+ const stack = this.layers.getAll();
4498
+ const topIndex = Math.max(...picked.map((layer) => stack.indexOf(layer)));
4499
+ const insertIndex = stack.slice(0, topIndex).filter((layer) => !picked.includes(layer)).length;
4055
4500
  return this.history.transaction(() => {
4056
- const childData = children.map((layer) => structuredClone(layer.toData()));
4057
- const objects = children.map((layer) => layer.fabricObject);
4058
- for (const layer of children) this.layers.remove(layer.id);
4059
- const group = new import_fabric17.Group(objects);
4501
+ const children = [];
4502
+ const objects = [];
4503
+ for (const layer of picked) {
4504
+ if (layer.type === "group" && layer.children.length > 0) {
4505
+ const inner = layer.fabricObject.removeAll();
4506
+ for (const [index, object] of inner.entries()) {
4507
+ object.setCoords();
4508
+ const child = layer.children[index];
4509
+ if (!child) continue;
4510
+ children.push(child);
4511
+ objects.push(object);
4512
+ }
4513
+ layer.children = [];
4514
+ this.layers.detach(layer.id);
4515
+ } else {
4516
+ children.push(layer);
4517
+ objects.push(layer.fabricObject);
4518
+ this.layers.detach(layer.id);
4519
+ }
4520
+ }
4521
+ const group = new import_fabric19.Group(objects);
4060
4522
  const grouped = this.layers.add("group", group, name);
4061
- grouped.meta.groupChildren = childData;
4523
+ for (const child of children) child.parentId = grouped.id;
4524
+ grouped.children = children;
4525
+ this.layers.reorder(grouped.id, insertIndex);
4062
4526
  this.layers.select(grouped.id);
4063
4527
  this.history.save();
4064
4528
  return grouped;
4065
4529
  });
4066
4530
  }
4067
- /** Restore a group created by groupLayers back to its original layer records. */
4531
+ /** Split a group back into the layers it holds, in place. */
4068
4532
  async ungroupLayer(id) {
4069
4533
  const grouped = this.layers.get(id);
4070
- const childData = grouped?.meta.groupChildren;
4071
- if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
4072
- const group = grouped.fabricObject;
4534
+ if (!grouped || grouped.type !== "group" || grouped.children.length === 0) return [];
4073
4535
  return this.history.transaction(() => {
4074
- const objects = group.removeAll();
4075
- this.layers.remove(id);
4076
- const restored = objects.map((object, index) => {
4077
- object.setCoords();
4078
- const data = childData[index];
4079
- const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
4080
- if (data?.meta) layer.meta = structuredClone(data.meta);
4081
- if (data && !data.visible) this.layers.setVisibility(layer.id, false);
4082
- if (data?.locked) this.layers.setLocked(layer.id, true);
4083
- if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
4084
- return layer;
4085
- });
4536
+ const restored = this.layers.explode(grouped);
4086
4537
  this.history.save();
4087
4538
  this.layers.select(restored[0]?.id ?? null);
4088
4539
  return restored;
@@ -4130,7 +4581,7 @@ var CanvasEditor = class {
4130
4581
  const layer = this.layers.get(id);
4131
4582
  if (!layer) throw new Error(`Layer not found: ${id}`);
4132
4583
  try {
4133
- if (options.resolution === "source" && layer.fabricObject instanceof import_fabric17.FabricImage) {
4584
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric19.FabricImage) {
4134
4585
  const image = await layer.fabricObject.clone();
4135
4586
  image.set({
4136
4587
  left: 0,
@@ -4406,7 +4857,7 @@ var CanvasEditor = class {
4406
4857
  return;
4407
4858
  }
4408
4859
  try {
4409
- const image = await import_fabric17.FabricImage.fromURL(
4860
+ const image = await import_fabric19.FabricImage.fromURL(
4410
4861
  url,
4411
4862
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
4412
4863
  { originX: "left", originY: "top" }
@@ -4830,6 +5281,7 @@ var AnnotationOverlay = class {
4830
5281
  applyLayerShadow,
4831
5282
  applyObjectSelectionStyle,
4832
5283
  applySelectionStyle,
5284
+ buildCurveLinePaths,
4833
5285
  buildCurvePathData,
4834
5286
  clamp,
4835
5287
  clearTextureMaskCache,
@@ -4841,6 +5293,7 @@ var AnnotationOverlay = class {
4841
5293
  displaceRgba,
4842
5294
  drawTiles,
4843
5295
  escapeXml,
5296
+ explodeTemplateSvg,
4844
5297
  exportDataURL,
4845
5298
  exportMockup,
4846
5299
  exportPNG,
@@ -4853,6 +5306,7 @@ var AnnotationOverlay = class {
4853
5306
  isShapeMaskId,
4854
5307
  isTextureMaskId,
4855
5308
  needsAbsoluteSpace,
5309
+ normalizeTextCurve,
4856
5310
  readLayerShadow,
4857
5311
  renderTextureMask,
4858
5312
  resetTransform,
@@ -4861,6 +5315,8 @@ var AnnotationOverlay = class {
4861
5315
  sanitizeSvg,
4862
5316
  serializeEditor,
4863
5317
  shapeMaskPathData,
5318
+ textPathCurve,
5319
+ textPathSpec,
4864
5320
  toCanvasSpace,
4865
5321
  toHostSpace,
4866
5322
  unwrapGroup