animot-presenter 0.6.3 → 0.6.5
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/AnimotPresenter.svelte +380 -57
- package/dist/cdn/animot-presenter.css +1 -1
- package/dist/cdn/animot-presenter.esm.js +9840 -7600
- package/dist/cdn/animot-presenter.min.js +14 -10
- package/dist/styles/presenter.css +38 -0
- package/dist/types.d.ts +33 -2
- package/dist/utils/arrow-clip-draw.d.ts +6 -3
- package/dist/utils/arrow-clip-draw.js +78 -63
- package/dist/utils/freehand.d.ts +26 -0
- package/dist/utils/freehand.js +70 -0
- package/dist/utils/path-morph.d.ts +89 -0
- package/dist/utils/path-morph.js +420 -0
- package/dist/utils/svg-path-edit.d.ts +37 -0
- package/dist/utils/svg-path-edit.js +279 -0
- package/package.json +3 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// flubber: import via NAMESPACE + default fallback, NOT named imports. Named
|
|
2
|
+
// imports (`import { interpolateAll } from 'flubber'`) resolve to flubber's ESM
|
|
3
|
+
// `src/` entry, whose `interpolateAll` breaks under Rollup's production bundle
|
|
4
|
+
// (it threw → makeMorphParts fell to its joined hard cut → multi-subpath morphs
|
|
5
|
+
// like the logo SNAPPED, while single-path morphs via `interpolate` were fine).
|
|
6
|
+
// The namespace+`.default ?? ns` form resolves to flubber's bundled CJS build
|
|
7
|
+
// where every function works — this is exactly what the editor app does, and it
|
|
8
|
+
// morphs correctly there. The "default is not exported" build warning is benign.
|
|
9
|
+
// Import the explicit CJS build, NOT the package root. The root's `module`
|
|
10
|
+
// (ESM) entry resolves under Rollup to flubber's `src/` files, whose
|
|
11
|
+
// `interpolateAll` breaks in the production bundle (multi-subpath morphs like
|
|
12
|
+
// the logo SNAP). The bundled CJS build has the working implementation.
|
|
13
|
+
// @ts-ignore — CJS build has no type declarations; used as `any` below.
|
|
14
|
+
import * as flubberNs from 'flubber/build/flubber.min.js';
|
|
15
|
+
const flubber = flubberNs.default ?? flubberNs;
|
|
16
|
+
const interpolate = flubber.interpolate;
|
|
17
|
+
const interpolateAll = flubber.interpolateAll;
|
|
18
|
+
const separate = flubber.separate;
|
|
19
|
+
const combine = flubber.combine;
|
|
20
|
+
const splitPathString = flubber.splitPathString;
|
|
21
|
+
import { parsePath, serializePath } from './svg-path-edit';
|
|
22
|
+
import { drawElementToPath } from './freehand';
|
|
23
|
+
/**
|
|
24
|
+
* Path morphing helpers shared by the editor preview and the presenter.
|
|
25
|
+
*
|
|
26
|
+
* The headline use is SVG→SVG (and shape→shape / shape→SVG) morphing across
|
|
27
|
+
* slides: extract a single path `d` from each endpoint, then build a flubber
|
|
28
|
+
* interpolator that produces an in-between `d` for any progress 0..1.
|
|
29
|
+
*/
|
|
30
|
+
/** Ellipse as 4 cubic Béziers (kappa) — editable, unlike arc commands. */
|
|
31
|
+
function ellipsePath(cx, cy, rx, ry) {
|
|
32
|
+
const k = 0.5522847498307936;
|
|
33
|
+
const ox = rx * k, oy = ry * k;
|
|
34
|
+
return [
|
|
35
|
+
`M${cx - rx},${cy}`,
|
|
36
|
+
`C${cx - rx},${cy - oy} ${cx - ox},${cy - ry} ${cx},${cy - ry}`,
|
|
37
|
+
`C${cx + ox},${cy - ry} ${cx + rx},${cy - oy} ${cx + rx},${cy}`,
|
|
38
|
+
`C${cx + rx},${cy + oy} ${cx + ox},${cy + ry} ${cx},${cy + ry}`,
|
|
39
|
+
`C${cx - ox},${cy + ry} ${cx - rx},${cy + oy} ${cx - rx},${cy}`,
|
|
40
|
+
'Z'
|
|
41
|
+
].join(' ');
|
|
42
|
+
}
|
|
43
|
+
/** Build a `d` string for a shape type, fitted to a w×h box (origin 0,0). */
|
|
44
|
+
export function shapeToPath(type, w, h, borderRadius = 0) {
|
|
45
|
+
const r = Math.max(0, Math.min(borderRadius, Math.min(w, h) / 2));
|
|
46
|
+
switch (type) {
|
|
47
|
+
case 'rectangle': {
|
|
48
|
+
if (r <= 0)
|
|
49
|
+
return `M0,0 L${w},0 L${w},${h} L0,${h} Z`;
|
|
50
|
+
return `M${r},0 L${w - r},0 Q${w},0 ${w},${r} L${w},${h - r} Q${w},${h} ${w - r},${h} L${r},${h} Q0,${h} 0,${h - r} L0,${r} Q0,0 ${r},0 Z`;
|
|
51
|
+
}
|
|
52
|
+
case 'circle':
|
|
53
|
+
case 'ellipse':
|
|
54
|
+
return ellipsePath(w / 2, h / 2, w / 2, h / 2);
|
|
55
|
+
case 'triangle':
|
|
56
|
+
return `M${w / 2},0 L${w},${h} L0,${h} Z`;
|
|
57
|
+
case 'hexagon': {
|
|
58
|
+
// Pointy-left/right flat hexagon fitted to the box.
|
|
59
|
+
const q = w * 0.25;
|
|
60
|
+
return `M${q},0 L${w - q},0 L${w},${h / 2} L${w - q},${h} L${q},${h} L0,${h / 2} Z`;
|
|
61
|
+
}
|
|
62
|
+
case 'star': {
|
|
63
|
+
const cx = w / 2, cy = h / 2, outer = Math.min(w, h) / 2, inner = outer * 0.5;
|
|
64
|
+
let d = '';
|
|
65
|
+
for (let i = 0; i < 10; i++) {
|
|
66
|
+
const rad = i % 2 === 0 ? outer : inner;
|
|
67
|
+
const ang = -Math.PI / 2 + (i * Math.PI) / 5;
|
|
68
|
+
const x = cx + rad * Math.cos(ang);
|
|
69
|
+
const y = cy + rad * Math.sin(ang);
|
|
70
|
+
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
|
|
71
|
+
}
|
|
72
|
+
return d + 'Z';
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
return `M0,0 L${w},0 L${w},${h} L0,${h} Z`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const NUM = '[-+]?[0-9]*\\.?[0-9]+(?:[eE][-+]?[0-9]+)?';
|
|
79
|
+
function nums(s) {
|
|
80
|
+
return (s.match(new RegExp(NUM, 'g')) ?? []).map(Number);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Convert a basic SVG primitive element to a path `d`. Returns '' for
|
|
84
|
+
* unsupported tags. Handles path/rect/circle/ellipse/line/polyline/polygon.
|
|
85
|
+
*/
|
|
86
|
+
function primitiveToPath(tag, attrs) {
|
|
87
|
+
const attr = (name) => {
|
|
88
|
+
const m = attrs.match(new RegExp(name + '\\s*=\\s*["\']([^"\']*)["\']', 'i'));
|
|
89
|
+
return m ? m[1] : null;
|
|
90
|
+
};
|
|
91
|
+
switch (tag.toLowerCase()) {
|
|
92
|
+
case 'path':
|
|
93
|
+
return attr('d') ?? '';
|
|
94
|
+
case 'rect': {
|
|
95
|
+
const x = Number(attr('x') ?? 0), y = Number(attr('y') ?? 0);
|
|
96
|
+
const w = Number(attr('width') ?? 0), h = Number(attr('height') ?? 0);
|
|
97
|
+
const rx = Number(attr('rx') ?? 0);
|
|
98
|
+
if (rx > 0)
|
|
99
|
+
return `M${x + rx},${y} L${x + w - rx},${y} Q${x + w},${y} ${x + w},${y + rx} L${x + w},${y + h - rx} Q${x + w},${y + h} ${x + w - rx},${y + h} L${x + rx},${y + h} Q${x},${y + h} ${x},${y + h - rx} L${x},${y + rx} Q${x},${y} ${x + rx},${y} Z`;
|
|
100
|
+
return `M${x},${y} L${x + w},${y} L${x + w},${y + h} L${x},${y + h} Z`;
|
|
101
|
+
}
|
|
102
|
+
case 'circle': {
|
|
103
|
+
const cx = Number(attr('cx') ?? 0), cy = Number(attr('cy') ?? 0), rr = Number(attr('r') ?? 0);
|
|
104
|
+
return ellipsePath(cx, cy, rr, rr);
|
|
105
|
+
}
|
|
106
|
+
case 'ellipse': {
|
|
107
|
+
const cx = Number(attr('cx') ?? 0), cy = Number(attr('cy') ?? 0);
|
|
108
|
+
const rx = Number(attr('rx') ?? 0), ry = Number(attr('ry') ?? 0);
|
|
109
|
+
return ellipsePath(cx, cy, rx, ry);
|
|
110
|
+
}
|
|
111
|
+
case 'line': {
|
|
112
|
+
const [x1, y1, x2, y2] = [Number(attr('x1') ?? 0), Number(attr('y1') ?? 0), Number(attr('x2') ?? 0), Number(attr('y2') ?? 0)];
|
|
113
|
+
return `M${x1},${y1} L${x2},${y2}`;
|
|
114
|
+
}
|
|
115
|
+
case 'polyline':
|
|
116
|
+
case 'polygon': {
|
|
117
|
+
const pts = nums(attr('points') ?? '');
|
|
118
|
+
if (pts.length < 4)
|
|
119
|
+
return '';
|
|
120
|
+
let d = `M${pts[0]},${pts[1]}`;
|
|
121
|
+
for (let i = 2; i < pts.length; i += 2)
|
|
122
|
+
d += ` L${pts[i]},${pts[i + 1]}`;
|
|
123
|
+
return tag.toLowerCase() === 'polygon' ? d + ' Z' : d;
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Extract drawable path `d` strings from raw SVG markup. Returns an array of
|
|
131
|
+
* subpath `d` strings (one per primitive). Empty when nothing is drawable.
|
|
132
|
+
*/
|
|
133
|
+
export function extractPaths(svgContent) {
|
|
134
|
+
const out = [];
|
|
135
|
+
const re = /<(path|rect|circle|ellipse|line|polyline|polygon)\b([^>]*)\/?>/gi;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = re.exec(svgContent)) !== null) {
|
|
138
|
+
const d = primitiveToPath(m[1], m[2]);
|
|
139
|
+
if (d.trim())
|
|
140
|
+
out.push(d.trim());
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Resolve a single primitive's own fill (style="fill:…" or fill="…"). */
|
|
145
|
+
function primitiveFill(attrs) {
|
|
146
|
+
const style = attrs.match(/style\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
147
|
+
if (style) {
|
|
148
|
+
const f = style.match(/fill\s*:\s*([^;]+)/i)?.[1]?.trim();
|
|
149
|
+
if (f && f.toLowerCase() !== 'none')
|
|
150
|
+
return f;
|
|
151
|
+
}
|
|
152
|
+
const attr = attrs.match(/\bfill\s*=\s*["']([^"']+)["']/i)?.[1]?.trim();
|
|
153
|
+
if (attr && attr.toLowerCase() !== 'none')
|
|
154
|
+
return attr;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Like extractPaths, but pairs each subpath with its own fill color so a
|
|
159
|
+
* multi-color SVG morphs per-piece (one `<path>` per subpath) instead of
|
|
160
|
+
* flattening to a single representative color. Primitives without their own
|
|
161
|
+
* fill inherit the root `<svg fill="…">` (default black, matching the
|
|
162
|
+
* browser), which is exactly the black outline strokes on icon SVGs.
|
|
163
|
+
*/
|
|
164
|
+
export function extractPathsWithFill(svgContent) {
|
|
165
|
+
const rootFill = svgContent.match(/<svg\b[^>]*?\bfill\s*=\s*["']([^"']+)["']/i)?.[1] || '#000000';
|
|
166
|
+
const out = [];
|
|
167
|
+
const re = /<(path|rect|circle|ellipse|line|polyline|polygon)\b([^>]*)\/?>/gi;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = re.exec(svgContent)) !== null) {
|
|
170
|
+
const d = primitiveToPath(m[1], m[2]);
|
|
171
|
+
if (!d.trim())
|
|
172
|
+
continue;
|
|
173
|
+
out.push({ d: d.trim(), fill: primitiveFill(m[2]) ?? rootFill });
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/** Single combined `d` (all subpaths concatenated) — for simple morphs. */
|
|
178
|
+
export function extractCombinedPath(svgContent) {
|
|
179
|
+
const paths = extractPaths(svgContent);
|
|
180
|
+
if (paths.length === 0)
|
|
181
|
+
return null;
|
|
182
|
+
return paths.join(' ');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Pick a representative fill color from SVG markup — the first non-"none"
|
|
186
|
+
* `fill` attribute. Used to color the flattened morph path (which loses
|
|
187
|
+
* per-subpath fills). Returns null if none found.
|
|
188
|
+
*/
|
|
189
|
+
export function extractFill(svgContent) {
|
|
190
|
+
// Match both `fill="x"` attributes and `fill:x` inside `style="…"` —
|
|
191
|
+
// many real-world SVGs (e.g. svgrepo exports) put the color in a style
|
|
192
|
+
// declaration, which the attribute-only regex would miss (→ gray morph).
|
|
193
|
+
const re = /fill\s*[:=]\s*["']?\s*([^"';\s>]+)/gi;
|
|
194
|
+
let m;
|
|
195
|
+
while ((m = re.exec(svgContent)) !== null) {
|
|
196
|
+
const v = m[1].trim();
|
|
197
|
+
if (v && v.toLowerCase() !== 'none')
|
|
198
|
+
return v;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function parseViewBox(vb) {
|
|
203
|
+
const p = vb.split(/[\s,]+/).map(Number);
|
|
204
|
+
return [p[0] || 0, p[1] || 0, p[2] || 1, p[3] || 1];
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Re-fit a path `d` from its own viewBox into a target W×H box (origin 0,0).
|
|
208
|
+
* Morph stops must share a coordinate space or flubber produces scale jumps;
|
|
209
|
+
* authoring normalizes every stop into the element's box via this. Parses to
|
|
210
|
+
* absolute cubics, scales anchors + handles, re-serializes.
|
|
211
|
+
*/
|
|
212
|
+
export function fitPathToBox(d, sourceViewBox, targetW, targetH) {
|
|
213
|
+
const [vx, vy, vw, vh] = parseViewBox(sourceViewBox);
|
|
214
|
+
const sx = targetW / (vw || 1);
|
|
215
|
+
const sy = targetH / (vh || 1);
|
|
216
|
+
const subs = parsePath(d);
|
|
217
|
+
if (!subs)
|
|
218
|
+
return d;
|
|
219
|
+
for (const sub of subs) {
|
|
220
|
+
for (const a of sub.anchors) {
|
|
221
|
+
a.x = (a.x - vx) * sx;
|
|
222
|
+
a.y = (a.y - vy) * sy;
|
|
223
|
+
if (a.cin) {
|
|
224
|
+
a.cin.x = (a.cin.x - vx) * sx;
|
|
225
|
+
a.cin.y = (a.cin.y - vy) * sy;
|
|
226
|
+
}
|
|
227
|
+
if (a.cout) {
|
|
228
|
+
a.cout.x = (a.cout.x - vx) * sx;
|
|
229
|
+
a.cout.y = (a.cout.y - vy) * sy;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return serializePath(subs);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolve any vector element (shape / svg / draw) to its current geometry as
|
|
237
|
+
* a path `d` plus the viewBox that `d` lives in. Single source of truth for
|
|
238
|
+
* both the morph engine and the "snapshot current" authoring action.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveElementPath(el) {
|
|
241
|
+
if (el.type === 'shape') {
|
|
242
|
+
const s = el;
|
|
243
|
+
return { d: shapeToPath(s.shapeType, s.size.width, s.size.height, s.borderRadius), viewBox: `0 0 ${s.size.width} ${s.size.height}` };
|
|
244
|
+
}
|
|
245
|
+
if (el.type === 'svg') {
|
|
246
|
+
const s = el;
|
|
247
|
+
const d = extractCombinedPath(s.svgContent);
|
|
248
|
+
if (!d)
|
|
249
|
+
return null;
|
|
250
|
+
const vb = s.viewBox ?? s.svgContent.match(/viewBox=["']([^"']+)["']/i)?.[1] ?? `0 0 ${s.size.width} ${s.size.height}`;
|
|
251
|
+
return { d, viewBox: vb };
|
|
252
|
+
}
|
|
253
|
+
if (el.type === 'draw') {
|
|
254
|
+
const p = drawElementToPath(el);
|
|
255
|
+
return { d: p.d, viewBox: p.viewBox };
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Like resolveElementPath but keeps subpaths SEPARATE, each with its own fill,
|
|
261
|
+
* so multi-color SVGs morph per-piece. Shapes/draws return a single part.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveElementPathParts(el) {
|
|
264
|
+
if (el.type === 'shape') {
|
|
265
|
+
const s = el;
|
|
266
|
+
return { parts: [{ d: shapeToPath(s.shapeType, s.size.width, s.size.height, s.borderRadius), fill: s.fillColor || '#888888' }], viewBox: `0 0 ${s.size.width} ${s.size.height}` };
|
|
267
|
+
}
|
|
268
|
+
if (el.type === 'svg') {
|
|
269
|
+
const s = el;
|
|
270
|
+
const parts = extractPathsWithFill(s.svgContent);
|
|
271
|
+
if (!parts.length)
|
|
272
|
+
return null;
|
|
273
|
+
const vb = s.viewBox ?? s.svgContent.match(/viewBox=["']([^"']+)["']/i)?.[1] ?? `0 0 ${s.size.width} ${s.size.height}`;
|
|
274
|
+
return { parts, viewBox: vb };
|
|
275
|
+
}
|
|
276
|
+
if (el.type === 'draw') {
|
|
277
|
+
const p = drawElementToPath(el);
|
|
278
|
+
return { parts: [{ d: p.d, fill: el.color }], viewBox: p.viewBox };
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Build per-piece morph parts between two fill-tagged subpath lists. Each `d`
|
|
284
|
+
* MUST already be in the same coordinate space (caller fits the source into
|
|
285
|
+
* the target viewBox). Picks the right flubber routine by count so 1↔many and
|
|
286
|
+
* many↔1 map correctly, and pairs colors per piece so multi-color SVGs keep
|
|
287
|
+
* their colors instead of flattening to one.
|
|
288
|
+
*/
|
|
289
|
+
export function makeMorphParts(from, to) {
|
|
290
|
+
const opts = { maxSegmentLength: 10 };
|
|
291
|
+
const fromN = from.length, toN = to.length;
|
|
292
|
+
if (fromN === 0 || toN === 0) {
|
|
293
|
+
const d = from[0]?.d || to[0]?.d || 'M0,0';
|
|
294
|
+
return [{ interp: () => d, fromColor: from[0]?.fill || '#888888', toColor: to[0]?.fill || '#888888' }];
|
|
295
|
+
}
|
|
296
|
+
// flubber's matched interpolators give the editor's "liquid" blend (the
|
|
297
|
+
// per-subpath index fallback below morphs piece-by-piece = rigid "lines").
|
|
298
|
+
// These work now that the UMD-context build fix repaired interpolateAll/
|
|
299
|
+
// separate/combine in the bundle; the per-subpath fallback covers any throw.
|
|
300
|
+
try {
|
|
301
|
+
if (fromN === 1 && toN === 1) {
|
|
302
|
+
return [{ interp: interpolate(from[0].d, to[0].d, opts), fromColor: from[0].fill, toColor: to[0].fill }];
|
|
303
|
+
}
|
|
304
|
+
if (fromN === 1 && toN > 1) {
|
|
305
|
+
const arr = separate(from[0].d, to.map((p) => p.d), opts);
|
|
306
|
+
return arr.map((fn, i) => ({ interp: fn, fromColor: from[0].fill, toColor: to[i % toN].fill }));
|
|
307
|
+
}
|
|
308
|
+
if (fromN > 1 && toN === 1) {
|
|
309
|
+
const arr = combine(from.map((p) => p.d), to[0].d, opts);
|
|
310
|
+
return arr.map((fn, i) => ({ interp: fn, fromColor: from[i % fromN].fill, toColor: to[0].fill }));
|
|
311
|
+
}
|
|
312
|
+
if (fromN === toN) {
|
|
313
|
+
const arr = interpolateAll(from.map((p) => p.d), to.map((p) => p.d), opts);
|
|
314
|
+
return arr.map((fn, i) => ({ interp: fn, fromColor: from[i % fromN].fill, toColor: to[i % toN].fill }));
|
|
315
|
+
}
|
|
316
|
+
const merged = from.map((p) => p.d).join(' ');
|
|
317
|
+
const arr = separate(merged, to.map((p) => p.d), opts);
|
|
318
|
+
return arr.map((fn, i) => ({ interp: fn, fromColor: from[0].fill, toColor: to[i % toN].fill }));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// Fallback: per-subpath index interpolation (rigid but never snaps).
|
|
322
|
+
const n = Math.max(fromN, toN);
|
|
323
|
+
const parts = [];
|
|
324
|
+
for (let i = 0; i < n; i++) {
|
|
325
|
+
const fp = from[Math.min(i, fromN - 1)];
|
|
326
|
+
const tp = to[Math.min(i, toN - 1)];
|
|
327
|
+
let interp;
|
|
328
|
+
try {
|
|
329
|
+
interp = interpolate(fp.d, tp.d, opts);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
const a = fp.d, b = tp.d;
|
|
333
|
+
interp = (t) => (t < 0.5 ? a : b);
|
|
334
|
+
}
|
|
335
|
+
parts.push({ interp, fromColor: fp.fill, toColor: tp.fill });
|
|
336
|
+
}
|
|
337
|
+
return parts;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Build an interpolator between two `d` strings. Handles differing subpath
|
|
342
|
+
* counts via flubber's many-to-many interpolation, falling back to a simple
|
|
343
|
+
* single-path interpolate. Always returns a function (t:0..1) => d.
|
|
344
|
+
*/
|
|
345
|
+
export function makePathInterpolator(fromD, toD) {
|
|
346
|
+
const opts = { maxSegmentLength: 10 };
|
|
347
|
+
let fromParts, toParts;
|
|
348
|
+
try {
|
|
349
|
+
fromParts = splitPathString(fromD);
|
|
350
|
+
toParts = splitPathString(toD);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
fromParts = [fromD];
|
|
354
|
+
toParts = [toD];
|
|
355
|
+
}
|
|
356
|
+
if (fromParts.length === 0)
|
|
357
|
+
fromParts = [fromD];
|
|
358
|
+
if (toParts.length === 0)
|
|
359
|
+
toParts = [toD];
|
|
360
|
+
// Single subpath: plain `interpolate`.
|
|
361
|
+
if (fromParts.length === 1 && toParts.length === 1) {
|
|
362
|
+
try {
|
|
363
|
+
return interpolate(fromD, toD, opts);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return (t) => (t < 0.5 ? fromD : toD);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Multi-subpath, EQUAL counts: use interpolateAll's ARRAY form (flubber's
|
|
370
|
+
// smart global matching) and concat manually. Falls back to per-subpath
|
|
371
|
+
// index interpolation for unequal counts or if interpolateAll still throws.
|
|
372
|
+
if (fromParts.length === toParts.length) {
|
|
373
|
+
try {
|
|
374
|
+
// `single: true` asks flubber to morph the multi-subpath shape as one
|
|
375
|
+
// unified path. This is the same behavior used by the app preview.
|
|
376
|
+
return interpolateAll(fromParts, toParts, { ...opts, single: true });
|
|
377
|
+
}
|
|
378
|
+
catch { /* fall through to per-subpath */ }
|
|
379
|
+
}
|
|
380
|
+
const n = Math.max(fromParts.length, toParts.length);
|
|
381
|
+
const interps = [];
|
|
382
|
+
for (let i = 0; i < n; i++) {
|
|
383
|
+
const a = fromParts[Math.min(i, fromParts.length - 1)];
|
|
384
|
+
const b = toParts[Math.min(i, toParts.length - 1)];
|
|
385
|
+
try {
|
|
386
|
+
interps.push(interpolate(a, b, opts));
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
interps.push((t) => (t < 0.5 ? a : b));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return (t) => interps.map((fn) => fn(t)).join(' ');
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Build a single interpolator that morphs through an ordered list of `d`
|
|
396
|
+
* stops. `t` runs 0..1 across the whole sequence; the segment is picked by
|
|
397
|
+
* scaling `t` across (stops.length - 1) and interpolating within it. Used by
|
|
398
|
+
* the within-slide multi-target morph (circle → star → hexagon → …).
|
|
399
|
+
* When `loop` is true the sequence wraps back to the first stop.
|
|
400
|
+
*/
|
|
401
|
+
export function makeSequenceInterpolator(stops, loop = false) {
|
|
402
|
+
const seq = loop && stops.length > 1 ? [...stops, stops[0]] : stops.slice();
|
|
403
|
+
if (seq.length === 0)
|
|
404
|
+
return () => '';
|
|
405
|
+
if (seq.length === 1)
|
|
406
|
+
return () => seq[0];
|
|
407
|
+
const segs = [];
|
|
408
|
+
for (let i = 0; i < seq.length - 1; i++)
|
|
409
|
+
segs.push(makePathInterpolator(seq[i], seq[i + 1]));
|
|
410
|
+
const n = segs.length;
|
|
411
|
+
return (t) => {
|
|
412
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
413
|
+
const scaled = clamped * n;
|
|
414
|
+
let idx = Math.floor(scaled);
|
|
415
|
+
if (idx >= n)
|
|
416
|
+
idx = n - 1;
|
|
417
|
+
const frac = scaled - idx;
|
|
418
|
+
return segs[idx](frac);
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse/serialize an SVG path `d` into an editable cubic-Bézier anchor model
|
|
3
|
+
* used by the node editor. Every segment is normalized to a cubic so each
|
|
4
|
+
* anchor has at most an incoming + outgoing control handle — the same mental
|
|
5
|
+
* model as the motion-path editor.
|
|
6
|
+
*
|
|
7
|
+
* Supported input commands: M m L l H h V v C c S s Q q T t Z z.
|
|
8
|
+
* Arcs (A/a) are not supported; `parsePath` returns null so the caller can
|
|
9
|
+
* keep node-editing disabled for those paths.
|
|
10
|
+
*/
|
|
11
|
+
export interface Anchor {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
/** Incoming control point (absolute). Undefined = straight into anchor. */
|
|
15
|
+
cin?: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
};
|
|
19
|
+
/** Outgoing control point (absolute). */
|
|
20
|
+
cout?: {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface SubPath {
|
|
26
|
+
anchors: Anchor[];
|
|
27
|
+
closed: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function parsePath(d: string): SubPath[] | null;
|
|
30
|
+
/**
|
|
31
|
+
* Reduce anchor count via Ramer–Douglas–Peucker on anchor positions. Used to
|
|
32
|
+
* make a dense freehand outline node-editable (otherwise it has hundreds of
|
|
33
|
+
* handles). Kept anchors retain their Bézier handles; dropped ones become
|
|
34
|
+
* straight. `tolerance` is in the path's own coordinate units.
|
|
35
|
+
*/
|
|
36
|
+
export declare function simplifyAnchors(subs: SubPath[], tolerance: number): SubPath[];
|
|
37
|
+
export declare function serializePath(subs: SubPath[]): string;
|