@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.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-MCBRZQ4M.mjs";
12
12
 
13
13
  // src/editor.ts
14
- import { Canvas, FabricImage as FabricImage3, Group as Group6, Textbox, filters, loadSVGFromString, util as util5 } from "fabric";
14
+ import { Canvas, FabricImage as FabricImage3, Group as Group7, Textbox as Textbox2, filters } from "fabric";
15
15
 
16
16
  // src/events.ts
17
17
  var EventEmitter = class {
@@ -48,6 +48,9 @@ var EventEmitter = class {
48
48
  }
49
49
  };
50
50
 
51
+ // src/layer.ts
52
+ import { ActiveSelection } from "fabric";
53
+
51
54
  // src/utils/id.ts
52
55
  import { nanoid } from "nanoid";
53
56
  var generateId = () => nanoid(12);
@@ -69,6 +72,14 @@ var Layer = class {
69
72
  * the layer's real object stays the one every editor API talks to.
70
73
  */
71
74
  renderProxy;
75
+ /**
76
+ * Layers nested inside this one, in draw order. Only a `group` layer has any:
77
+ * their fabric objects live inside this layer's fabric `Group`, not on the
78
+ * canvas, which is why the manager keeps them off the canvas stack.
79
+ */
80
+ children;
81
+ /** Group this layer sits inside, or null when it is on the canvas stack. */
82
+ parentId;
72
83
  constructor(type, fabricObject, name, id) {
73
84
  this.id = id ?? generateId();
74
85
  this.type = type;
@@ -79,6 +90,8 @@ var Layer = class {
79
90
  this.meta = {};
80
91
  this.fabricObject = fabricObject;
81
92
  this.renderProxy = null;
93
+ this.children = [];
94
+ this.parentId = null;
82
95
  this.fabricObject._layerId = this.id;
83
96
  }
84
97
  hasMeta() {
@@ -92,12 +105,15 @@ var Layer = class {
92
105
  visible: this.visible,
93
106
  locked: this.locked,
94
107
  opacity: this.opacity,
95
- ...this.hasMeta() ? { meta: this.meta } : {}
108
+ ...this.hasMeta() ? { meta: this.meta } : {},
109
+ ...this.children.length > 0 ? { children: this.children.map((c) => c.toData()) } : {}
96
110
  };
97
111
  }
98
112
  serialize() {
99
113
  return {
100
114
  ...this.toData(),
115
+ // A group's own object already carries its children's geometry, so the
116
+ // child records in `toData` stay identity-only — nothing is duplicated.
101
117
  fabricObject: this.fabricObject.toObject()
102
118
  };
103
119
  }
@@ -123,9 +139,23 @@ var LayerManager = class {
123
139
  return layer;
124
140
  }
125
141
  remove(id) {
142
+ const layer = this.get(id);
143
+ if (!layer) return false;
144
+ if (layer.parentId) {
145
+ const parent = this.get(layer.parentId);
146
+ if (!parent) return false;
147
+ parent.fabricObject.remove(layer.fabricObject);
148
+ parent.children = parent.children.filter((c) => c.id !== id);
149
+ layer.parentId = null;
150
+ this.events.emit("layer:removed", { layerId: id });
151
+ if (parent.children.length < 2) this.dissolve(parent);
152
+ this.emitChanged();
153
+ this.onPropertyChanged?.();
154
+ this.canvas.requestRenderAll();
155
+ return true;
156
+ }
126
157
  const index = this.layers.findIndex((l) => l.id === id);
127
158
  if (index === -1) return false;
128
- const layer = this.layers[index];
129
159
  this.canvas.remove(layer.fabricObject);
130
160
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
131
161
  this.layers.splice(index, 1);
@@ -134,6 +164,81 @@ var LayerManager = class {
134
164
  this.onPropertyChanged?.();
135
165
  return true;
136
166
  }
167
+ /**
168
+ * Take a top-level layer off the canvas stack **without** disposing it — it is
169
+ * about to become a group's child, so its object has to stay alive.
170
+ */
171
+ detach(id) {
172
+ const index = this.layers.findIndex((l) => l.id === id);
173
+ if (index === -1) return void 0;
174
+ const [layer] = this.layers.splice(index, 1);
175
+ this.canvas.remove(layer.fabricObject);
176
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
177
+ return layer;
178
+ }
179
+ /** Put a group's children back on the canvas stack and drop the group. */
180
+ explode(group) {
181
+ const children = [...group.children];
182
+ this.dissolve(group);
183
+ this.events.emit("layer:removed", { layerId: group.id });
184
+ this.emitChanged();
185
+ this.onPropertyChanged?.();
186
+ this.canvas.requestRenderAll();
187
+ return children;
188
+ }
189
+ /**
190
+ * Empty a group onto the canvas stack, in the group's own place, and drop the
191
+ * group record. `removeAll` restores each child's absolute transform, so the
192
+ * group's matrix must NOT be re-applied on top of it.
193
+ */
194
+ dissolve(group) {
195
+ const objects = group.fabricObject.removeAll();
196
+ const at = this.layers.findIndex((l) => l.id === group.id);
197
+ const survivors = group.children;
198
+ group.children = [];
199
+ for (const [index, object] of objects.entries()) {
200
+ object.setCoords();
201
+ const child = survivors[index];
202
+ if (!child) continue;
203
+ child.parentId = null;
204
+ this.layers.splice(at + index, 0, child);
205
+ this.canvas.add(object);
206
+ if (child.renderProxy) this.canvas.add(child.renderProxy);
207
+ }
208
+ this.canvas.remove(group.fabricObject);
209
+ this.layers = this.layers.filter((l) => l.id !== group.id);
210
+ this.syncZOrder();
211
+ }
212
+ /**
213
+ * Re-attach child records to an enlivened group. `data[i]` describes
214
+ * `group._objects[i]` — the order fabric serializes and restores them in. A
215
+ * missing record still becomes a child, so a hand-edited state cannot drop
216
+ * artwork off the canvas.
217
+ */
218
+ adoptGroupChildren(group, data) {
219
+ const objects = group.fabricObject.getObjects();
220
+ group.children = objects.map((object, index) => {
221
+ const record = data[index];
222
+ const child = new Layer(record?.type ?? "shape", object, record?.name, record?.id);
223
+ child.parentId = group.id;
224
+ if (record?.meta) child.meta = structuredClone(record.meta);
225
+ if (record && !record.visible) {
226
+ child.visible = false;
227
+ object.visible = false;
228
+ }
229
+ if (record?.locked) {
230
+ child.locked = true;
231
+ object.selectable = false;
232
+ object.evented = false;
233
+ }
234
+ if (record && record.opacity !== 1) {
235
+ child.opacity = record.opacity;
236
+ object.opacity = record.opacity;
237
+ }
238
+ return child;
239
+ });
240
+ this.emitChanged();
241
+ }
137
242
  /** Replace a layer's render object while preserving its immutable ID and panel state. */
138
243
  replaceObject(id, fabricObject) {
139
244
  const layer = this.get(id);
@@ -198,16 +303,32 @@ var LayerManager = class {
198
303
  if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
199
304
  }
200
305
  }
306
+ /** The list a layer lives in: its group's children, or the canvas stack. */
307
+ siblingsOf(layer) {
308
+ if (!layer.parentId) return this.layers;
309
+ return this.get(layer.parentId)?.children ?? this.layers;
310
+ }
201
311
  reorder(id, newIndex) {
202
- const oldIndex = this.layers.findIndex((l) => l.id === id);
312
+ const target = this.get(id);
313
+ if (!target) return false;
314
+ const list = this.siblingsOf(target);
315
+ const oldIndex = list.indexOf(target);
203
316
  if (oldIndex === -1) return false;
204
317
  if (!Number.isFinite(newIndex)) return false;
205
- const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
318
+ const clamped = Math.max(0, Math.min(list.length - 1, Math.round(newIndex)));
206
319
  if (oldIndex === clamped) return false;
207
- const [layer] = this.layers.splice(oldIndex, 1);
208
- this.layers.splice(clamped, 0, layer);
209
- this.syncZOrder();
210
- this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
320
+ const [layer] = list.splice(oldIndex, 1);
321
+ list.splice(clamped, 0, layer);
322
+ if (layer.parentId) {
323
+ const group = this.get(layer.parentId)?.fabricObject;
324
+ if (group) {
325
+ group.remove(layer.fabricObject);
326
+ group.insertAt(clamped, layer.fabricObject);
327
+ }
328
+ } else {
329
+ this.syncZOrder();
330
+ }
331
+ this.events.emit("layer:reordered", { layerIds: list.map((l) => l.id) });
211
332
  this.emitChanged();
212
333
  this.onPropertyChanged?.();
213
334
  return true;
@@ -215,9 +336,12 @@ var LayerManager = class {
215
336
  select(id) {
216
337
  if (id === null) {
217
338
  this.canvas.discardActiveObject();
339
+ this.exitGroups();
218
340
  } else {
219
341
  const layer = this.get(id);
220
342
  if (layer) {
343
+ if (layer.parentId) this.enterGroup(layer.parentId);
344
+ else this.exitGroups();
221
345
  this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
222
346
  }
223
347
  }
@@ -226,16 +350,67 @@ var LayerManager = class {
226
350
  const selected = id && this.get(id) ? [id] : [];
227
351
  this.events.emit("selection:changed", { selected });
228
352
  }
353
+ /** Drill into a group so its children can be selected and clicked. */
354
+ enterGroup(groupId) {
355
+ const parent = this.get(groupId);
356
+ if (!parent) return;
357
+ const group = parent.fabricObject;
358
+ group.subTargetCheck = true;
359
+ group.interactive = true;
360
+ }
361
+ /** Leave every entered group — selecting anything else is done drilling in. */
362
+ exitGroups() {
363
+ for (const layer of this.layers) {
364
+ if (layer.type !== "group") continue;
365
+ const group = layer.fabricObject;
366
+ if (group.interactive) group.interactive = false;
367
+ }
368
+ }
369
+ /**
370
+ * Select several layers at once, as one fabric `ActiveSelection`. Children are
371
+ * skipped: a selection spanning in and out of a group has no single transform.
372
+ */
373
+ selectMany(ids) {
374
+ const layers = ids.flatMap((id) => {
375
+ const layer = this.get(id);
376
+ return layer && !layer.parentId ? [layer] : [];
377
+ });
378
+ if (layers.length === 0) return this.select(null);
379
+ if (layers.length === 1) return this.select(layers[0].id);
380
+ this.canvas.discardActiveObject();
381
+ this.exitGroups();
382
+ const selection = new ActiveSelection(
383
+ layers.map((l) => l.renderProxy ?? l.fabricObject),
384
+ { canvas: this.canvas }
385
+ );
386
+ this.canvas.setActiveObject(selection);
387
+ this.canvas.requestRenderAll();
388
+ this.events.emit("selection:changed", { selected: layers.map((l) => l.id) });
389
+ }
390
+ /** Depth-first over the whole tree, each layer before its children. */
391
+ *walk(layers = this.layers) {
392
+ for (const layer of layers) {
393
+ yield layer;
394
+ if (layer.children.length > 0) yield* this.walk(layer.children);
395
+ }
396
+ }
229
397
  get(id) {
230
- return this.layers.find((l) => l.id === id);
398
+ for (const layer of this.walk()) if (layer.id === id) return layer;
399
+ return void 0;
231
400
  }
401
+ /** Top-level layers only — the canvas stack. Children hang off their group. */
232
402
  getAll() {
233
403
  return [...this.layers];
234
404
  }
405
+ /** Top-level layers, each carrying its children: what the layer panel renders. */
406
+ getTree() {
407
+ return this.layers.map((l) => l.toData());
408
+ }
235
409
  findByObject(obj) {
236
410
  const layerId = obj._layerId;
237
411
  if (layerId) return this.get(layerId);
238
- return this.layers.find((l) => l.fabricObject === obj);
412
+ for (const layer of this.walk()) if (layer.fabricObject === obj) return layer;
413
+ return void 0;
239
414
  }
240
415
  setVisibility(id, visible) {
241
416
  const layer = this.get(id);
@@ -316,6 +491,7 @@ var LayerManager = class {
316
491
  for (const layer of this.layers) {
317
492
  this.canvas.remove(layer.fabricObject);
318
493
  if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
494
+ layer.children = [];
319
495
  }
320
496
  this.layers = [];
321
497
  this.emitChanged();
@@ -330,6 +506,107 @@ var LayerManager = class {
330
506
  }
331
507
  };
332
508
 
509
+ // src/template-explode.ts
510
+ import { Textbox, loadSVGFromString } from "fabric";
511
+ var NAME_MAX = 24;
512
+ function isText(object) {
513
+ return typeof object.text === "string";
514
+ }
515
+ function labelsByRun(template, values) {
516
+ const labels = new Map(template.parameters.map((p) => [p.key, p.label || p.key]));
517
+ const byRun = /* @__PURE__ */ new Map();
518
+ for (const match of template.svg.matchAll(/<text\b[^>]*>([\s\S]*?)<\/text>/g)) {
519
+ const token = /\{\{(\w+)(?:[|:]([^}]*))?\}\}/.exec(match[1]);
520
+ if (!token) continue;
521
+ const label = labels.get(token[1]);
522
+ if (!label) continue;
523
+ const resolved = String(values[token[1]] ?? token[2] ?? "").trim();
524
+ if (resolved) byRun.set(resolved, label);
525
+ }
526
+ return byRun;
527
+ }
528
+ function textName(run, labels) {
529
+ const trimmed = run.trim();
530
+ const label = labels.get(trimmed);
531
+ if (label) return label;
532
+ if (!trimmed) return "Text";
533
+ return trimmed.length > NAME_MAX ? `${trimmed.slice(0, NAME_MAX - 1)}\u2026` : trimmed;
534
+ }
535
+ function shapeName(object) {
536
+ const id = object.id;
537
+ if (typeof id === "string" && id) return id;
538
+ const type = object.type || "shape";
539
+ return type.charAt(0).toUpperCase() + type.slice(1);
540
+ }
541
+ function toTextbox(text) {
542
+ const box = new Textbox(text.text, {
543
+ left: text.left,
544
+ top: text.top,
545
+ originX: text.originX,
546
+ originY: text.originY,
547
+ angle: text.angle,
548
+ scaleX: text.scaleX,
549
+ scaleY: text.scaleY,
550
+ fontSize: text.fontSize,
551
+ fontFamily: text.fontFamily,
552
+ fontWeight: text.fontWeight,
553
+ fontStyle: text.fontStyle,
554
+ textAlign: text.textAlign,
555
+ charSpacing: text.charSpacing,
556
+ lineHeight: text.lineHeight,
557
+ fill: text.fill,
558
+ stroke: text.stroke,
559
+ strokeWidth: text.strokeWidth,
560
+ opacity: text.opacity,
561
+ // A Textbox wraps at its box width; size it to the run the template drew so
562
+ // the authored copy never comes in pre-wrapped.
563
+ width: Math.max(1, (text.width ?? 0) + 2)
564
+ });
565
+ return box;
566
+ }
567
+ function textPathCurve(template, values, runWidth) {
568
+ if (template.id === "circular-text") return { arc: 100, wave: 0 };
569
+ if (template.id === "wave-text") return { arc: 0, wave: 50 };
570
+ const radius = Number(values.radius ?? 200);
571
+ if (!Number.isFinite(radius) || radius <= 0) return { arc: 0, wave: 0 };
572
+ const arc = 100 * runWidth / (Math.PI * radius);
573
+ return { arc: Math.max(-100, Math.min(100, arc)), wave: 0 };
574
+ }
575
+ function textPathSpec(template, values) {
576
+ const content = String(values.text ?? "Text");
577
+ const fontSize = template.id === "circular-text" ? 28 : 40;
578
+ const box = new Textbox(content, {
579
+ fontSize,
580
+ fontFamily: String(values.font ?? "Arial"),
581
+ fontWeight: "bold",
582
+ fill: String(values.color ?? "#000000"),
583
+ originX: "left",
584
+ originY: "top",
585
+ // Wide enough that the run stays on one line before the curve widens it.
586
+ width: Math.max(1, content.length * fontSize * 0.62)
587
+ });
588
+ return {
589
+ type: "text",
590
+ name: template.parameters.find((p) => p.key === "text")?.label || "Text",
591
+ object: box,
592
+ curve: textPathCurve(template, values, box.width ?? 0)
593
+ };
594
+ }
595
+ async function explodeTemplateSvg(template, resolvedSvg, values) {
596
+ const labels = labelsByRun(template, values);
597
+ const { objects } = await loadSVGFromString(resolvedSvg);
598
+ const specs = [];
599
+ for (const object of objects) {
600
+ if (object === null) continue;
601
+ if (isText(object)) {
602
+ specs.push({ type: "text", name: textName(object.text, labels), object: toTextbox(object) });
603
+ continue;
604
+ }
605
+ specs.push({ type: "shape", name: shapeName(object), object });
606
+ }
607
+ return specs;
608
+ }
609
+
333
610
  // src/history.ts
334
611
  var HistoryManager = class _HistoryManager {
335
612
  static ASSET_KEY = "__canvasEditorHistoryAsset";
@@ -1463,90 +1740,153 @@ function restoreLocks(obj, locks) {
1463
1740
 
1464
1741
  // src/text-curve.ts
1465
1742
  import { Path } from "fabric";
1466
- var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
1743
+
1744
+ // src/text-curve-geometry.ts
1745
+ var DEFAULT_TEXT_CURVE = {
1746
+ shape: "arc",
1747
+ arc: 0,
1748
+ wave: 0,
1749
+ waveLength: 4.1,
1750
+ offset: 0,
1751
+ centerOffset: 0
1752
+ };
1467
1753
  var MIN_ARC = 0.5;
1468
1754
  var FULL_CIRCLE_ARC = 99.5;
1469
1755
  var MIN_SWEEP = 0.12;
1470
- var WAVE_PERIOD_EM = 4.1;
1471
1756
  var WAVE_AMPLITUDE_EM = 0.9;
1757
+ var MIN_WAVE_LENGTH = 1;
1758
+ var MAX_WAVE_LENGTH = 12;
1472
1759
  var WAVE_STEP = 6;
1473
- var MEASURE_WIDTH = 1e5;
1474
- var PATH_SLACK = 0.06;
1475
- function isCurvable(object) {
1476
- return !!object && typeof object.text === "string";
1760
+ var MAX_WAVE_STEPS = 4e3;
1761
+ function round(value) {
1762
+ return Math.round(value * 100) / 100;
1477
1763
  }
1478
- function measureText(text) {
1479
- const authored = text.width;
1480
- try {
1481
- text.set({ width: MEASURE_WIDTH });
1482
- text.initDimensions?.();
1483
- return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1484
- } finally {
1485
- if (authored !== void 0) text.set({ width: authored });
1486
- text.initDimensions?.();
1487
- }
1764
+ function normalizeTextCurve(config) {
1765
+ const arc = clamp(config.arc ?? 0, -100, 100);
1766
+ const wave = clamp(config.wave ?? 0, 0, 100);
1767
+ const inferred = Math.abs(arc) < MIN_ARC && wave > 0 ? "wave" : "arc";
1768
+ return {
1769
+ shape: config.shape ?? inferred,
1770
+ arc,
1771
+ wave,
1772
+ waveLength: clamp(config.waveLength ?? DEFAULT_TEXT_CURVE.waveLength, MIN_WAVE_LENGTH, MAX_WAVE_LENGTH),
1773
+ offset: clamp(config.offset ?? 0, -100, 100),
1774
+ centerOffset: clamp(config.centerOffset ?? 0, -100, 100)
1775
+ };
1776
+ }
1777
+ function bendsBaseline(config) {
1778
+ return config.shape === "arc" ? Math.abs(config.arc) >= MIN_ARC : config.wave > 0;
1488
1779
  }
1489
- function arcPathData(width, arc) {
1490
- const magnitude = Math.min(100, Math.abs(arc));
1491
- const direction = arc < 0 ? -1 : 1;
1492
- const full = magnitude >= FULL_CIRCLE_ARC;
1493
- const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1494
- const radius = width / sweep;
1780
+ function arcGeometry(width, config) {
1781
+ const magnitude = Math.min(100, Math.abs(config.arc));
1782
+ const direction = config.arc < 0 ? -1 : 1;
1783
+ const baseSweep = magnitude >= FULL_CIRCLE_ARC ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
1784
+ const baseRadius = width / baseSweep;
1785
+ const radius = baseRadius * Math.pow(2, config.centerOffset / 100);
1786
+ const sweep = radius >= baseRadius ? baseSweep : Math.min(Math.PI * 2, width / Math.max(radius, 1e-3));
1787
+ return { radius, sweep, direction, baseRadius };
1788
+ }
1789
+ function arcLinePath(width, geometry, radius, rotation) {
1790
+ const { sweep, direction, baseRadius } = geometry;
1791
+ const full = sweep >= Math.PI * 2 - 1e-6;
1495
1792
  const centerX = width / 2;
1496
1793
  const point = (angle) => [
1497
1794
  centerX + radius * Math.sin(angle),
1498
- direction * radius * (1 - Math.cos(angle))
1795
+ direction * (baseRadius - radius * Math.cos(angle))
1499
1796
  ];
1500
1797
  const sweepFlag = direction > 0 ? 1 : 0;
1501
1798
  const format = ([x, y]) => `${round(x)} ${round(y)}`;
1799
+ const r = round(radius);
1502
1800
  if (full) {
1503
- const start2 = point(-Math.PI);
1504
- const top = point(0);
1801
+ const start2 = point(rotation - Math.PI);
1802
+ const opposite = point(rotation);
1505
1803
  return {
1506
1804
  data: [
1507
1805
  `M ${format(start2)}`,
1508
- `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
1509
- `A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
1806
+ `A ${r} ${r} 0 0 ${sweepFlag} ${format(opposite)}`,
1807
+ `A ${r} ${r} 0 0 ${sweepFlag} ${format(start2)}`
1510
1808
  ].join(" "),
1511
- length: width
1809
+ length: radius * Math.PI * 2
1512
1810
  };
1513
1811
  }
1514
- const start = point(-sweep / 2);
1515
- const end = point(sweep / 2);
1812
+ const start = point(rotation - sweep / 2);
1813
+ const end = point(rotation + sweep / 2);
1516
1814
  const largeArc = sweep > Math.PI ? 1 : 0;
1517
1815
  return {
1518
- data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1519
- // radius = width / sweep, so the arc is exactly `width` long.
1520
- length: width
1816
+ data: `M ${format(start)} A ${r} ${r} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
1817
+ length: radius * sweep
1521
1818
  };
1522
1819
  }
1523
- function wavePathData(width, fontSize, wave) {
1524
- const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
1525
- const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
1526
- const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
1820
+ function waveLinePath(width, fontSize, config, dy) {
1821
+ const amplitude = config.wave / 100 * fontSize * WAVE_AMPLITUDE_EM;
1822
+ const period = Math.max(1, fontSize * config.waveLength);
1823
+ const phase = config.offset / 100 * Math.PI * 2;
1824
+ const step = Math.min(WAVE_STEP, period / 8);
1825
+ const steps = Math.min(MAX_WAVE_STEPS, Math.max(2, Math.ceil(width / step)));
1527
1826
  const commands = [];
1528
1827
  let length = 0;
1529
1828
  let previous = null;
1530
1829
  for (let index = 0; index <= steps; index++) {
1531
1830
  const x = width * index / steps;
1532
- const y = amplitude * Math.sin(x / period * Math.PI * 2);
1831
+ const y = dy + amplitude * Math.sin(x / period * Math.PI * 2 + phase);
1533
1832
  if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
1534
1833
  previous = [x, y];
1535
1834
  commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
1536
1835
  }
1537
1836
  return { data: commands.join(" "), length };
1538
1837
  }
1539
- function round(value) {
1540
- return Math.round(value * 100) / 100;
1838
+ function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight = 0) {
1839
+ const curve = normalizeTextCurve(config);
1840
+ if (!bendsBaseline(curve)) return null;
1841
+ const lines = Math.max(1, Math.floor(lineCount));
1842
+ const paths = [];
1843
+ if (curve.shape === "wave") {
1844
+ for (let index = 0; index < lines; index++) {
1845
+ paths.push(waveLinePath(width, fontSize, curve, (index - (lines - 1) / 2) * lineHeight));
1846
+ }
1847
+ return paths;
1848
+ }
1849
+ const geometry = arcGeometry(width, curve);
1850
+ const rotation = curve.offset / 100 * geometry.sweep;
1851
+ for (let index = 0; index < lines; index++) {
1852
+ const radius = Math.max(1, geometry.radius + (index - (lines - 1) / 2) * lineHeight);
1853
+ paths.push(arcLinePath(width, geometry, radius, rotation));
1854
+ }
1855
+ return paths;
1541
1856
  }
1542
1857
  function buildCurvePathData(config, width, fontSize) {
1543
- if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
1544
- if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
1545
- return null;
1858
+ return buildCurveLinePaths(config, width, fontSize)?.[0] ?? null;
1546
1859
  }
1547
- function normalize(config) {
1548
- const arc = clamp(config.arc ?? 0, -100, 100);
1549
- return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
1860
+
1861
+ // src/text-curve.ts
1862
+ var MEASURE_WIDTH = 1e5;
1863
+ var PATH_SLACK = 0.06;
1864
+ var FALLBACK_LINE_HEIGHT = 1.16;
1865
+ var patches = /* @__PURE__ */ new WeakMap();
1866
+ function isCurvable(object) {
1867
+ return !!object && typeof object.text === "string";
1868
+ }
1869
+ function measureText(text) {
1870
+ const authored = text.width;
1871
+ try {
1872
+ text.set({ width: MEASURE_WIDTH });
1873
+ text.initDimensions?.();
1874
+ return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
1875
+ } finally {
1876
+ if (authored !== void 0) text.set({ width: authored });
1877
+ text.initDimensions?.();
1878
+ }
1879
+ }
1880
+ function lineCountOf(text) {
1881
+ return Math.max(1, text._textLines?.length ?? 1);
1882
+ }
1883
+ function lineHeightOf(text) {
1884
+ const measured = text.getHeightOfLine?.(0);
1885
+ if (typeof measured === "number" && Number.isFinite(measured) && measured > 0) return measured;
1886
+ return text.fontSize * (text.lineHeight ?? 1) * FALLBACK_LINE_HEIGHT;
1887
+ }
1888
+ function toFabricPath(curve) {
1889
+ return new Path(curve.data, { visible: false, objectCaching: false });
1550
1890
  }
1551
1891
  var TextCurveManager = class {
1552
1892
  constructor(canvas, layers, history, events) {
@@ -1563,33 +1903,35 @@ var TextCurveManager = class {
1563
1903
  get(layerId) {
1564
1904
  const layer = this.layers.get(layerId);
1565
1905
  if (!layer || !isCurvable(layer.fabricObject)) return null;
1566
- return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
1906
+ return normalizeTextCurve(layer.meta.curve ?? {});
1567
1907
  }
1568
1908
  isCurved(layerId) {
1569
1909
  const curve = this.get(layerId);
1570
- return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
1910
+ return !!curve && bendsBaseline(curve);
1571
1911
  }
1572
- /** Apply (or update) the curve on a text layer. Zeroed config clears it. */
1912
+ /** Apply (or update) the curve on a text layer. A straight config clears it. */
1573
1913
  apply(layerId, config, save = true) {
1574
1914
  const layer = this.layers.get(layerId);
1575
1915
  if (!layer || !isCurvable(layer.fabricObject)) return false;
1576
- const next = normalize(config);
1916
+ const next = normalizeTextCurve(config);
1577
1917
  const text = layer.fabricObject;
1578
1918
  const run = measureText(text);
1579
- const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
1580
- if (!curve) {
1919
+ if (!bendsBaseline(next)) {
1581
1920
  this.detach(text, layer.meta.curveWidth);
1582
- delete layer.meta.curve;
1583
1921
  delete layer.meta.curveWidth;
1922
+ layer.meta.curve = next;
1584
1923
  } else {
1585
1924
  if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
1586
1925
  text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
1587
- text.set({
1588
- path: new Path(curve.data, { visible: false, objectCaching: false }),
1589
- pathAlign: "center",
1590
- pathSide: "left",
1591
- pathStartOffset: Math.max(0, (curve.length - run) / 2)
1592
- });
1926
+ text.initDimensions?.();
1927
+ const curves = buildCurveLinePaths(
1928
+ next,
1929
+ run * (1 + PATH_SLACK),
1930
+ text.fontSize,
1931
+ lineCountOf(text),
1932
+ lineHeightOf(text)
1933
+ );
1934
+ if (curves) this.attach(text, curves);
1593
1935
  layer.meta.curve = next;
1594
1936
  }
1595
1937
  text.initDimensions?.();
@@ -1602,12 +1944,14 @@ var TextCurveManager = class {
1602
1944
  }
1603
1945
  /** Remove the curve, restoring the authored text box width. */
1604
1946
  clear(layerId, save = true) {
1605
- return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
1947
+ const layer = this.layers.get(layerId);
1948
+ const shape = layer?.meta.curve?.shape;
1949
+ return this.apply(layerId, { shape }, save);
1606
1950
  }
1607
1951
  /**
1608
- * Rebuild the path from the stored parameters. Text content, font family and
1609
- * font size all change the run's width, and the path is sized to that width
1610
- * without this the curve keeps the geometry of the text it was created from.
1952
+ * Rebuild the paths from the stored parameters. Text content, line count,
1953
+ * font family and font size all change the geometry the paths are sized to —
1954
+ * without this the curve keeps that of the text it was created from.
1611
1955
  */
1612
1956
  refresh(layerId, save = false) {
1613
1957
  const curve = this.layers.get(layerId)?.meta.curve;
@@ -1620,7 +1964,77 @@ var TextCurveManager = class {
1620
1964
  if (layer.meta.curve) this.refresh(layer.id);
1621
1965
  }
1622
1966
  }
1967
+ /**
1968
+ * Puts every line's path on the object as one shape, and hooks per-line
1969
+ * measuring so each line is laid out on its own.
1970
+ *
1971
+ * The object's own path is the union of the lines: fabric sizes a curved text
1972
+ * object from it, and a single line's path would leave the rest of the block
1973
+ * outside the layer's box — where object caching clips it away.
1974
+ */
1975
+ attach(text, curves) {
1976
+ const paths = curves.map(toFabricPath);
1977
+ const union = paths.length === 1 ? paths[0] : toFabricPath({ data: curves.map((c) => c.data).join(" "), length: 0 });
1978
+ text.set({
1979
+ path: union,
1980
+ pathAlign: "center",
1981
+ pathSide: "left",
1982
+ // Each line is centred on its own path by the measuring hook below, so
1983
+ // there is no single offset left to apply.
1984
+ pathStartOffset: 0
1985
+ });
1986
+ this.patchLineMeasure(text, paths, union);
1987
+ }
1988
+ /**
1989
+ * Fabric lays every line of a text object along `this.path`, from one
1990
+ * `pathStartOffset` — so a multi-line run piles all its lines on top of each
1991
+ * other. Each line is measured against its own path instead, by swapping the
1992
+ * path in around fabric's own per-line measure pass.
1993
+ *
1994
+ * The paths all share the first one's `pathOffset`, which is the origin
1995
+ * fabric subtracts to place a glyph in the object — without that each line
1996
+ * would be re-centred on its own bounding box and land back in the same spot.
1997
+ *
1998
+ * `textAlign` is switched to centre for the pass, which is how fabric centres
1999
+ * a line on its path; the authored value is restored before anything else
2000
+ * reads it.
2001
+ */
2002
+ patchLineMeasure(text, paths, union) {
2003
+ this.unpatchLineMeasure(text);
2004
+ const original = text._measureLine;
2005
+ if (typeof original !== "function") return;
2006
+ for (const path of paths) path.pathOffset = union.pathOffset;
2007
+ const installed = function(lineIndex) {
2008
+ const path = paths[lineIndex];
2009
+ if (!path) return original.call(this, lineIndex);
2010
+ const previousPath = this.path;
2011
+ const previousAlign = this.textAlign;
2012
+ this.path = path;
2013
+ this.textAlign = "center";
2014
+ this.setPathInfo?.();
2015
+ try {
2016
+ return original.call(this, lineIndex);
2017
+ } finally {
2018
+ this.path = previousPath;
2019
+ this.textAlign = previousAlign;
2020
+ }
2021
+ };
2022
+ const ownedOriginal = Object.prototype.hasOwnProperty.call(text, "_measureLine");
2023
+ text._measureLine = installed;
2024
+ patches.set(text, { original, installed, ownedOriginal });
2025
+ }
2026
+ /** Restore fabric's own per-line measuring, if this manager replaced it. */
2027
+ unpatchLineMeasure(text) {
2028
+ const patch = patches.get(text);
2029
+ if (!patch) return;
2030
+ if (text._measureLine === patch.installed) {
2031
+ if (patch.ownedOriginal) text._measureLine = patch.original;
2032
+ else delete text._measureLine;
2033
+ }
2034
+ patches.delete(text);
2035
+ }
1623
2036
  detach(text, authoredWidth) {
2037
+ this.unpatchLineMeasure(text);
1624
2038
  text.set({ path: null, pathStartOffset: 0 });
1625
2039
  if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
1626
2040
  }
@@ -2151,6 +2565,11 @@ function restoreLayer(editor, serialized, fabricObject) {
2151
2565
  if (serialized.opacity !== 1) {
2152
2566
  editor.layers.setOpacity(layer.id, serialized.opacity);
2153
2567
  }
2568
+ const childData = serialized.children ?? serialized.meta?.groupChildren;
2569
+ if (layer.type === "group" && Array.isArray(childData)) {
2570
+ editor.layers.adoptGroupChildren(layer, childData);
2571
+ if (layer.meta.groupChildren) delete layer.meta.groupChildren;
2572
+ }
2154
2573
  return layer;
2155
2574
  }
2156
2575
 
@@ -2726,10 +3145,10 @@ var MaskController = class {
2726
3145
  };
2727
3146
 
2728
3147
  // src/masks/manager.ts
2729
- import { Group as Group5 } from "fabric";
3148
+ import { Group as Group6 } from "fabric";
2730
3149
 
2731
3150
  // src/masks/compose.ts
2732
- import { Group, Rect as Rect2 } from "fabric";
3151
+ import { Group as Group2, Rect as Rect2 } from "fabric";
2733
3152
  var MODE_OPERATION = {
2734
3153
  add: "source-over",
2735
3154
  subtract: "destination-out",
@@ -2766,7 +3185,7 @@ function composeMaskGroup(children, entries, options) {
2766
3185
  });
2767
3186
  const first = entries.find((entry) => entry.visible);
2768
3187
  const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
2769
- return new Group(withBase, {
3188
+ return new Group2(withBase, {
2770
3189
  absolutePositioned: options.absolute,
2771
3190
  // Cached, so the children's compositing operations resolve against each
2772
3191
  // other instead of against the page underneath the mask.
@@ -2942,7 +3361,7 @@ var MaskEditController = class {
2942
3361
  };
2943
3362
 
2944
3363
  // src/masks/store.ts
2945
- import { Group as Group4 } from "fabric";
3364
+ import { Group as Group5 } from "fabric";
2946
3365
 
2947
3366
  // src/masks/host.ts
2948
3367
  import { Rect as Rect3 } from "fabric";
@@ -2999,9 +3418,9 @@ function hostBoxOf(canvas, host, absolute) {
2999
3418
  }
3000
3419
 
3001
3420
  // src/masks/install.ts
3002
- import { Group as Group3 } from "fabric";
3421
+ import { Group as Group4 } from "fabric";
3003
3422
  function convertSpace(host, sources, absolute) {
3004
- const wasAbsolute = host.clipPath instanceof Group3 ? host.clipPath.absolutePositioned : absolute;
3423
+ const wasAbsolute = host.clipPath instanceof Group4 ? host.clipPath.absolutePositioned : absolute;
3005
3424
  if (absolute === wasAbsolute) return;
3006
3425
  for (const source of sources) {
3007
3426
  if (absolute) toCanvasSpace(source, host);
@@ -3093,7 +3512,7 @@ var MaskStackStore = class {
3093
3512
  const clip = host.clipPath;
3094
3513
  if (!clip) return [];
3095
3514
  const entries = this.list(target);
3096
- if (entries.length === 0 || !(clip instanceof Group4)) return [asObject(clip)];
3515
+ if (entries.length === 0 || !(clip instanceof Group5)) return [asObject(clip)];
3097
3516
  const children = unwrapGroup(clip);
3098
3517
  const extra = children.length - entries.length;
3099
3518
  return extra > 0 ? children.slice(extra) : children;
@@ -3193,7 +3612,7 @@ var LayerMaskManager = class extends MaskStackStore {
3193
3612
  if (index === -1) return false;
3194
3613
  this.endEdit(false);
3195
3614
  this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
3196
- const group = host.clipPath instanceof Group5 ? host.clipPath : null;
3615
+ const group = host.clipPath instanceof Group6 ? host.clipPath : null;
3197
3616
  if (!group) return false;
3198
3617
  const children = group.getObjects();
3199
3618
  const child = children[children.length - entries.length + index];
@@ -3588,7 +4007,7 @@ var CanvasEditor = class {
3588
4007
  }
3589
4008
  }
3590
4009
  addText(text, options) {
3591
- const textbox = new Textbox(text, {
4010
+ const textbox = new Textbox2(text, {
3592
4011
  fontSize: 32,
3593
4012
  fontFamily: "Arial",
3594
4013
  fill: "#000000",
@@ -3629,20 +4048,30 @@ var CanvasEditor = class {
3629
4048
  return value === void 0 ? token : escapeXml(value);
3630
4049
  })
3631
4050
  );
3632
- const { objects, options } = await loadSVGFromString(resolved);
3633
- const validObjects = objects.filter((object) => object !== null);
3634
- if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
3635
- const group = util5.groupSVGElements(validObjects, options);
3636
- group.set({
3637
- left: this.canvas.getWidth() / 2,
3638
- top: this.canvas.getHeight() / 2,
3639
- originX: "center",
3640
- originY: "center"
4051
+ const specs = resolved.includes("<textPath") ? [textPathSpec(template, values)] : await explodeTemplateSvg(template, resolved, values);
4052
+ if (specs.length === 0) throw new Error("Template SVG contains no renderable objects");
4053
+ return this.history.transaction(async () => {
4054
+ const created = specs.map((spec) => {
4055
+ const layer2 = this.layers.add(spec.type, spec.object, spec.name);
4056
+ if (spec.curve) this.curves.apply(layer2.id, spec.curve, false);
4057
+ return layer2;
4058
+ });
4059
+ const layer = created.length === 1 ? created[0] : await this.groupLayers(
4060
+ created.map((l) => l.id),
4061
+ template.name
4062
+ ) ?? created[0];
4063
+ layer.fabricObject.set({
4064
+ left: this.canvas.getWidth() / 2,
4065
+ top: this.canvas.getHeight() / 2,
4066
+ originX: "center",
4067
+ originY: "center"
4068
+ });
4069
+ layer.fabricObject.setCoords();
4070
+ this.layers.select(layer.id);
4071
+ this.canvas.requestRenderAll();
4072
+ this.history.save();
4073
+ return layer;
3641
4074
  });
3642
- const layer = this.layers.add("template", group, template.name);
3643
- this.layers.select(layer.id);
3644
- this.history.save();
3645
- return layer;
3646
4075
  }
3647
4076
  removeLayer(id) {
3648
4077
  if (this.crop.activeLayerId() === id) this.crop.cancel();
@@ -3654,6 +4083,10 @@ var CanvasEditor = class {
3654
4083
  selectLayer(id) {
3655
4084
  this.layers.select(id);
3656
4085
  }
4086
+ /** Select several layers at once — what a panel's shift/⌘ click drives. */
4087
+ selectLayers(ids) {
4088
+ this.layers.selectMany(ids);
4089
+ }
3657
4090
  getSelectedLayer() {
3658
4091
  const active = this.canvas.getActiveObject();
3659
4092
  if (!active) return null;
@@ -3716,42 +4149,55 @@ var CanvasEditor = class {
3716
4149
  this.history.save();
3717
4150
  return true;
3718
4151
  }
3719
- /** Combine two or more layers into a single editable group layer. */
4152
+ /**
4153
+ * Combine two or more layers into one group layer. Picking a group among them
4154
+ * **absorbs** it — its children join the new group and the old group record is
4155
+ * dropped — because groups are exactly one level deep.
4156
+ */
3720
4157
  async groupLayers(ids, name = "Group") {
3721
4158
  const uniqueIds = [...new Set(ids)];
3722
- const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
3723
- if (children.length < 2 || children.length !== uniqueIds.length) return null;
4159
+ const picked = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0 && layer.parentId === null);
4160
+ if (picked.length < 2 || picked.length !== uniqueIds.length) return null;
4161
+ const stack = this.layers.getAll();
4162
+ const topIndex = Math.max(...picked.map((layer) => stack.indexOf(layer)));
4163
+ const insertIndex = stack.slice(0, topIndex).filter((layer) => !picked.includes(layer)).length;
3724
4164
  return this.history.transaction(() => {
3725
- const childData = children.map((layer) => structuredClone(layer.toData()));
3726
- const objects = children.map((layer) => layer.fabricObject);
3727
- for (const layer of children) this.layers.remove(layer.id);
3728
- const group = new Group6(objects);
4165
+ const children = [];
4166
+ const objects = [];
4167
+ for (const layer of picked) {
4168
+ if (layer.type === "group" && layer.children.length > 0) {
4169
+ const inner = layer.fabricObject.removeAll();
4170
+ for (const [index, object] of inner.entries()) {
4171
+ object.setCoords();
4172
+ const child = layer.children[index];
4173
+ if (!child) continue;
4174
+ children.push(child);
4175
+ objects.push(object);
4176
+ }
4177
+ layer.children = [];
4178
+ this.layers.detach(layer.id);
4179
+ } else {
4180
+ children.push(layer);
4181
+ objects.push(layer.fabricObject);
4182
+ this.layers.detach(layer.id);
4183
+ }
4184
+ }
4185
+ const group = new Group7(objects);
3729
4186
  const grouped = this.layers.add("group", group, name);
3730
- grouped.meta.groupChildren = childData;
4187
+ for (const child of children) child.parentId = grouped.id;
4188
+ grouped.children = children;
4189
+ this.layers.reorder(grouped.id, insertIndex);
3731
4190
  this.layers.select(grouped.id);
3732
4191
  this.history.save();
3733
4192
  return grouped;
3734
4193
  });
3735
4194
  }
3736
- /** Restore a group created by groupLayers back to its original layer records. */
4195
+ /** Split a group back into the layers it holds, in place. */
3737
4196
  async ungroupLayer(id) {
3738
4197
  const grouped = this.layers.get(id);
3739
- const childData = grouped?.meta.groupChildren;
3740
- if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
3741
- const group = grouped.fabricObject;
4198
+ if (!grouped || grouped.type !== "group" || grouped.children.length === 0) return [];
3742
4199
  return this.history.transaction(() => {
3743
- const objects = group.removeAll();
3744
- this.layers.remove(id);
3745
- const restored = objects.map((object, index) => {
3746
- object.setCoords();
3747
- const data = childData[index];
3748
- const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
3749
- if (data?.meta) layer.meta = structuredClone(data.meta);
3750
- if (data && !data.visible) this.layers.setVisibility(layer.id, false);
3751
- if (data?.locked) this.layers.setLocked(layer.id, true);
3752
- if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
3753
- return layer;
3754
- });
4200
+ const restored = this.layers.explode(grouped);
3755
4201
  this.history.save();
3756
4202
  this.layers.select(restored[0]?.id ?? null);
3757
4203
  return restored;
@@ -4498,6 +4944,7 @@ export {
4498
4944
  applyLayerShadow,
4499
4945
  applyObjectSelectionStyle,
4500
4946
  applySelectionStyle,
4947
+ buildCurveLinePaths,
4501
4948
  buildCurvePathData,
4502
4949
  clamp,
4503
4950
  clearTextureMaskCache,
@@ -4509,6 +4956,7 @@ export {
4509
4956
  displaceRgba,
4510
4957
  drawTiles,
4511
4958
  escapeXml,
4959
+ explodeTemplateSvg,
4512
4960
  exportDataURL,
4513
4961
  exportMockup,
4514
4962
  exportPNG,
@@ -4521,6 +4969,7 @@ export {
4521
4969
  isShapeMaskId,
4522
4970
  isTextureMaskId,
4523
4971
  needsAbsoluteSpace,
4972
+ normalizeTextCurve,
4524
4973
  readLayerShadow,
4525
4974
  renderTextureMask,
4526
4975
  resetTransform,
@@ -4529,6 +4978,8 @@ export {
4529
4978
  sanitizeSvg,
4530
4979
  serializeEditor,
4531
4980
  shapeMaskPathData,
4981
+ textPathCurve,
4982
+ textPathSpec,
4532
4983
  toCanvasSpace,
4533
4984
  toHostSpace,
4534
4985
  unwrapGroup