@motionscript/latex 0.0.0-stage → 0.1.0-alpha.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/CHANGELOG.md +5 -0
- package/LICENSE +201 -0
- package/README.md +67 -3
- package/dist/browser/index.js +61 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/manifest.json +11 -0
- package/dist/cache.d.ts +30 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +53 -0
- package/dist/cache.js.map +1 -0
- package/dist/engine.d.ts +5 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +10 -0
- package/dist/engine.js.map +1 -0
- package/dist/geometry.d.ts +34 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +524 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +151 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +359 -0
- package/dist/node.js.map +1 -0
- package/dist/nodes.d.ts +18 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +18 -0
- package/dist/nodes.js.map +1 -0
- package/dist/tween.d.ts +44 -0
- package/dist/tween.d.ts.map +1 -0
- package/dist/tween.js +233 -0
- package/dist/tween.js.map +1 -0
- package/package.json +68 -3
- package/registry.json +6 -0
- package/src/cache.ts +61 -0
- package/src/engine.ts +11 -0
- package/src/geometry.ts +578 -0
- package/src/index.ts +3 -0
- package/src/node.ts +381 -0
- package/src/nodes.ts +18 -0
- package/src/tween.ts +286 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { RenderContext2D, Graphics2D, EasingFunction, command, node, lerpNumber, NodeConfig, ShapeNode, ShapeProps, InsetsProps, Size2D, SizeConstraints, toPathString, InsetsResolved, property, insetsOps, FillResolved, ShadowResolved, driveCommand, type Command, type CommandArgs, type TweenStepper } from "@motionscript/core";
|
|
2
|
+
import { buildLatexPath, defaultLatexMorph, type AnimatedToken, type LatexMorphStrategy, type LatexToken } from "./engine";
|
|
3
|
+
|
|
4
|
+
export interface LatexProps extends ShapeProps {
|
|
5
|
+
latex: string;
|
|
6
|
+
fontSize: number;
|
|
7
|
+
padding: InsetsProps;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Construction-time-only options: pluggable engine behavior, set once when
|
|
12
|
+
* the node is created. Deliberately *not* part of {@link LatexProps} — every
|
|
13
|
+
* `@property` field accepts either a value or a zero-arg reactive binding
|
|
14
|
+
* (`value | (() => value)`, see `PropInputs`), and the node's own reactive
|
|
15
|
+
* write path (`_writeProp`) treats any function value as the latter. A
|
|
16
|
+
* strategy *is* a function, so it can't be told apart from a binding that
|
|
17
|
+
* computes one — it has to stay outside the reactive prop system entirely.
|
|
18
|
+
*/
|
|
19
|
+
export interface LatexStrategies {
|
|
20
|
+
/** How the node morphs between two formulas. Defaults to `defaultLatexMorph`. */
|
|
21
|
+
morph?: LatexMorphStrategy;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@node({
|
|
25
|
+
key: "latex",
|
|
26
|
+
parentKey: "shape",
|
|
27
|
+
forkable: true,
|
|
28
|
+
layout: {
|
|
29
|
+
children: "freeform",
|
|
30
|
+
defaultWidthMode: "hug",
|
|
31
|
+
defaultHeightMode: "hug",
|
|
32
|
+
acceptsChildren: true,
|
|
33
|
+
},
|
|
34
|
+
seed: {
|
|
35
|
+
fill: [
|
|
36
|
+
{
|
|
37
|
+
type: "solid",
|
|
38
|
+
color: [
|
|
39
|
+
0.412,
|
|
40
|
+
0.565,
|
|
41
|
+
0.867,
|
|
42
|
+
1,
|
|
43
|
+
],
|
|
44
|
+
opacity: 1,
|
|
45
|
+
enabled: true,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
export class Latex extends ShapeNode<LatexProps> {
|
|
51
|
+
@property({ default: "" }) declare readonly latex: string;
|
|
52
|
+
@property({ default: 16 }) declare readonly fontSize: number;
|
|
53
|
+
@property({ default: 0, mapper: insetsOps.resolve, tween: insetsOps.lerp }) declare readonly padding: InsetsResolved;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How this node morphs between two formulas. Construction-time-only (see
|
|
57
|
+
* {@link LatexStrategies}) — resolved once here rather than left as
|
|
58
|
+
* `?? defaultLatexMorph` at every call site.
|
|
59
|
+
*/
|
|
60
|
+
private readonly morph: LatexMorphStrategy;
|
|
61
|
+
|
|
62
|
+
private _intrinsicWidth: number = 0;
|
|
63
|
+
private _intrinsicHeight: number = 0;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shared center frame for all current tokens, [minX, minY, maxX, maxY] in
|
|
67
|
+
* token space. Passed to every per-token path so glyphs keep their relative
|
|
68
|
+
* layout instead of each centering on its own bbox.
|
|
69
|
+
*/
|
|
70
|
+
private _bounds: [number, number, number, number] = [0, 0, 0, 0];
|
|
71
|
+
|
|
72
|
+
/** Current tokens to render. Each has its own opacity for tween transitions. */
|
|
73
|
+
private _tokens: AnimatedToken[] = [];
|
|
74
|
+
|
|
75
|
+
/** Suppresses retokenization while a custom to() is driving frames. */
|
|
76
|
+
private _animating: boolean = false;
|
|
77
|
+
|
|
78
|
+
/** The `fontSize|latex` the live tokens were built from. See {@link _ensureTokens}. */
|
|
79
|
+
private _tokenizedFrom: string | null = null;
|
|
80
|
+
|
|
81
|
+
constructor(props: NodeConfig<Latex, LatexProps> & LatexStrategies) {
|
|
82
|
+
super(props);
|
|
83
|
+
this.morph = props.morph ?? defaultLatexMorph;
|
|
84
|
+
this.applyProp("width", props.width ?? "hug");
|
|
85
|
+
this.applyProp("height", props.height ?? "hug");
|
|
86
|
+
|
|
87
|
+
this._ensureTokens();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rebuild the token list when the formula or size has moved since it last
|
|
92
|
+
* was built.
|
|
93
|
+
*
|
|
94
|
+
* Pulled from every reader rather than pushed from a prop write, because
|
|
95
|
+
* `latex` can be a binding — and a binding has no write to hang a
|
|
96
|
+
* subscription on. The key is what the current tokens were built from;
|
|
97
|
+
* `_animating` hands ownership to the morph command for its open interval.
|
|
98
|
+
*/
|
|
99
|
+
private _ensureTokens(): void {
|
|
100
|
+
if (this._animating) return;
|
|
101
|
+
const key = `${this.fontSize}|${this.latex}`;
|
|
102
|
+
if (key === this._tokenizedFrom) return;
|
|
103
|
+
this._tokenizedFrom = key;
|
|
104
|
+
this._updateTokens();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private _updateTokens() {
|
|
108
|
+
if (this.latex) {
|
|
109
|
+
const result = buildLatexPath(this.latex, this.fontSize);
|
|
110
|
+
this._intrinsicWidth = result.width;
|
|
111
|
+
this._intrinsicHeight = result.height;
|
|
112
|
+
this._bounds = result.bounds;
|
|
113
|
+
this._tokens = result.tokens.map(t => ({
|
|
114
|
+
token: t.token,
|
|
115
|
+
path: t.path,
|
|
116
|
+
opacity: 1,
|
|
117
|
+
x: 0,
|
|
118
|
+
y: 0,
|
|
119
|
+
}));
|
|
120
|
+
} else {
|
|
121
|
+
this._tokens = [];
|
|
122
|
+
this._intrinsicWidth = 0;
|
|
123
|
+
this._intrinsicHeight = 0;
|
|
124
|
+
this._bounds = [0, 0, 0, 0];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override measure(constraints: SizeConstraints): Partial<Size2D> {
|
|
129
|
+
this._ensureTokens();
|
|
130
|
+
const pad = this.padding;
|
|
131
|
+
const wm = this.width;
|
|
132
|
+
const hm = this.height;
|
|
133
|
+
|
|
134
|
+
const resolvedW = typeof wm === "number"
|
|
135
|
+
? wm
|
|
136
|
+
: wm === "hug"
|
|
137
|
+
? this._intrinsicWidth + pad.left + pad.right
|
|
138
|
+
: constraints.maxWidth ?? 0;
|
|
139
|
+
|
|
140
|
+
const resolvedH = typeof hm === "number"
|
|
141
|
+
? hm
|
|
142
|
+
: hm === "hug"
|
|
143
|
+
? this._intrinsicHeight + pad.top + pad.bottom
|
|
144
|
+
: constraints.maxHeight ?? 0;
|
|
145
|
+
|
|
146
|
+
return { width: resolvedW, height: resolvedH };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A LaTeX morph — one {@link Command}, like every other `to()`.
|
|
151
|
+
*
|
|
152
|
+
* Sequence two of them with `sequence(...)` rather than chaining: the chain
|
|
153
|
+
* builder this used to return existed only so `latexRef().to(a, 1).to(b, 1)`
|
|
154
|
+
* would read as two steps, and it could not compose with anything that was
|
|
155
|
+
* not another Latex morph.
|
|
156
|
+
*/
|
|
157
|
+
override to<D extends Partial<LatexProps> = Partial<LatexProps>>(args: CommandArgs<D> & { duration: number }): Command<LatexProps> {
|
|
158
|
+
return this._buildMorph(args.data ?? {}, args.duration, args.easing);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Morph this formula into another — a document-facing name for {@link to},
|
|
163
|
+
* which already routes through {@link _buildMorph}.
|
|
164
|
+
*/
|
|
165
|
+
@command({ args: [{ key: "latex", kind: "text", multiline: true }] })
|
|
166
|
+
morphTo(args: CommandArgs<{ latex: string }> & { duration: number }): Command<LatexProps> {
|
|
167
|
+
return this.to({ data: { latex: args.data?.latex } as Partial<LatexProps>, duration: args.duration, easing: args.easing });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build a single LaTeX morph as a {@link Command}: every ordinary
|
|
172
|
+
* `ShapeProps` field (via the inherited `_prepareStep`), plus the
|
|
173
|
+
* intrinsic size, shared center frame and per-glyph token list — state a
|
|
174
|
+
* plain `set()` can't reach — driven together by one eased `t`, matching
|
|
175
|
+
* the concurrent `parallel` the generator version ran.
|
|
176
|
+
*
|
|
177
|
+
* The "from" snapshot (`fromTokens`, `fromLatex`, …) is captured
|
|
178
|
+
* **lazily, on the command's first evaluation**, not when this is called.
|
|
179
|
+
* That is what lets two morphs be sequenced: `commandSequence` settles each
|
|
180
|
+
* prior step before evaluating the next, so a second morph's "from" reads
|
|
181
|
+
* the first one's end state rather than whatever the node held before either
|
|
182
|
+
* of them ran.
|
|
183
|
+
*
|
|
184
|
+
* `_animating` suppresses {@link _ensureTokens} for the open interval, and
|
|
185
|
+
* both ends commit: `t === 1`
|
|
186
|
+
* lands on the target formula, `t === 0` restores the source one — the same
|
|
187
|
+
* "membership for the open interval, commit at either end" shape
|
|
188
|
+
* `packages/components/code`'s `runTransition` uses, so seeking into either
|
|
189
|
+
* side of the morph (not just running it forward) shows the right thing.
|
|
190
|
+
*
|
|
191
|
+
* `@internal` — the entry point for authors is {@link to}.
|
|
192
|
+
*/
|
|
193
|
+
_buildMorph(to: Partial<LatexProps>, duration: number, easing?: EasingFunction): Command<LatexProps> {
|
|
194
|
+
let setupDone = false;
|
|
195
|
+
let fromAnimTokens: AnimatedToken[] = [];
|
|
196
|
+
let fromLatex = "";
|
|
197
|
+
let fromFontSize = 0;
|
|
198
|
+
let fromWidth = 0;
|
|
199
|
+
let fromHeight = 0;
|
|
200
|
+
let fromBounds: [number, number, number, number] = [0, 0, 0, 0];
|
|
201
|
+
let toFontSize = 0;
|
|
202
|
+
let toLatex = "";
|
|
203
|
+
let toResult: ReturnType<typeof buildLatexPath>;
|
|
204
|
+
let propStep: TweenStepper;
|
|
205
|
+
let latexFrame: (t: number) => AnimatedToken[];
|
|
206
|
+
|
|
207
|
+
const setup = (): void => {
|
|
208
|
+
setupDone = true;
|
|
209
|
+
this._ensureTokens();
|
|
210
|
+
const fromTokens: LatexToken[] = this._tokens.map(t => ({ token: t.token, path: t.path }));
|
|
211
|
+
fromAnimTokens = this._tokens.map(t => ({ ...t }));
|
|
212
|
+
fromLatex = this.latex;
|
|
213
|
+
fromFontSize = this.fontSize;
|
|
214
|
+
fromWidth = this._intrinsicWidth;
|
|
215
|
+
fromHeight = this._intrinsicHeight;
|
|
216
|
+
fromBounds = this._bounds;
|
|
217
|
+
|
|
218
|
+
toFontSize = to.fontSize !== undefined ? to.fontSize : this.fontSize;
|
|
219
|
+
toLatex = to.latex !== undefined ? to.latex : this.latex;
|
|
220
|
+
toResult = buildLatexPath(toLatex, toFontSize);
|
|
221
|
+
|
|
222
|
+
propStep = this._prepareStep(to, duration);
|
|
223
|
+
latexFrame = this.morph(fromTokens, toResult.tokens);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return driveCommand(duration, (t) => {
|
|
227
|
+
if (!setupDone) setup();
|
|
228
|
+
const toBounds = toResult.bounds;
|
|
229
|
+
|
|
230
|
+
this._animating = true;
|
|
231
|
+
propStep.seek(t * duration);
|
|
232
|
+
this.set({ fontSize: lerpNumber(fromFontSize, toFontSize, t) });
|
|
233
|
+
// Track the measured size so a hugging box grows/shrinks smoothly
|
|
234
|
+
// across the morph rather than jumping when the end commits.
|
|
235
|
+
this._intrinsicWidth = lerpNumber(fromWidth, toResult.width, t);
|
|
236
|
+
this._intrinsicHeight = lerpNumber(fromHeight, toResult.height, t);
|
|
237
|
+
// Interpolate the shared center frame in lockstep so glyphs stay
|
|
238
|
+
// centered within the resizing box.
|
|
239
|
+
this._bounds = [
|
|
240
|
+
lerpNumber(fromBounds[0], toBounds[0], t),
|
|
241
|
+
lerpNumber(fromBounds[1], toBounds[1], t),
|
|
242
|
+
lerpNumber(fromBounds[2], toBounds[2], t),
|
|
243
|
+
lerpNumber(fromBounds[3], toBounds[3], t),
|
|
244
|
+
];
|
|
245
|
+
this._tokens = latexFrame(t);
|
|
246
|
+
|
|
247
|
+
if (t >= 1) {
|
|
248
|
+
this.set({ latex: toLatex, fontSize: toFontSize });
|
|
249
|
+
this._tokens = toResult.tokens.map(tok => ({ token: tok.token, path: tok.path, opacity: 1, x: 0, y: 0 }));
|
|
250
|
+
this._intrinsicWidth = toResult.width;
|
|
251
|
+
this._intrinsicHeight = toResult.height;
|
|
252
|
+
this._bounds = toResult.bounds;
|
|
253
|
+
this._animating = false;
|
|
254
|
+
this._tokenizedFrom = `${toFontSize}|${toLatex}`;
|
|
255
|
+
} else if (t <= 0) {
|
|
256
|
+
this.set({ latex: fromLatex, fontSize: fromFontSize });
|
|
257
|
+
this._tokens = fromAnimTokens;
|
|
258
|
+
this._intrinsicWidth = fromWidth;
|
|
259
|
+
this._intrinsicHeight = fromHeight;
|
|
260
|
+
this._bounds = fromBounds;
|
|
261
|
+
this._animating = false;
|
|
262
|
+
this._tokenizedFrom = `${fromFontSize}|${fromLatex}`;
|
|
263
|
+
}
|
|
264
|
+
}, easing) as Command<LatexProps>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The shadow op comes **first**, as it does on every other painted node.
|
|
269
|
+
*
|
|
270
|
+
* The renderer does not draw a shadow when it sees one: `.shadow()` stores
|
|
271
|
+
* it pending and the *next* fill or stroke op casts it (`_shadow` →
|
|
272
|
+
* `storePendingShadows`, `_fill`/`_stroke` → `takePendingShadows`). Appended
|
|
273
|
+
* last, it was stored by every token and then thrown away by the reset at
|
|
274
|
+
* the end of the draw — a shadow set on a LaTeX node simply never appeared,
|
|
275
|
+
* with no error to say why. Same failure {@link PaintedNode.renderStroke}
|
|
276
|
+
* documents for a fill-less stroked shape, and the same one-op fix.
|
|
277
|
+
*
|
|
278
|
+
* Order also carries the fill-less case for free: an empty fill returns
|
|
279
|
+
* before it takes the pending shadow, so the stroke below casts it from the
|
|
280
|
+
* glyph outlines instead — which is what `castsShadowFromStroke` arranges on
|
|
281
|
+
* a plain shape.
|
|
282
|
+
*/
|
|
283
|
+
protected renderSelf(ctx: RenderContext2D): void {
|
|
284
|
+
this.eachToken(ctx, (graphics, opacity) => graphics
|
|
285
|
+
.shadow(scaleShadowOpacity(this.shadow as ShadowResolved[], opacity))
|
|
286
|
+
.fill(scaleFillopacity(this.fill as FillResolved[], opacity))
|
|
287
|
+
.stroke(this.stroke));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The overlay, painted *through* the glyphs.
|
|
292
|
+
*
|
|
293
|
+
* `ShapeNode`'s inherited overlay pass fills whatever {@link
|
|
294
|
+
* ShapeNode.shapeGraphics} describes, and this node describes nothing there:
|
|
295
|
+
* a formula has no single fillable silhouette — its silhouette *is* the list
|
|
296
|
+
* of token paths, which is why `renderSelf` is overridden rather than a
|
|
297
|
+
* `shapeGraphics` supplied. So the generic pass drew nothing and an overlay
|
|
298
|
+
* set on a LaTeX node simply never appeared, with no error to say why.
|
|
299
|
+
*
|
|
300
|
+
* Painted the same way the fill is: one `Graphics2D` per token, all sharing the
|
|
301
|
+
* centre frame, each scaled by its token's animated opacity — so a morph
|
|
302
|
+
* carries the overlay along with the glyph it is laid over instead of leaving
|
|
303
|
+
* a wash hanging over glyphs that have faded out.
|
|
304
|
+
*
|
|
305
|
+
* The stroke is *not* re-drawn here. `renderSelf` already strokes each token
|
|
306
|
+
* (there is no silhouette for the deferred {@link ShapeNode.renderStroke} to
|
|
307
|
+
* outline), so it is painted under the overlay rather than over it — the one
|
|
308
|
+
* place this node's draw order differs from a plain shape's, and the price of
|
|
309
|
+
* a stroke that follows glyphs rather than a box.
|
|
310
|
+
*/
|
|
311
|
+
protected override renderOverlay(ctx: RenderContext2D): void {
|
|
312
|
+
const overlay = this.overlay as FillResolved[];
|
|
313
|
+
if (overlay.length === 0) return;
|
|
314
|
+
this.eachToken(ctx, (graphics, opacity) => graphics.fill(scaleFillopacity(overlay, opacity)));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Draws every visible token's path once, handing each one to `paint` to have
|
|
319
|
+
* its paint ops appended.
|
|
320
|
+
*
|
|
321
|
+
* A fresh `Graphics2D` per token per pass, because `.fill()`/`.stroke()` push
|
|
322
|
+
* onto one op list and return `this` — a silhouette shared between the fill
|
|
323
|
+
* pass and the overlay pass would accumulate both.
|
|
324
|
+
*/
|
|
325
|
+
private eachToken(ctx: RenderContext2D, paint: (graphics: Graphics2D, opacity: number) => Graphics2D): void {
|
|
326
|
+
// The one funnel every paint pass goes through, so the tokens a frame
|
|
327
|
+
// draws cannot be older than the formula it holds.
|
|
328
|
+
this._ensureTokens();
|
|
329
|
+
for (const token of this._tokens) {
|
|
330
|
+
if (token.opacity <= 0) continue;
|
|
331
|
+
|
|
332
|
+
// Translate the path by token's interpolated position offset
|
|
333
|
+
const pathStr = token.x !== 0 || token.y !== 0
|
|
334
|
+
? toPathString(offsetPath(token.path, token.x, token.y))
|
|
335
|
+
: toPathString(token.path);
|
|
336
|
+
|
|
337
|
+
ctx.draw(paint(new Graphics2D()
|
|
338
|
+
.path({
|
|
339
|
+
data: pathStr,
|
|
340
|
+
start: this.start,
|
|
341
|
+
end: this.end,
|
|
342
|
+
// All tokens share one center frame so they keep their relative
|
|
343
|
+
// layout — without this each glyph centers on its own bbox and
|
|
344
|
+
// they all stack on the origin.
|
|
345
|
+
centerBounds: this._bounds,
|
|
346
|
+
}), token.opacity));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function scaleFillopacity(fills: FillResolved[], opacity: number): FillResolved[] {
|
|
353
|
+
if (opacity >= 1) return fills;
|
|
354
|
+
return fills.map(f => ({ ...f, opacity: (f.opacity ?? 1) * opacity }));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A morphing token fades, and its shadow has to fade with it — the paints are
|
|
359
|
+
* scaled the same way the fill's are, so a half-faded glyph casts a half-strength
|
|
360
|
+
* shadow rather than sitting over a shadow at full weight.
|
|
361
|
+
*/
|
|
362
|
+
function scaleShadowOpacity(shadows: ShadowResolved[], opacity: number): ShadowResolved[] {
|
|
363
|
+
if (opacity >= 1) return shadows;
|
|
364
|
+
return shadows.map(s => ({ ...s, fill: scaleFillopacity(s.fill, opacity) }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function offsetPath(
|
|
368
|
+
path: LatexToken["path"],
|
|
369
|
+
dx: number,
|
|
370
|
+
dy: number,
|
|
371
|
+
): LatexToken["path"] {
|
|
372
|
+
return path.map(cmd => {
|
|
373
|
+
const c = cmd as any;
|
|
374
|
+
const shifted: any = { ...c };
|
|
375
|
+
if ("x" in c) shifted.x = c.x + dx;
|
|
376
|
+
if ("y" in c) shifted.y = c.y + dy;
|
|
377
|
+
if ("x1" in c) { shifted.x1 = c.x1 + dx; shifted.y1 = c.y1 + dy; }
|
|
378
|
+
if ("x2" in c) { shifted.x2 = c.x2 + dx; shifted.y2 = c.y2 + dy; }
|
|
379
|
+
return shifted;
|
|
380
|
+
});
|
|
381
|
+
}
|
package/src/nodes.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Latex } from "./node";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every node type this package publishes.
|
|
5
|
+
*
|
|
6
|
+
* A host registers these by handing the array to an engine —
|
|
7
|
+
* `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
|
|
8
|
+
* key. The decorator only *declares* that key: nothing is registered by the mere
|
|
9
|
+
* act of importing a module, so a registry holds exactly what its owner asked
|
|
10
|
+
* for and two engines in one process can differ about it.
|
|
11
|
+
*
|
|
12
|
+
* Listing class **values** is also what survives bundling. This package declares
|
|
13
|
+
* `sideEffects: false`, which licenses a bundler to drop a module nothing
|
|
14
|
+
* imports a value from, and a document names a node type by string — so
|
|
15
|
+
* `import "./node"` for its side effect is exactly what gets shaken out, where an
|
|
16
|
+
* array of bindings is a data dependency that cannot be.
|
|
17
|
+
*/
|
|
18
|
+
export const NODES = [Latex];
|
package/src/tween.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { lerpNumber, type PathCommand } from "@motionscript/core";
|
|
2
|
+
import type { LatexToken } from "./geometry";
|
|
3
|
+
|
|
4
|
+
export interface AnimatedToken {
|
|
5
|
+
token: string;
|
|
6
|
+
path: LatexToken["path"];
|
|
7
|
+
/** 0 = invisible, 1 = fully visible */
|
|
8
|
+
opacity: number;
|
|
9
|
+
/** Interpolated position offset applied during morph (x, y in formula space). */
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How a `Latex` node interpolates between two formulas: given the tokens it
|
|
16
|
+
* currently holds and the tokens the target formula resolves to, return a
|
|
17
|
+
* pure `t → AnimatedToken[]` frame function. `prepareLatexTween` below is the
|
|
18
|
+
* default implementation (exported as `defaultLatexMorph`) — pass a `morph`
|
|
19
|
+
* of this shape to `<Latex>` to replace it with your own.
|
|
20
|
+
*/
|
|
21
|
+
export type LatexMorphStrategy = (from: LatexToken[], to: LatexToken[]) => (t: number) => AnimatedToken[];
|
|
22
|
+
|
|
23
|
+
/** The coordinate pairs a `PathCommand` can carry, as (x, y) field names. */
|
|
24
|
+
const POINT_FIELDS = [["x", "y"], ["x1", "y1"], ["x2", "y2"]] as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute the centroid of a token's path for position-based interpolation.
|
|
28
|
+
*/
|
|
29
|
+
function centroid(path: LatexToken["path"]): { x: number; y: number } {
|
|
30
|
+
let sx = 0, sy = 0, n = 0;
|
|
31
|
+
for (const cmd of path) {
|
|
32
|
+
if ("x" in cmd && "y" in cmd) {
|
|
33
|
+
sx += (cmd as any).x;
|
|
34
|
+
sy += (cmd as any).y;
|
|
35
|
+
n++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return n > 0 ? { x: sx / n, y: sy / n } : { x: 0, y: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A glyph's on-screen extent, as the diagonal of its control-point bbox.
|
|
43
|
+
*
|
|
44
|
+
* Only ever used as a *ratio* between the two ends of a match, so the fact that
|
|
45
|
+
* control points overshoot the true outline doesn't matter: both ends overshoot
|
|
46
|
+
* by the same proportion, because they are the same outline.
|
|
47
|
+
*/
|
|
48
|
+
function extent(path: LatexToken["path"]): number {
|
|
49
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
50
|
+
for (const cmd of path) {
|
|
51
|
+
const c = cmd as any;
|
|
52
|
+
for (const [kx, ky] of POINT_FIELDS) {
|
|
53
|
+
if (kx in c) {
|
|
54
|
+
if (c[kx] < minX) minX = c[kx];
|
|
55
|
+
if (c[kx] > maxX) maxX = c[kx];
|
|
56
|
+
if (c[ky] < minY) minY = c[ky];
|
|
57
|
+
if (c[ky] > maxY) maxY = c[ky];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (minX > maxX) return 0;
|
|
62
|
+
return Math.hypot(maxX - minX, maxY - minY);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether two paths can be interpolated point by point: same command count,
|
|
67
|
+
* same command types in the same order.
|
|
68
|
+
*
|
|
69
|
+
* For a matched pair this is the overwhelmingly common case, and not by luck.
|
|
70
|
+
* Tokens are matched on the character their MathJax glyph id decodes to, and
|
|
71
|
+
* that id *is* the key into the `<defs>` dictionary — so the same character in
|
|
72
|
+
* two formulas is the same outline, emitted twice under two different
|
|
73
|
+
* transforms. Same commands, different numbers.
|
|
74
|
+
*/
|
|
75
|
+
function isPointwiseCompatible(from: PathCommand[], to: PathCommand[]): boolean {
|
|
76
|
+
if (from.length !== to.length) return false;
|
|
77
|
+
for (let i = 0; i < from.length; i++) {
|
|
78
|
+
if (from[i].type !== to[i].type) return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Lerp two structurally identical paths point by point.
|
|
85
|
+
*
|
|
86
|
+
* When the two ends are the same outline under two affine transforms — which is
|
|
87
|
+
* what a matched glyph is — this is exact at both ends *and* correct in
|
|
88
|
+
* between: `(1-t)·A·q + t·B·q = ((1-t)A + tB)·q`, and a blend of two
|
|
89
|
+
* uniform-scale-plus-translate transforms is another one. The intermediate is a
|
|
90
|
+
* properly formed glyph at an intermediate size, not a smeared one.
|
|
91
|
+
*/
|
|
92
|
+
function lerpPath(from: PathCommand[], to: PathCommand[], t: number): PathCommand[] {
|
|
93
|
+
const out: PathCommand[] = new Array(from.length);
|
|
94
|
+
for (let i = 0; i < from.length; i++) {
|
|
95
|
+
const f = from[i] as any;
|
|
96
|
+
const g = to[i] as any;
|
|
97
|
+
const c: any = { ...g };
|
|
98
|
+
for (const [kx, ky] of POINT_FIELDS) {
|
|
99
|
+
if (kx in g && kx in f) {
|
|
100
|
+
c[kx] = lerpNumber(f[kx], g[kx], t);
|
|
101
|
+
c[ky] = lerpNumber(f[ky], g[ky], t);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
out[i] = c as PathCommand;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scale a path uniformly about (cx, cy), then translate by (dx, dy).
|
|
111
|
+
*
|
|
112
|
+
* The fallback for a matched pair whose paths *aren't* structurally identical —
|
|
113
|
+
* a stretchy delimiter assembled from a different number of pieces at the two
|
|
114
|
+
* sizes, say. Point-by-point lerping is impossible there, so the target outline
|
|
115
|
+
* is shrunk to the source's size instead and grown back over the morph: the
|
|
116
|
+
* size still changes continuously, which is the whole point, at the cost of
|
|
117
|
+
* showing the target's shape from the start.
|
|
118
|
+
*/
|
|
119
|
+
function scaleAbout(
|
|
120
|
+
path: PathCommand[],
|
|
121
|
+
cx: number,
|
|
122
|
+
cy: number,
|
|
123
|
+
s: number,
|
|
124
|
+
dx: number,
|
|
125
|
+
dy: number,
|
|
126
|
+
): PathCommand[] {
|
|
127
|
+
return path.map(cmd => {
|
|
128
|
+
const c: any = { ...(cmd as any) };
|
|
129
|
+
for (const [kx, ky] of POINT_FIELDS) {
|
|
130
|
+
if (kx in c) {
|
|
131
|
+
c[kx] = cx + (c[kx] - cx) * s + dx;
|
|
132
|
+
c[ky] = cy + (c[ky] - cy) * s + dy;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return c as PathCommand;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Greedily match tokens from `from` to `to` by character key.
|
|
141
|
+
* Returns three lists: matched pairs, deleted tokens (only in from), added tokens (only in to).
|
|
142
|
+
*/
|
|
143
|
+
function matchTokens(
|
|
144
|
+
from: LatexToken[],
|
|
145
|
+
to: LatexToken[],
|
|
146
|
+
): {
|
|
147
|
+
matched: Array<{ from: LatexToken; to: LatexToken }>;
|
|
148
|
+
deleted: LatexToken[];
|
|
149
|
+
added: LatexToken[];
|
|
150
|
+
} {
|
|
151
|
+
const remaining = [...to];
|
|
152
|
+
const matched: Array<{ from: LatexToken; to: LatexToken }> = [];
|
|
153
|
+
const deleted: LatexToken[] = [];
|
|
154
|
+
|
|
155
|
+
for (const ft of from) {
|
|
156
|
+
// Skip synthetic shapes (rects/paths) — they don't have a natural token key
|
|
157
|
+
if (ft.token.startsWith("__")) {
|
|
158
|
+
deleted.push(ft);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const idx = remaining.findIndex(t => t.token === ft.token && !t.token.startsWith("__"));
|
|
162
|
+
if (idx !== -1) {
|
|
163
|
+
matched.push({ from: ft, to: remaining[idx] });
|
|
164
|
+
remaining.splice(idx, 1);
|
|
165
|
+
} else {
|
|
166
|
+
deleted.push(ft);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Remaining to-tokens that weren't matched
|
|
171
|
+
const added = remaining;
|
|
172
|
+
|
|
173
|
+
return { matched, deleted, added };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Precompute a formula-change morph and return a pure `t → AnimatedToken[]`
|
|
178
|
+
* frame function:
|
|
179
|
+
* - Deleted tokens fade out over the first half of `t`.
|
|
180
|
+
* - Matched tokens are interpolated — place *and* size — across the full range
|
|
181
|
+
* of `t`.
|
|
182
|
+
* - Added tokens fade in over the second half of `t`.
|
|
183
|
+
*
|
|
184
|
+
* A matched glyph is rarely the same size at both ends: the `2` of `b^2` is set
|
|
185
|
+
* at script size and the `2` of `2a` at full size, and a `\frac`'s arguments
|
|
186
|
+
* come back a step smaller than the same symbols on a baseline. So the
|
|
187
|
+
* interpolation has to carry the glyph's *geometry*, not just where it sits.
|
|
188
|
+
* Sliding the target outline from one centroid to the other — which is all this
|
|
189
|
+
* used to do — put the whole size change between the last static frame and the
|
|
190
|
+
* morph's first one: a snap to the new size, then a smooth glide to the new
|
|
191
|
+
* place. Point-by-point lerping (see {@link lerpPath}) is that same
|
|
192
|
+
* interpolation generalised from a glyph's average point to all of them, and it
|
|
193
|
+
* costs nothing extra — the path was already being rebuilt every frame to apply
|
|
194
|
+
* the slide.
|
|
195
|
+
*
|
|
196
|
+
* `t` is normalized `[0, 1]` and already eased — the caller (a `Command`'s
|
|
197
|
+
* `at`) applies easing once, up front, the same eased value driving every
|
|
198
|
+
* concurrent aspect of the morph (props, intrinsic size, tokens) in lockstep.
|
|
199
|
+
*/
|
|
200
|
+
export function prepareLatexTween(
|
|
201
|
+
from: LatexToken[],
|
|
202
|
+
to: LatexToken[],
|
|
203
|
+
): (t: number) => AnimatedToken[] {
|
|
204
|
+
const { matched, deleted, added } = matchTokens(from, to);
|
|
205
|
+
|
|
206
|
+
// Per-match interpolation data. Structure compatibility, and the centroids
|
|
207
|
+
// and extents behind the fallback, are properties of the pair rather than
|
|
208
|
+
// of `t`, so they are settled once here instead of at every frame.
|
|
209
|
+
const matchedData = matched.map(({ from: f, to: t }) => {
|
|
210
|
+
const toExtent = extent(t.path);
|
|
211
|
+
return {
|
|
212
|
+
pointwise: isPointwiseCompatible(f.path, t.path),
|
|
213
|
+
fromPath: f.path,
|
|
214
|
+
toPath: t.path,
|
|
215
|
+
token: t.token,
|
|
216
|
+
fromCenter: centroid(f.path),
|
|
217
|
+
toCenter: centroid(t.path),
|
|
218
|
+
/** Only the fallback needs it, and only as a ratio. */
|
|
219
|
+
fromScale: toExtent > 0 ? extent(f.path) / toExtent : 1,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return (t: number): AnimatedToken[] => {
|
|
224
|
+
const tokens: AnimatedToken[] = [];
|
|
225
|
+
|
|
226
|
+
// Matched tokens: interpolate place and size across the full duration.
|
|
227
|
+
for (const m of matchedData) {
|
|
228
|
+
if (m.pointwise) {
|
|
229
|
+
tokens.push({
|
|
230
|
+
token: m.token,
|
|
231
|
+
path: lerpPath(m.fromPath, m.toPath, t),
|
|
232
|
+
opacity: 1,
|
|
233
|
+
// Baked into the path above: the lerp carries every point,
|
|
234
|
+
// which includes where the glyph sits.
|
|
235
|
+
x: 0,
|
|
236
|
+
y: 0,
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const s = lerpNumber(m.fromScale, 1, t);
|
|
242
|
+
const cx = lerpNumber(m.fromCenter.x, m.toCenter.x, t);
|
|
243
|
+
const cy = lerpNumber(m.fromCenter.y, m.toCenter.y, t);
|
|
244
|
+
tokens.push({
|
|
245
|
+
token: m.token,
|
|
246
|
+
path: scaleAbout(
|
|
247
|
+
m.toPath,
|
|
248
|
+
m.toCenter.x,
|
|
249
|
+
m.toCenter.y,
|
|
250
|
+
s,
|
|
251
|
+
cx - m.toCenter.x,
|
|
252
|
+
cy - m.toCenter.y,
|
|
253
|
+
),
|
|
254
|
+
opacity: 1,
|
|
255
|
+
x: 0,
|
|
256
|
+
y: 0,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Deleted tokens: fade out over the first half, gone by t=0.5
|
|
261
|
+
for (const d of deleted) {
|
|
262
|
+
const fadeT = Math.min(t * 2, 1);
|
|
263
|
+
tokens.push({
|
|
264
|
+
token: d.token,
|
|
265
|
+
path: d.path,
|
|
266
|
+
opacity: lerpNumber(1, 0, fadeT),
|
|
267
|
+
x: 0,
|
|
268
|
+
y: 0,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Added tokens: fade in over the second half, starting at t=0.5
|
|
273
|
+
for (const a of added) {
|
|
274
|
+
const fadeT = Math.max(t * 2 - 1, 0);
|
|
275
|
+
tokens.push({
|
|
276
|
+
token: a.token,
|
|
277
|
+
path: a.path,
|
|
278
|
+
opacity: lerpNumber(0, 1, fadeT),
|
|
279
|
+
x: 0,
|
|
280
|
+
y: 0,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return tokens;
|
|
285
|
+
};
|
|
286
|
+
}
|