@overtone-art/canvas-editor-core 0.5.0 → 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.d.mts +180 -15
- package/dist/index.d.ts +180 -15
- package/dist/index.global.js +52 -52
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +697 -173
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +648 -129
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/{types-JP8MZsIs.d.mts → types-LM7pOkkj.d.mts} +34 -4
- package/dist/{types-JP8MZsIs.d.ts → types-LM7pOkkj.d.ts} +34 -4
- package/package.json +1 -1
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
|
|
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
|
|
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(
|
|
318
|
+
const clamped = Math.max(0, Math.min(list.length - 1, Math.round(newIndex)));
|
|
206
319
|
if (oldIndex === clamped) return false;
|
|
207
|
-
const [layer] =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -886,8 +1163,10 @@ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
|
|
|
886
1163
|
}
|
|
887
1164
|
return placements;
|
|
888
1165
|
}
|
|
889
|
-
function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
|
|
1166
|
+
function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin, draw) {
|
|
890
1167
|
const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
|
|
1168
|
+
const drawW = draw && draw.width > 0 ? draw.width : baseW;
|
|
1169
|
+
const drawH = draw && draw.height > 0 ? draw.height : baseH;
|
|
891
1170
|
ctx.save();
|
|
892
1171
|
ctx.translate(anchor.x, anchor.y);
|
|
893
1172
|
ctx.rotate(config.angle * Math.PI / 180);
|
|
@@ -895,7 +1174,7 @@ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
|
|
|
895
1174
|
ctx.save();
|
|
896
1175
|
ctx.translate(tile.x, tile.y);
|
|
897
1176
|
ctx.rotate(tile.rotation);
|
|
898
|
-
ctx.drawImage(img, -
|
|
1177
|
+
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
|
899
1178
|
ctx.restore();
|
|
900
1179
|
}
|
|
901
1180
|
ctx.restore();
|
|
@@ -917,6 +1196,7 @@ function mod2(n) {
|
|
|
917
1196
|
var MAX_SNAPSHOT_PIXELS = 16e6;
|
|
918
1197
|
var MAX_SNAPSHOT_SCALE = 8;
|
|
919
1198
|
var SNAPSHOT_SHRINK_FACTOR = 2;
|
|
1199
|
+
var TILE_BLEED_DEVICE_PX = 2;
|
|
920
1200
|
var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
921
1201
|
static type = "TiledPattern";
|
|
922
1202
|
/** The layer's real object. Never rendered directly (it is kept at opacity 0). */
|
|
@@ -1006,7 +1286,8 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
|
1006
1286
|
const { width, height } = this.area;
|
|
1007
1287
|
if (width <= 0 || height <= 0) return;
|
|
1008
1288
|
const { tileW, tileH } = this.liveTile();
|
|
1009
|
-
const
|
|
1289
|
+
const scale = contextScale(ctx);
|
|
1290
|
+
const snapshot = this.ensureSnapshot(scale * (tileW / Math.max(1, this.baseW())));
|
|
1010
1291
|
if (!snapshot) return;
|
|
1011
1292
|
const centre = this.getCenterPoint();
|
|
1012
1293
|
const angle = this.liveAngle();
|
|
@@ -1017,7 +1298,17 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
|
1017
1298
|
const shift = this.shiftVector(tileW, tileH, angle);
|
|
1018
1299
|
const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
|
|
1019
1300
|
const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
|
|
1020
|
-
drawTiles(
|
|
1301
|
+
drawTiles(
|
|
1302
|
+
ctx,
|
|
1303
|
+
snapshot,
|
|
1304
|
+
config,
|
|
1305
|
+
width,
|
|
1306
|
+
height,
|
|
1307
|
+
tileW,
|
|
1308
|
+
tileH,
|
|
1309
|
+
origin,
|
|
1310
|
+
this.drawSize(tileW, tileH, TILE_BLEED_DEVICE_PX / scale)
|
|
1311
|
+
);
|
|
1021
1312
|
ctx.restore();
|
|
1022
1313
|
}
|
|
1023
1314
|
/** Raster fallback for SVG export — one `<image>` covering the print area. */
|
|
@@ -1043,7 +1334,9 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
|
1043
1334
|
height,
|
|
1044
1335
|
tileW,
|
|
1045
1336
|
tileH,
|
|
1046
|
-
{ x: centre.x - shift.x, y: centre.y - shift.y }
|
|
1337
|
+
{ x: centre.x - shift.x, y: centre.y - shift.y },
|
|
1338
|
+
// The fallback raster is built at 1:1, so a device pixel is a canvas unit.
|
|
1339
|
+
this.drawSize(tileW, tileH, TILE_BLEED_DEVICE_PX)
|
|
1047
1340
|
);
|
|
1048
1341
|
return [
|
|
1049
1342
|
`<g transform="rotate(${-angle}) translate(${-centre.x} ${-centre.y})">`,
|
|
@@ -1083,18 +1376,64 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
|
1083
1376
|
});
|
|
1084
1377
|
return Promise.resolve(copy);
|
|
1085
1378
|
}
|
|
1379
|
+
/**
|
|
1380
|
+
* The source's tile footprint: its bounding box, minus a stroke it reserves
|
|
1381
|
+
* room for but never paints.
|
|
1382
|
+
*
|
|
1383
|
+
* fabric keeps `strokeWidth` inside an object's box whether or not a `stroke`
|
|
1384
|
+
* colour paints it, and the built-in shapes arrive with `strokeWidth: 1` and no
|
|
1385
|
+
* stroke. Stepping the grid by that box puts a transparent seam between every
|
|
1386
|
+
* pair of neighbours at zero spacing: the artwork is a pixel narrower than the
|
|
1387
|
+
* box it is stepped by.
|
|
1388
|
+
*/
|
|
1389
|
+
sourceBox() {
|
|
1390
|
+
const rect = this.source.getBoundingRect();
|
|
1391
|
+
if (paintsStroke(this.source)) return { width: rect.width, height: rect.height };
|
|
1392
|
+
const dims = this.source._getTransformedDimensions({ strokeWidth: 0 });
|
|
1393
|
+
const radians = (this.source.angle ?? 0) * Math.PI / 180;
|
|
1394
|
+
const cos = Math.abs(Math.cos(radians));
|
|
1395
|
+
const sin = Math.abs(Math.sin(radians));
|
|
1396
|
+
return {
|
|
1397
|
+
width: dims.x * cos + dims.y * sin,
|
|
1398
|
+
height: dims.x * sin + dims.y * cos
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1086
1401
|
/** The source's on-canvas width, before the tile scale. */
|
|
1087
1402
|
baseW() {
|
|
1088
|
-
return Math.max(1, this.
|
|
1403
|
+
return Math.max(1, this.sourceBox().width);
|
|
1404
|
+
}
|
|
1405
|
+
/** The source's on-canvas height, before the tile scale. */
|
|
1406
|
+
baseH() {
|
|
1407
|
+
return Math.max(1, this.sourceBox().height);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Size to paint the snapshot at, for a tile of `tileW × tileH`.
|
|
1411
|
+
*
|
|
1412
|
+
* The snapshot is NOT exactly the source's box: `toCanvasElement` rounds the
|
|
1413
|
+
* raster up to whole pixels and pads it further for a shadow. Painting it into
|
|
1414
|
+
* the tile step would squeeze the artwork inside that padding — every tile
|
|
1415
|
+
* shrinks by up to a pixel and the grid shows transparent seams at zero
|
|
1416
|
+
* spacing. So the bitmap is painted at its own footprint, scaled by the same
|
|
1417
|
+
* factor the tile is, and the step stays the source's box.
|
|
1418
|
+
*/
|
|
1419
|
+
drawSize(tileW, tileH, bleed = 0) {
|
|
1420
|
+
const snapshot = this.snapshotEl;
|
|
1421
|
+
if (!snapshot || this.snapshotScale <= 0) {
|
|
1422
|
+
return { width: tileW + bleed, height: tileH + bleed };
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
width: snapshot.width / this.snapshotScale * (tileW / this.baseW()) + bleed,
|
|
1426
|
+
height: snapshot.height / this.snapshotScale * (tileH / this.baseH()) + bleed
|
|
1427
|
+
};
|
|
1089
1428
|
}
|
|
1090
1429
|
/** Tile size from the config alone, ignoring any in-flight gesture. */
|
|
1091
1430
|
baseTile() {
|
|
1092
|
-
const
|
|
1431
|
+
const box = this.sourceBox();
|
|
1093
1432
|
const scale = Math.max(1, this.config.scale ?? 100) / 100;
|
|
1094
1433
|
const floor = this.tileFloor();
|
|
1095
1434
|
return {
|
|
1096
|
-
tileW: Math.max(floor,
|
|
1097
|
-
tileH: Math.max(floor,
|
|
1435
|
+
tileW: Math.max(floor, box.width * scale),
|
|
1436
|
+
tileH: Math.max(floor, box.height * scale)
|
|
1098
1437
|
};
|
|
1099
1438
|
}
|
|
1100
1439
|
/** Tile size as drawn right now, including a live scale gesture. */
|
|
@@ -1157,6 +1496,12 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
|
1157
1496
|
return Math.max(0.05, Math.min(requested, budgeted));
|
|
1158
1497
|
}
|
|
1159
1498
|
};
|
|
1499
|
+
function paintsStroke(object) {
|
|
1500
|
+
if (!object.strokeWidth || object.strokeWidth <= 0) return false;
|
|
1501
|
+
const { stroke } = object;
|
|
1502
|
+
if (typeof stroke === "string") return stroke !== "" && stroke !== "transparent";
|
|
1503
|
+
return stroke !== null && stroke !== void 0;
|
|
1504
|
+
}
|
|
1160
1505
|
function clampPercent(value) {
|
|
1161
1506
|
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
1162
1507
|
return Math.max(-100, Math.min(100, value));
|
|
@@ -1395,90 +1740,153 @@ function restoreLocks(obj, locks) {
|
|
|
1395
1740
|
|
|
1396
1741
|
// src/text-curve.ts
|
|
1397
1742
|
import { Path } from "fabric";
|
|
1398
|
-
|
|
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
|
+
};
|
|
1399
1753
|
var MIN_ARC = 0.5;
|
|
1400
1754
|
var FULL_CIRCLE_ARC = 99.5;
|
|
1401
1755
|
var MIN_SWEEP = 0.12;
|
|
1402
|
-
var WAVE_PERIOD_EM = 4.1;
|
|
1403
1756
|
var WAVE_AMPLITUDE_EM = 0.9;
|
|
1757
|
+
var MIN_WAVE_LENGTH = 1;
|
|
1758
|
+
var MAX_WAVE_LENGTH = 12;
|
|
1404
1759
|
var WAVE_STEP = 6;
|
|
1405
|
-
var
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
return !!object && typeof object.text === "string";
|
|
1760
|
+
var MAX_WAVE_STEPS = 4e3;
|
|
1761
|
+
function round(value) {
|
|
1762
|
+
return Math.round(value * 100) / 100;
|
|
1409
1763
|
}
|
|
1410
|
-
function
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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;
|
|
1779
|
+
}
|
|
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 };
|
|
1420
1788
|
}
|
|
1421
|
-
function
|
|
1422
|
-
const
|
|
1423
|
-
const
|
|
1424
|
-
const full = magnitude >= FULL_CIRCLE_ARC;
|
|
1425
|
-
const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
|
|
1426
|
-
const radius = width / sweep;
|
|
1789
|
+
function arcLinePath(width, geometry, radius, rotation) {
|
|
1790
|
+
const { sweep, direction, baseRadius } = geometry;
|
|
1791
|
+
const full = sweep >= Math.PI * 2 - 1e-6;
|
|
1427
1792
|
const centerX = width / 2;
|
|
1428
1793
|
const point = (angle) => [
|
|
1429
1794
|
centerX + radius * Math.sin(angle),
|
|
1430
|
-
direction * radius *
|
|
1795
|
+
direction * (baseRadius - radius * Math.cos(angle))
|
|
1431
1796
|
];
|
|
1432
1797
|
const sweepFlag = direction > 0 ? 1 : 0;
|
|
1433
1798
|
const format = ([x, y]) => `${round(x)} ${round(y)}`;
|
|
1799
|
+
const r = round(radius);
|
|
1434
1800
|
if (full) {
|
|
1435
|
-
const start2 = point(-Math.PI);
|
|
1436
|
-
const
|
|
1801
|
+
const start2 = point(rotation - Math.PI);
|
|
1802
|
+
const opposite = point(rotation);
|
|
1437
1803
|
return {
|
|
1438
1804
|
data: [
|
|
1439
1805
|
`M ${format(start2)}`,
|
|
1440
|
-
`A ${
|
|
1441
|
-
`A ${
|
|
1806
|
+
`A ${r} ${r} 0 0 ${sweepFlag} ${format(opposite)}`,
|
|
1807
|
+
`A ${r} ${r} 0 0 ${sweepFlag} ${format(start2)}`
|
|
1442
1808
|
].join(" "),
|
|
1443
|
-
length:
|
|
1809
|
+
length: radius * Math.PI * 2
|
|
1444
1810
|
};
|
|
1445
1811
|
}
|
|
1446
|
-
const start = point(-sweep / 2);
|
|
1447
|
-
const end = point(sweep / 2);
|
|
1812
|
+
const start = point(rotation - sweep / 2);
|
|
1813
|
+
const end = point(rotation + sweep / 2);
|
|
1448
1814
|
const largeArc = sweep > Math.PI ? 1 : 0;
|
|
1449
1815
|
return {
|
|
1450
|
-
data: `M ${format(start)} A ${
|
|
1451
|
-
|
|
1452
|
-
length: width
|
|
1816
|
+
data: `M ${format(start)} A ${r} ${r} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
|
|
1817
|
+
length: radius * sweep
|
|
1453
1818
|
};
|
|
1454
1819
|
}
|
|
1455
|
-
function
|
|
1456
|
-
const amplitude =
|
|
1457
|
-
const period = Math.max(1, fontSize *
|
|
1458
|
-
const
|
|
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)));
|
|
1459
1826
|
const commands = [];
|
|
1460
1827
|
let length = 0;
|
|
1461
1828
|
let previous = null;
|
|
1462
1829
|
for (let index = 0; index <= steps; index++) {
|
|
1463
1830
|
const x = width * index / steps;
|
|
1464
|
-
const y = amplitude * Math.sin(x / period * Math.PI * 2);
|
|
1831
|
+
const y = dy + amplitude * Math.sin(x / period * Math.PI * 2 + phase);
|
|
1465
1832
|
if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
|
|
1466
1833
|
previous = [x, y];
|
|
1467
1834
|
commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
|
|
1468
1835
|
}
|
|
1469
1836
|
return { data: commands.join(" "), length };
|
|
1470
1837
|
}
|
|
1471
|
-
function
|
|
1472
|
-
|
|
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;
|
|
1473
1856
|
}
|
|
1474
1857
|
function buildCurvePathData(config, width, fontSize) {
|
|
1475
|
-
|
|
1476
|
-
if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
|
|
1477
|
-
return null;
|
|
1858
|
+
return buildCurveLinePaths(config, width, fontSize)?.[0] ?? null;
|
|
1478
1859
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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 });
|
|
1482
1890
|
}
|
|
1483
1891
|
var TextCurveManager = class {
|
|
1484
1892
|
constructor(canvas, layers, history, events) {
|
|
@@ -1495,33 +1903,35 @@ var TextCurveManager = class {
|
|
|
1495
1903
|
get(layerId) {
|
|
1496
1904
|
const layer = this.layers.get(layerId);
|
|
1497
1905
|
if (!layer || !isCurvable(layer.fabricObject)) return null;
|
|
1498
|
-
return layer.meta.curve ?? {
|
|
1906
|
+
return normalizeTextCurve(layer.meta.curve ?? {});
|
|
1499
1907
|
}
|
|
1500
1908
|
isCurved(layerId) {
|
|
1501
1909
|
const curve = this.get(layerId);
|
|
1502
|
-
return !!curve && (
|
|
1910
|
+
return !!curve && bendsBaseline(curve);
|
|
1503
1911
|
}
|
|
1504
|
-
/** Apply (or update) the curve on a text layer.
|
|
1912
|
+
/** Apply (or update) the curve on a text layer. A straight config clears it. */
|
|
1505
1913
|
apply(layerId, config, save = true) {
|
|
1506
1914
|
const layer = this.layers.get(layerId);
|
|
1507
1915
|
if (!layer || !isCurvable(layer.fabricObject)) return false;
|
|
1508
|
-
const next =
|
|
1916
|
+
const next = normalizeTextCurve(config);
|
|
1509
1917
|
const text = layer.fabricObject;
|
|
1510
1918
|
const run = measureText(text);
|
|
1511
|
-
|
|
1512
|
-
if (!curve) {
|
|
1919
|
+
if (!bendsBaseline(next)) {
|
|
1513
1920
|
this.detach(text, layer.meta.curveWidth);
|
|
1514
|
-
delete layer.meta.curve;
|
|
1515
1921
|
delete layer.meta.curveWidth;
|
|
1922
|
+
layer.meta.curve = next;
|
|
1516
1923
|
} else {
|
|
1517
1924
|
if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
|
|
1518
1925
|
text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
|
|
1519
|
-
text.
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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);
|
|
1525
1935
|
layer.meta.curve = next;
|
|
1526
1936
|
}
|
|
1527
1937
|
text.initDimensions?.();
|
|
@@ -1534,12 +1944,14 @@ var TextCurveManager = class {
|
|
|
1534
1944
|
}
|
|
1535
1945
|
/** Remove the curve, restoring the authored text box width. */
|
|
1536
1946
|
clear(layerId, save = true) {
|
|
1537
|
-
|
|
1947
|
+
const layer = this.layers.get(layerId);
|
|
1948
|
+
const shape = layer?.meta.curve?.shape;
|
|
1949
|
+
return this.apply(layerId, { shape }, save);
|
|
1538
1950
|
}
|
|
1539
1951
|
/**
|
|
1540
|
-
* Rebuild the
|
|
1541
|
-
* font size all change the
|
|
1542
|
-
* without this the curve keeps
|
|
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.
|
|
1543
1955
|
*/
|
|
1544
1956
|
refresh(layerId, save = false) {
|
|
1545
1957
|
const curve = this.layers.get(layerId)?.meta.curve;
|
|
@@ -1552,7 +1964,77 @@ var TextCurveManager = class {
|
|
|
1552
1964
|
if (layer.meta.curve) this.refresh(layer.id);
|
|
1553
1965
|
}
|
|
1554
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
|
+
}
|
|
1555
2036
|
detach(text, authoredWidth) {
|
|
2037
|
+
this.unpatchLineMeasure(text);
|
|
1556
2038
|
text.set({ path: null, pathStartOffset: 0 });
|
|
1557
2039
|
if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
|
|
1558
2040
|
}
|
|
@@ -2083,6 +2565,11 @@ function restoreLayer(editor, serialized, fabricObject) {
|
|
|
2083
2565
|
if (serialized.opacity !== 1) {
|
|
2084
2566
|
editor.layers.setOpacity(layer.id, serialized.opacity);
|
|
2085
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
|
+
}
|
|
2086
2573
|
return layer;
|
|
2087
2574
|
}
|
|
2088
2575
|
|
|
@@ -2658,10 +3145,10 @@ var MaskController = class {
|
|
|
2658
3145
|
};
|
|
2659
3146
|
|
|
2660
3147
|
// src/masks/manager.ts
|
|
2661
|
-
import { Group as
|
|
3148
|
+
import { Group as Group6 } from "fabric";
|
|
2662
3149
|
|
|
2663
3150
|
// src/masks/compose.ts
|
|
2664
|
-
import { Group, Rect as Rect2 } from "fabric";
|
|
3151
|
+
import { Group as Group2, Rect as Rect2 } from "fabric";
|
|
2665
3152
|
var MODE_OPERATION = {
|
|
2666
3153
|
add: "source-over",
|
|
2667
3154
|
subtract: "destination-out",
|
|
@@ -2698,7 +3185,7 @@ function composeMaskGroup(children, entries, options) {
|
|
|
2698
3185
|
});
|
|
2699
3186
|
const first = entries.find((entry) => entry.visible);
|
|
2700
3187
|
const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
|
|
2701
|
-
return new
|
|
3188
|
+
return new Group2(withBase, {
|
|
2702
3189
|
absolutePositioned: options.absolute,
|
|
2703
3190
|
// Cached, so the children's compositing operations resolve against each
|
|
2704
3191
|
// other instead of against the page underneath the mask.
|
|
@@ -2874,7 +3361,7 @@ var MaskEditController = class {
|
|
|
2874
3361
|
};
|
|
2875
3362
|
|
|
2876
3363
|
// src/masks/store.ts
|
|
2877
|
-
import { Group as
|
|
3364
|
+
import { Group as Group5 } from "fabric";
|
|
2878
3365
|
|
|
2879
3366
|
// src/masks/host.ts
|
|
2880
3367
|
import { Rect as Rect3 } from "fabric";
|
|
@@ -2931,9 +3418,9 @@ function hostBoxOf(canvas, host, absolute) {
|
|
|
2931
3418
|
}
|
|
2932
3419
|
|
|
2933
3420
|
// src/masks/install.ts
|
|
2934
|
-
import { Group as
|
|
3421
|
+
import { Group as Group4 } from "fabric";
|
|
2935
3422
|
function convertSpace(host, sources, absolute) {
|
|
2936
|
-
const wasAbsolute = host.clipPath instanceof
|
|
3423
|
+
const wasAbsolute = host.clipPath instanceof Group4 ? host.clipPath.absolutePositioned : absolute;
|
|
2937
3424
|
if (absolute === wasAbsolute) return;
|
|
2938
3425
|
for (const source of sources) {
|
|
2939
3426
|
if (absolute) toCanvasSpace(source, host);
|
|
@@ -3025,7 +3512,7 @@ var MaskStackStore = class {
|
|
|
3025
3512
|
const clip = host.clipPath;
|
|
3026
3513
|
if (!clip) return [];
|
|
3027
3514
|
const entries = this.list(target);
|
|
3028
|
-
if (entries.length === 0 || !(clip instanceof
|
|
3515
|
+
if (entries.length === 0 || !(clip instanceof Group5)) return [asObject(clip)];
|
|
3029
3516
|
const children = unwrapGroup(clip);
|
|
3030
3517
|
const extra = children.length - entries.length;
|
|
3031
3518
|
return extra > 0 ? children.slice(extra) : children;
|
|
@@ -3125,7 +3612,7 @@ var LayerMaskManager = class extends MaskStackStore {
|
|
|
3125
3612
|
if (index === -1) return false;
|
|
3126
3613
|
this.endEdit(false);
|
|
3127
3614
|
this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
|
|
3128
|
-
const group = host.clipPath instanceof
|
|
3615
|
+
const group = host.clipPath instanceof Group6 ? host.clipPath : null;
|
|
3129
3616
|
if (!group) return false;
|
|
3130
3617
|
const children = group.getObjects();
|
|
3131
3618
|
const child = children[children.length - entries.length + index];
|
|
@@ -3520,7 +4007,7 @@ var CanvasEditor = class {
|
|
|
3520
4007
|
}
|
|
3521
4008
|
}
|
|
3522
4009
|
addText(text, options) {
|
|
3523
|
-
const textbox = new
|
|
4010
|
+
const textbox = new Textbox2(text, {
|
|
3524
4011
|
fontSize: 32,
|
|
3525
4012
|
fontFamily: "Arial",
|
|
3526
4013
|
fill: "#000000",
|
|
@@ -3561,20 +4048,30 @@ var CanvasEditor = class {
|
|
|
3561
4048
|
return value === void 0 ? token : escapeXml(value);
|
|
3562
4049
|
})
|
|
3563
4050
|
);
|
|
3564
|
-
const
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
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;
|
|
3573
4074
|
});
|
|
3574
|
-
const layer = this.layers.add("template", group, template.name);
|
|
3575
|
-
this.layers.select(layer.id);
|
|
3576
|
-
this.history.save();
|
|
3577
|
-
return layer;
|
|
3578
4075
|
}
|
|
3579
4076
|
removeLayer(id) {
|
|
3580
4077
|
if (this.crop.activeLayerId() === id) this.crop.cancel();
|
|
@@ -3586,6 +4083,10 @@ var CanvasEditor = class {
|
|
|
3586
4083
|
selectLayer(id) {
|
|
3587
4084
|
this.layers.select(id);
|
|
3588
4085
|
}
|
|
4086
|
+
/** Select several layers at once — what a panel's shift/⌘ click drives. */
|
|
4087
|
+
selectLayers(ids) {
|
|
4088
|
+
this.layers.selectMany(ids);
|
|
4089
|
+
}
|
|
3589
4090
|
getSelectedLayer() {
|
|
3590
4091
|
const active = this.canvas.getActiveObject();
|
|
3591
4092
|
if (!active) return null;
|
|
@@ -3648,42 +4149,55 @@ var CanvasEditor = class {
|
|
|
3648
4149
|
this.history.save();
|
|
3649
4150
|
return true;
|
|
3650
4151
|
}
|
|
3651
|
-
/**
|
|
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
|
+
*/
|
|
3652
4157
|
async groupLayers(ids, name = "Group") {
|
|
3653
4158
|
const uniqueIds = [...new Set(ids)];
|
|
3654
|
-
const
|
|
3655
|
-
if (
|
|
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;
|
|
3656
4164
|
return this.history.transaction(() => {
|
|
3657
|
-
const
|
|
3658
|
-
const objects =
|
|
3659
|
-
for (const layer of
|
|
3660
|
-
|
|
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);
|
|
3661
4186
|
const grouped = this.layers.add("group", group, name);
|
|
3662
|
-
|
|
4187
|
+
for (const child of children) child.parentId = grouped.id;
|
|
4188
|
+
grouped.children = children;
|
|
4189
|
+
this.layers.reorder(grouped.id, insertIndex);
|
|
3663
4190
|
this.layers.select(grouped.id);
|
|
3664
4191
|
this.history.save();
|
|
3665
4192
|
return grouped;
|
|
3666
4193
|
});
|
|
3667
4194
|
}
|
|
3668
|
-
/**
|
|
4195
|
+
/** Split a group back into the layers it holds, in place. */
|
|
3669
4196
|
async ungroupLayer(id) {
|
|
3670
4197
|
const grouped = this.layers.get(id);
|
|
3671
|
-
|
|
3672
|
-
if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
|
|
3673
|
-
const group = grouped.fabricObject;
|
|
4198
|
+
if (!grouped || grouped.type !== "group" || grouped.children.length === 0) return [];
|
|
3674
4199
|
return this.history.transaction(() => {
|
|
3675
|
-
const
|
|
3676
|
-
this.layers.remove(id);
|
|
3677
|
-
const restored = objects.map((object, index) => {
|
|
3678
|
-
object.setCoords();
|
|
3679
|
-
const data = childData[index];
|
|
3680
|
-
const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
|
|
3681
|
-
if (data?.meta) layer.meta = structuredClone(data.meta);
|
|
3682
|
-
if (data && !data.visible) this.layers.setVisibility(layer.id, false);
|
|
3683
|
-
if (data?.locked) this.layers.setLocked(layer.id, true);
|
|
3684
|
-
if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
|
|
3685
|
-
return layer;
|
|
3686
|
-
});
|
|
4200
|
+
const restored = this.layers.explode(grouped);
|
|
3687
4201
|
this.history.save();
|
|
3688
4202
|
this.layers.select(restored[0]?.id ?? null);
|
|
3689
4203
|
return restored;
|
|
@@ -4430,6 +4944,7 @@ export {
|
|
|
4430
4944
|
applyLayerShadow,
|
|
4431
4945
|
applyObjectSelectionStyle,
|
|
4432
4946
|
applySelectionStyle,
|
|
4947
|
+
buildCurveLinePaths,
|
|
4433
4948
|
buildCurvePathData,
|
|
4434
4949
|
clamp,
|
|
4435
4950
|
clearTextureMaskCache,
|
|
@@ -4441,6 +4956,7 @@ export {
|
|
|
4441
4956
|
displaceRgba,
|
|
4442
4957
|
drawTiles,
|
|
4443
4958
|
escapeXml,
|
|
4959
|
+
explodeTemplateSvg,
|
|
4444
4960
|
exportDataURL,
|
|
4445
4961
|
exportMockup,
|
|
4446
4962
|
exportPNG,
|
|
@@ -4453,6 +4969,7 @@ export {
|
|
|
4453
4969
|
isShapeMaskId,
|
|
4454
4970
|
isTextureMaskId,
|
|
4455
4971
|
needsAbsoluteSpace,
|
|
4972
|
+
normalizeTextCurve,
|
|
4456
4973
|
readLayerShadow,
|
|
4457
4974
|
renderTextureMask,
|
|
4458
4975
|
resetTransform,
|
|
@@ -4461,6 +4978,8 @@ export {
|
|
|
4461
4978
|
sanitizeSvg,
|
|
4462
4979
|
serializeEditor,
|
|
4463
4980
|
shapeMaskPathData,
|
|
4981
|
+
textPathCurve,
|
|
4982
|
+
textPathSpec,
|
|
4464
4983
|
toCanvasSpace,
|
|
4465
4984
|
toHostSpace,
|
|
4466
4985
|
unwrapGroup
|