@overtone-art/canvas-editor-core 0.5.1 → 0.6.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.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
+ };
1861
+ }
1862
+ function bendsBaseline(config) {
1863
+ return config.shape === "arc" ? Math.abs(config.arc) >= MIN_ARC : config.wave > 0;
1568
1864
  }
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;
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,32 +2029,114 @@ 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;
1694
2043
  if (!curve) return false;
1695
2044
  return this.apply(layerId, curve, save);
1696
2045
  }
1697
- /** Rebuild every curved layer — used after a state restore. */
2046
+ /**
2047
+ * Rebuild every curved layer — used after a state restore.
2048
+ *
2049
+ * `getAll` is the canvas stack, so this walks each group's children too:
2050
+ * curved text inside a group would otherwise keep the path geometry it was
2051
+ * restored with, and stop tracking its own text, font and size.
2052
+ */
1698
2053
  refreshAll() {
1699
- for (const layer of this.layers.getAll()) {
1700
- if (layer.meta.curve) this.refresh(layer.id);
2054
+ const visit = (layers) => {
2055
+ for (const layer of layers) {
2056
+ if (layer.meta.curve) this.refresh(layer.id);
2057
+ if (layer.children.length > 0) visit(layer.children);
2058
+ }
2059
+ };
2060
+ visit(this.layers.getAll());
2061
+ }
2062
+ /**
2063
+ * Puts every line's path on the object as one shape, and hooks per-line
2064
+ * measuring so each line is laid out on its own.
2065
+ *
2066
+ * The object's own path is the union of the lines: fabric sizes a curved text
2067
+ * object from it, and a single line's path would leave the rest of the block
2068
+ * outside the layer's box — where object caching clips it away.
2069
+ */
2070
+ attach(text, curves) {
2071
+ const paths = curves.map(toFabricPath);
2072
+ const union = paths.length === 1 ? paths[0] : toFabricPath({ data: curves.map((c) => c.data).join(" "), length: 0 });
2073
+ text.set({
2074
+ path: union,
2075
+ pathAlign: "center",
2076
+ pathSide: "left",
2077
+ // Each line is centred on its own path by the measuring hook below, so
2078
+ // there is no single offset left to apply.
2079
+ pathStartOffset: 0
2080
+ });
2081
+ this.patchLineMeasure(text, paths, union);
2082
+ }
2083
+ /**
2084
+ * Fabric lays every line of a text object along `this.path`, from one
2085
+ * `pathStartOffset` — so a multi-line run piles all its lines on top of each
2086
+ * other. Each line is measured against its own path instead, by swapping the
2087
+ * path in around fabric's own per-line measure pass.
2088
+ *
2089
+ * The paths all share the first one's `pathOffset`, which is the origin
2090
+ * fabric subtracts to place a glyph in the object — without that each line
2091
+ * would be re-centred on its own bounding box and land back in the same spot.
2092
+ *
2093
+ * `textAlign` is switched to centre for the pass, which is how fabric centres
2094
+ * a line on its path; the authored value is restored before anything else
2095
+ * reads it.
2096
+ */
2097
+ patchLineMeasure(text, paths, union) {
2098
+ this.unpatchLineMeasure(text);
2099
+ const original = text._measureLine;
2100
+ if (typeof original !== "function") return;
2101
+ for (const path of paths) path.pathOffset = union.pathOffset;
2102
+ const installed = function(lineIndex) {
2103
+ const path = paths[lineIndex];
2104
+ if (!path) return original.call(this, lineIndex);
2105
+ const previousPath = this.path;
2106
+ const previousAlign = this.textAlign;
2107
+ this.path = path;
2108
+ this.textAlign = "center";
2109
+ this.setPathInfo?.();
2110
+ try {
2111
+ return original.call(this, lineIndex);
2112
+ } finally {
2113
+ this.path = previousPath;
2114
+ this.textAlign = previousAlign;
2115
+ }
2116
+ };
2117
+ const ownedOriginal = Object.prototype.hasOwnProperty.call(text, "_measureLine");
2118
+ text._measureLine = installed;
2119
+ patches.set(text, { original, installed, ownedOriginal });
2120
+ }
2121
+ /** Restore fabric's own per-line measuring, if this manager replaced it. */
2122
+ unpatchLineMeasure(text) {
2123
+ const patch = patches.get(text);
2124
+ if (!patch) return;
2125
+ if (text._measureLine === patch.installed) {
2126
+ if (patch.ownedOriginal) text._measureLine = patch.original;
2127
+ else delete text._measureLine;
1701
2128
  }
2129
+ patches.delete(text);
1702
2130
  }
1703
2131
  detach(text, authoredWidth) {
2132
+ this.unpatchLineMeasure(text);
1704
2133
  text.set({ path: null, pathStartOffset: 0 });
1705
2134
  if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1706
2135
  }
1707
2136
  };
1708
2137
 
1709
2138
  // src/mask-presets/manager.ts
1710
- var import_fabric5 = require("fabric");
2139
+ var import_fabric7 = require("fabric");
1711
2140
 
1712
2141
  // src/mask-presets/shapes.ts
1713
2142
  var SHAPE_MASK_IDS = [
@@ -1974,13 +2403,13 @@ var MaskPresetManager = class {
1974
2403
  objectCaching: false
1975
2404
  };
1976
2405
  if (isShapeMaskId(id)) {
1977
- return new import_fabric5.Path(shapeMaskPathData(id), {
2406
+ return new import_fabric7.Path(shapeMaskPathData(id), {
1978
2407
  ...shared,
1979
2408
  scaleX: width / SHAPE_MASK_BOX,
1980
2409
  scaleY: height / SHAPE_MASK_BOX
1981
2410
  });
1982
2411
  }
1983
- return new import_fabric5.FabricImage(renderTextureMask(id), {
2412
+ return new import_fabric7.FabricImage(renderTextureMask(id), {
1984
2413
  ...shared,
1985
2414
  scaleX: width / TEXTURE_MASK_SIZE,
1986
2415
  scaleY: height / TEXTURE_MASK_SIZE
@@ -1989,7 +2418,7 @@ var MaskPresetManager = class {
1989
2418
  };
1990
2419
 
1991
2420
  // src/shadow.ts
1992
- var import_fabric6 = require("fabric");
2421
+ var import_fabric8 = require("fabric");
1993
2422
 
1994
2423
  // src/utils/color.ts
1995
2424
  var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
@@ -2027,7 +2456,7 @@ function applyLayerShadow(object, config) {
2027
2456
  }
2028
2457
  const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
2029
2458
  object.set({
2030
- shadow: new import_fabric6.Shadow({
2459
+ shadow: new import_fabric8.Shadow({
2031
2460
  color,
2032
2461
  blur: Math.max(0, next.blur),
2033
2462
  offsetX: next.offsetX,
@@ -2141,7 +2570,7 @@ var UnitConverter = class {
2141
2570
  };
2142
2571
 
2143
2572
  // src/serialization.ts
2144
- var import_fabric7 = require("fabric");
2573
+ var import_fabric9 = require("fabric");
2145
2574
  var VERSION = "2.0.0";
2146
2575
  function serializeEditor(editor) {
2147
2576
  return {
@@ -2177,7 +2606,7 @@ async function deserializeEditor(editor, state) {
2177
2606
  }
2178
2607
  const staged = await Promise.all(
2179
2608
  state.layers.map(async (serialized) => {
2180
- const fabricObject = (await import_fabric7.util.enlivenObjects([serialized.fabricObject]))[0];
2609
+ const fabricObject = (await import_fabric9.util.enlivenObjects([serialized.fabricObject]))[0];
2181
2610
  if (!fabricObject) {
2182
2611
  const source = serialized.fabricObject.src;
2183
2612
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -2188,7 +2617,7 @@ async function deserializeEditor(editor, state) {
2188
2617
  return { serialized, fabricObject };
2189
2618
  })
2190
2619
  );
2191
- const stagedBackground = state.backgroundImage ? (await import_fabric7.util.enlivenObjects([state.backgroundImage]))[0] : null;
2620
+ const stagedBackground = state.backgroundImage ? (await import_fabric9.util.enlivenObjects([state.backgroundImage]))[0] : null;
2192
2621
  if (state.backgroundImage && !stagedBackground) {
2193
2622
  const source = state.backgroundImage.src;
2194
2623
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -2231,11 +2660,16 @@ function restoreLayer(editor, serialized, fabricObject) {
2231
2660
  if (serialized.opacity !== 1) {
2232
2661
  editor.layers.setOpacity(layer.id, serialized.opacity);
2233
2662
  }
2663
+ const childData = serialized.children ?? serialized.meta?.groupChildren;
2664
+ if (layer.type === "group" && Array.isArray(childData)) {
2665
+ editor.layers.adoptGroupChildren(layer, childData);
2666
+ if (layer.meta.groupChildren) delete layer.meta.groupChildren;
2667
+ }
2234
2668
  return layer;
2235
2669
  }
2236
2670
 
2237
2671
  // src/export.ts
2238
- var import_fabric8 = require("fabric");
2672
+ var import_fabric10 = require("fabric");
2239
2673
 
2240
2674
  // src/displacement.ts
2241
2675
  var CHANNEL_INDEX = {
@@ -2329,7 +2763,7 @@ async function exportPNG(canvas, options = {}) {
2329
2763
  }
2330
2764
  async function exportIsolatedPNG(source, objects, options = {}) {
2331
2765
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
2332
- const canvas = new import_fabric8.StaticCanvas(element, {
2766
+ const canvas = new import_fabric10.StaticCanvas(element, {
2333
2767
  width: options.width ?? source.getWidth(),
2334
2768
  height: options.height ?? source.getHeight(),
2335
2769
  backgroundColor: options.backgroundColor || void 0
@@ -2359,7 +2793,7 @@ async function exportPrintArea(source, area, options = {}) {
2359
2793
  throw new Error("Print area does not overlap the canvas");
2360
2794
  }
2361
2795
  const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
2362
- const canvas = new import_fabric8.StaticCanvas(element, { width, height });
2796
+ const canvas = new import_fabric10.StaticCanvas(element, { width, height });
2363
2797
  try {
2364
2798
  const clones = await Promise.all(source.getObjects().map((object) => object.clone()));
2365
2799
  if (clones.length) canvas.add(...clones);
@@ -2799,7 +3233,7 @@ var ProjectManager = class {
2799
3233
  };
2800
3234
 
2801
3235
  // src/mask.ts
2802
- var import_fabric9 = require("fabric");
3236
+ var import_fabric11 = require("fabric");
2803
3237
  var MaskRefinementError = class extends Error {
2804
3238
  constructor(code, message, cause) {
2805
3239
  super(message);
@@ -2831,7 +3265,7 @@ var MaskController = class {
2831
3265
  throw new Error("Mask dimensions must be positive integers");
2832
3266
  }
2833
3267
  const backing = this.makeCanvas(width, height);
2834
- const image = new import_fabric9.FabricImage(backing, {
3268
+ const image = new import_fabric11.FabricImage(backing, {
2835
3269
  left: 0,
2836
3270
  top: 0,
2837
3271
  originX: "left",
@@ -3057,10 +3491,10 @@ var MaskController = class {
3057
3491
  };
3058
3492
 
3059
3493
  // src/masks/manager.ts
3060
- var import_fabric16 = require("fabric");
3494
+ var import_fabric18 = require("fabric");
3061
3495
 
3062
3496
  // src/masks/compose.ts
3063
- var import_fabric10 = require("fabric");
3497
+ var import_fabric12 = require("fabric");
3064
3498
  var MODE_OPERATION = {
3065
3499
  add: "source-over",
3066
3500
  subtract: "destination-out",
@@ -3070,7 +3504,7 @@ function neutralize(child) {
3070
3504
  child.set({ opacity: 0, globalCompositeOperation: "source-over" });
3071
3505
  }
3072
3506
  function baseRect(box) {
3073
- return new import_fabric10.Rect({
3507
+ return new import_fabric12.Rect({
3074
3508
  left: box.left,
3075
3509
  top: box.top,
3076
3510
  width: Math.max(1, box.width),
@@ -3097,7 +3531,7 @@ function composeMaskGroup(children, entries, options) {
3097
3531
  });
3098
3532
  const first = entries.find((entry) => entry.visible);
3099
3533
  const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
3100
- return new import_fabric10.Group(withBase, {
3534
+ return new import_fabric12.Group(withBase, {
3101
3535
  absolutePositioned: options.absolute,
3102
3536
  // Cached, so the children's compositing operations resolve against each
3103
3537
  // other instead of against the page underneath the mask.
@@ -3111,15 +3545,15 @@ function needsAbsoluteSpace(entries) {
3111
3545
  }
3112
3546
 
3113
3547
  // src/masks/edit.ts
3114
- var import_fabric12 = require("fabric");
3548
+ var import_fabric14 = require("fabric");
3115
3549
 
3116
3550
  // src/masks/space.ts
3117
- var import_fabric11 = require("fabric");
3551
+ var import_fabric13 = require("fabric");
3118
3552
  function matrixOf(object) {
3119
3553
  return object.calcTransformMatrix();
3120
3554
  }
3121
3555
  function applyMatrix(object, matrix) {
3122
- const decomposed = import_fabric11.util.qrDecompose(matrix);
3556
+ const decomposed = import_fabric13.util.qrDecompose(matrix);
3123
3557
  object.set({
3124
3558
  flipX: false,
3125
3559
  flipY: false,
@@ -3136,19 +3570,19 @@ function applyMatrix(object, matrix) {
3136
3570
  object.setCoords();
3137
3571
  }
3138
3572
  function toCanvasSpace(object, host) {
3139
- applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
3573
+ applyMatrix(object, import_fabric13.util.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
3140
3574
  }
3141
3575
  function toHostSpace(object, host) {
3142
3576
  applyMatrix(
3143
3577
  object,
3144
- import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object))
3578
+ import_fabric13.util.multiplyTransformMatrices(import_fabric13.util.invertTransform(matrixOf(host)), matrixOf(object))
3145
3579
  );
3146
3580
  }
3147
3581
  function relativeMatrix(object, host) {
3148
- return import_fabric11.util.multiplyTransformMatrices(import_fabric11.util.invertTransform(matrixOf(host)), matrixOf(object));
3582
+ return import_fabric13.util.multiplyTransformMatrices(import_fabric13.util.invertTransform(matrixOf(host)), matrixOf(object));
3149
3583
  }
3150
3584
  function applyRelativeMatrix(object, host, rel) {
3151
- applyMatrix(object, import_fabric11.util.multiplyTransformMatrices(matrixOf(host), rel));
3585
+ applyMatrix(object, import_fabric13.util.multiplyTransformMatrices(matrixOf(host), rel));
3152
3586
  }
3153
3587
  function asObject(clip) {
3154
3588
  return clip;
@@ -3261,8 +3695,8 @@ var MaskEditController = class {
3261
3695
  if (!this.handle || !this.child || !this.group) return;
3262
3696
  applyMatrix(
3263
3697
  this.child,
3264
- import_fabric12.util.multiplyTransformMatrices(
3265
- import_fabric12.util.invertTransform(matrixOf(this.group)),
3698
+ import_fabric14.util.multiplyTransformMatrices(
3699
+ import_fabric14.util.invertTransform(matrixOf(this.group)),
3266
3700
  matrixOf(this.handle)
3267
3701
  )
3268
3702
  );
@@ -3273,15 +3707,15 @@ var MaskEditController = class {
3273
3707
  };
3274
3708
 
3275
3709
  // src/masks/store.ts
3276
- var import_fabric15 = require("fabric");
3710
+ var import_fabric17 = require("fabric");
3277
3711
 
3278
3712
  // src/masks/host.ts
3279
- var import_fabric13 = require("fabric");
3713
+ var import_fabric15 = require("fabric");
3280
3714
  function findCanvasHost(layers) {
3281
3715
  return layers.getAll().find((layer) => layer.meta.canvasMask);
3282
3716
  }
3283
3717
  function createCanvasHost(canvas, layers) {
3284
- const rect = new import_fabric13.Rect({
3718
+ const rect = new import_fabric15.Rect({
3285
3719
  left: 0,
3286
3720
  top: 0,
3287
3721
  width: canvas.getWidth(),
@@ -3330,9 +3764,9 @@ function hostBoxOf(canvas, host, absolute) {
3330
3764
  }
3331
3765
 
3332
3766
  // src/masks/install.ts
3333
- var import_fabric14 = require("fabric");
3767
+ var import_fabric16 = require("fabric");
3334
3768
  function convertSpace(host, sources, absolute) {
3335
- const wasAbsolute = host.clipPath instanceof import_fabric14.Group ? host.clipPath.absolutePositioned : absolute;
3769
+ const wasAbsolute = host.clipPath instanceof import_fabric16.Group ? host.clipPath.absolutePositioned : absolute;
3336
3770
  if (absolute === wasAbsolute) return;
3337
3771
  for (const source of sources) {
3338
3772
  if (absolute) toCanvasSpace(source, host);
@@ -3424,7 +3858,7 @@ var MaskStackStore = class {
3424
3858
  const clip = host.clipPath;
3425
3859
  if (!clip) return [];
3426
3860
  const entries = this.list(target);
3427
- if (entries.length === 0 || !(clip instanceof import_fabric15.Group)) return [asObject(clip)];
3861
+ if (entries.length === 0 || !(clip instanceof import_fabric17.Group)) return [asObject(clip)];
3428
3862
  const children = unwrapGroup(clip);
3429
3863
  const extra = children.length - entries.length;
3430
3864
  return extra > 0 ? children.slice(extra) : children;
@@ -3524,7 +3958,7 @@ var LayerMaskManager = class extends MaskStackStore {
3524
3958
  if (index === -1) return false;
3525
3959
  this.endEdit(false);
3526
3960
  this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3527
- const group = host.clipPath instanceof import_fabric16.Group ? host.clipPath : null;
3961
+ const group = host.clipPath instanceof import_fabric18.Group ? host.clipPath : null;
3528
3962
  if (!group) return false;
3529
3963
  const children = group.getObjects();
3530
3964
  const child = children[children.length - entries.length + index];
@@ -3830,7 +4264,7 @@ var CanvasEditor = class {
3830
4264
  const widthPx = this.units.toPixels(config.width);
3831
4265
  const heightPx = this.units.toPixels(config.height);
3832
4266
  this.designBackground = config.backgroundColor ?? "#ffffff";
3833
- this.canvas = new import_fabric17.Canvas(canvasElement, {
4267
+ this.canvas = new import_fabric19.Canvas(canvasElement, {
3834
4268
  width: widthPx,
3835
4269
  height: heightPx,
3836
4270
  backgroundColor: this.designBackground,
@@ -3864,7 +4298,7 @@ var CanvasEditor = class {
3864
4298
  // ─── Layer Operations ────────────────────────────────
3865
4299
  async addImage(url, options) {
3866
4300
  try {
3867
- const img = await import_fabric17.FabricImage.fromURL(
4301
+ const img = await import_fabric19.FabricImage.fromURL(
3868
4302
  url,
3869
4303
  {},
3870
4304
  { originX: "left", originY: "top", ...options }
@@ -3890,7 +4324,7 @@ var CanvasEditor = class {
3890
4324
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
3891
4325
  const previous = layer.fabricObject;
3892
4326
  try {
3893
- const replacement = await import_fabric17.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
4327
+ const replacement = await import_fabric19.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
3894
4328
  replacement.set({
3895
4329
  left: previous.left,
3896
4330
  top: previous.top,
@@ -3919,7 +4353,7 @@ var CanvasEditor = class {
3919
4353
  }
3920
4354
  }
3921
4355
  addText(text, options) {
3922
- const textbox = new import_fabric17.Textbox(text, {
4356
+ const textbox = new import_fabric19.Textbox(text, {
3923
4357
  fontSize: 32,
3924
4358
  fontFamily: "Arial",
3925
4359
  fill: "#000000",
@@ -3960,20 +4394,30 @@ var CanvasEditor = class {
3960
4394
  return value === void 0 ? token : escapeXml(value);
3961
4395
  })
3962
4396
  );
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"
4397
+ const specs = resolved.includes("<textPath") ? [textPathSpec(template, values)] : await explodeTemplateSvg(template, resolved, values);
4398
+ if (specs.length === 0) throw new Error("Template SVG contains no renderable objects");
4399
+ return this.history.transaction(async () => {
4400
+ const created = specs.map((spec) => {
4401
+ const layer2 = this.layers.add(spec.type, spec.object, spec.name);
4402
+ if (spec.curve) this.curves.apply(layer2.id, spec.curve, false);
4403
+ return layer2;
4404
+ });
4405
+ const layer = created.length === 1 ? created[0] : await this.groupLayers(
4406
+ created.map((l) => l.id),
4407
+ template.name
4408
+ ) ?? created[0];
4409
+ layer.fabricObject.set({
4410
+ left: this.canvas.getWidth() / 2,
4411
+ top: this.canvas.getHeight() / 2,
4412
+ originX: "center",
4413
+ originY: "center"
4414
+ });
4415
+ layer.fabricObject.setCoords();
4416
+ this.layers.select(layer.id);
4417
+ this.canvas.requestRenderAll();
4418
+ this.history.save();
4419
+ return layer;
3972
4420
  });
3973
- const layer = this.layers.add("template", group, template.name);
3974
- this.layers.select(layer.id);
3975
- this.history.save();
3976
- return layer;
3977
4421
  }
3978
4422
  removeLayer(id) {
3979
4423
  if (this.crop.activeLayerId() === id) this.crop.cancel();
@@ -3985,6 +4429,10 @@ var CanvasEditor = class {
3985
4429
  selectLayer(id) {
3986
4430
  this.layers.select(id);
3987
4431
  }
4432
+ /** Select several layers at once — what a panel's shift/⌘ click drives. */
4433
+ selectLayers(ids) {
4434
+ this.layers.selectMany(ids);
4435
+ }
3988
4436
  getSelectedLayer() {
3989
4437
  const active = this.canvas.getActiveObject();
3990
4438
  if (!active) return null;
@@ -4036,10 +4484,10 @@ var CanvasEditor = class {
4036
4484
  const next = { ...previous, ...adjustments };
4037
4485
  const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
4038
4486
  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) })
4487
+ new import_fabric19.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
4488
+ new import_fabric19.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
4489
+ new import_fabric19.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
4490
+ new import_fabric19.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
4043
4491
  ];
4044
4492
  layer.meta.imageAdjustments = next;
4045
4493
  image.applyFilters();
@@ -4047,42 +4495,55 @@ var CanvasEditor = class {
4047
4495
  this.history.save();
4048
4496
  return true;
4049
4497
  }
4050
- /** Combine two or more layers into a single editable group layer. */
4498
+ /**
4499
+ * Combine two or more layers into one group layer. Picking a group among them
4500
+ * **absorbs** it — its children join the new group and the old group record is
4501
+ * dropped — because groups are exactly one level deep.
4502
+ */
4051
4503
  async groupLayers(ids, name = "Group") {
4052
4504
  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;
4505
+ const picked = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0 && layer.parentId === null);
4506
+ if (picked.length < 2 || picked.length !== uniqueIds.length) return null;
4507
+ const stack = this.layers.getAll();
4508
+ const topIndex = Math.max(...picked.map((layer) => stack.indexOf(layer)));
4509
+ const insertIndex = stack.slice(0, topIndex).filter((layer) => !picked.includes(layer)).length;
4055
4510
  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);
4511
+ const children = [];
4512
+ const objects = [];
4513
+ for (const layer of picked) {
4514
+ if (layer.type === "group" && layer.children.length > 0) {
4515
+ const inner = layer.fabricObject.removeAll();
4516
+ for (const [index, object] of inner.entries()) {
4517
+ object.setCoords();
4518
+ const child = layer.children[index];
4519
+ if (!child) continue;
4520
+ children.push(child);
4521
+ objects.push(object);
4522
+ }
4523
+ layer.children = [];
4524
+ this.layers.detach(layer.id);
4525
+ } else {
4526
+ children.push(layer);
4527
+ objects.push(layer.fabricObject);
4528
+ this.layers.detach(layer.id);
4529
+ }
4530
+ }
4531
+ const group = new import_fabric19.Group(objects);
4060
4532
  const grouped = this.layers.add("group", group, name);
4061
- grouped.meta.groupChildren = childData;
4533
+ for (const child of children) child.parentId = grouped.id;
4534
+ grouped.children = children;
4535
+ this.layers.reorder(grouped.id, insertIndex);
4062
4536
  this.layers.select(grouped.id);
4063
4537
  this.history.save();
4064
4538
  return grouped;
4065
4539
  });
4066
4540
  }
4067
- /** Restore a group created by groupLayers back to its original layer records. */
4541
+ /** Split a group back into the layers it holds, in place. */
4068
4542
  async ungroupLayer(id) {
4069
4543
  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;
4544
+ if (!grouped || grouped.type !== "group" || grouped.children.length === 0) return [];
4073
4545
  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
- });
4546
+ const restored = this.layers.explode(grouped);
4086
4547
  this.history.save();
4087
4548
  this.layers.select(restored[0]?.id ?? null);
4088
4549
  return restored;
@@ -4130,7 +4591,7 @@ var CanvasEditor = class {
4130
4591
  const layer = this.layers.get(id);
4131
4592
  if (!layer) throw new Error(`Layer not found: ${id}`);
4132
4593
  try {
4133
- if (options.resolution === "source" && layer.fabricObject instanceof import_fabric17.FabricImage) {
4594
+ if (options.resolution === "source" && layer.fabricObject instanceof import_fabric19.FabricImage) {
4134
4595
  const image = await layer.fabricObject.clone();
4135
4596
  image.set({
4136
4597
  left: 0,
@@ -4406,7 +4867,7 @@ var CanvasEditor = class {
4406
4867
  return;
4407
4868
  }
4408
4869
  try {
4409
- const image = await import_fabric17.FabricImage.fromURL(
4870
+ const image = await import_fabric19.FabricImage.fromURL(
4410
4871
  url,
4411
4872
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
4412
4873
  { originX: "left", originY: "top" }
@@ -4830,6 +5291,7 @@ var AnnotationOverlay = class {
4830
5291
  applyLayerShadow,
4831
5292
  applyObjectSelectionStyle,
4832
5293
  applySelectionStyle,
5294
+ buildCurveLinePaths,
4833
5295
  buildCurvePathData,
4834
5296
  clamp,
4835
5297
  clearTextureMaskCache,
@@ -4841,6 +5303,7 @@ var AnnotationOverlay = class {
4841
5303
  displaceRgba,
4842
5304
  drawTiles,
4843
5305
  escapeXml,
5306
+ explodeTemplateSvg,
4844
5307
  exportDataURL,
4845
5308
  exportMockup,
4846
5309
  exportPNG,
@@ -4853,6 +5316,7 @@ var AnnotationOverlay = class {
4853
5316
  isShapeMaskId,
4854
5317
  isTextureMaskId,
4855
5318
  needsAbsoluteSpace,
5319
+ normalizeTextCurve,
4856
5320
  readLayerShadow,
4857
5321
  renderTextureMask,
4858
5322
  resetTransform,
@@ -4861,6 +5325,8 @@ var AnnotationOverlay = class {
4861
5325
  sanitizeSvg,
4862
5326
  serializeEditor,
4863
5327
  shapeMaskPathData,
5328
+ textPathCurve,
5329
+ textPathSpec,
4864
5330
  toCanvasSpace,
4865
5331
  toHostSpace,
4866
5332
  unwrapGroup