@vysmo/text 0.1.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/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/animate.d.ts +28 -0
- package/dist/animate.d.ts.map +1 -0
- package/dist/animate.js +418 -0
- package/dist/animate.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/presets/emphasis.d.ts +11 -0
- package/dist/presets/emphasis.d.ts.map +1 -0
- package/dist/presets/emphasis.js +54 -0
- package/dist/presets/emphasis.js.map +1 -0
- package/dist/presets/enter.d.ts +8 -0
- package/dist/presets/enter.d.ts.map +1 -0
- package/dist/presets/enter.js +58 -0
- package/dist/presets/enter.js.map +1 -0
- package/dist/presets/exit.d.ts +5 -0
- package/dist/presets/exit.d.ts.map +1 -0
- package/dist/presets/exit.js +30 -0
- package/dist/presets/exit.js.map +1 -0
- package/dist/presets/generated.d.ts +240 -0
- package/dist/presets/generated.d.ts.map +1 -0
- package/dist/presets/generated.js +3084 -0
- package/dist/presets/generated.js.map +1 -0
- package/dist/presets/index.d.ts +15 -0
- package/dist/presets/index.d.ts.map +1 -0
- package/dist/presets/index.js +43 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/properties.d.ts +10 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/properties.js +80 -0
- package/dist/properties.js.map +1 -0
- package/dist/split.d.ts +21 -0
- package/dist/split.d.ts.map +1 -0
- package/dist/split.js +171 -0
- package/dist/split.js.map +1 -0
- package/dist/stagger.d.ts +13 -0
- package/dist/stagger.d.ts.map +1 -0
- package/dist/stagger.js +53 -0
- package/dist/stagger.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
- package/src/__tests__/animate.test.ts +638 -0
- package/src/__tests__/presets.test.ts +87 -0
- package/src/__tests__/properties.test.ts +62 -0
- package/src/__tests__/split.test.ts +140 -0
- package/src/__tests__/ssr.test.ts +48 -0
- package/src/__tests__/stagger.test.ts +47 -0
- package/src/__tests__/types-check.ts +80 -0
- package/src/animate.ts +469 -0
- package/src/index.ts +38 -0
- package/src/presets/emphasis.ts +60 -0
- package/src/presets/enter.ts +64 -0
- package/src/presets/exit.ts +33 -0
- package/src/presets/generated.ts +3315 -0
- package/src/presets/index.ts +62 -0
- package/src/properties.ts +78 -0
- package/src/split.ts +180 -0
- package/src/stagger.ts +55 -0
- package/src/types.ts +245 -0
package/src/animate.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { animate, type AnimationHandle } from "@vysmo/animations";
|
|
2
|
+
import { parseEasing, type EasingFn } from "@vysmo/easings";
|
|
3
|
+
import { applyProps, clearProps, type PropValues } from "./properties.js";
|
|
4
|
+
import { resolvePreset } from "./presets/index.js";
|
|
5
|
+
import { splitText } from "./split.js";
|
|
6
|
+
import { computeStaggerDelays } from "./stagger.js";
|
|
7
|
+
import type {
|
|
8
|
+
AnimateTextHandle,
|
|
9
|
+
AnimateTextOptions,
|
|
10
|
+
StaggerOrder,
|
|
11
|
+
TextAnimationSpec,
|
|
12
|
+
TextValue,
|
|
13
|
+
TransformOrigin,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_DURATION = 600;
|
|
17
|
+
const DEFAULT_STAGGER = 30;
|
|
18
|
+
|
|
19
|
+
function linear(t: number): number {
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a spec's `ease` to a callable function. Strings (e.g.
|
|
25
|
+
* `"power2.out"`, `"back.out(2)"`) go through `@vysmo/easings` →
|
|
26
|
+
* `parseEasing()`; functions pass through as-is. Called once per spec
|
|
27
|
+
* at plan time so the per-frame hot path stays a direct call.
|
|
28
|
+
*/
|
|
29
|
+
function resolveEase(
|
|
30
|
+
ease: string | EasingFn | ((t: number) => number) | undefined,
|
|
31
|
+
): (t: number) => number {
|
|
32
|
+
if (ease === undefined) return linear;
|
|
33
|
+
if (typeof ease === "string") return parseEasing(ease);
|
|
34
|
+
return ease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert a `TransformOrigin` to the CSS string the browser wants. Two
|
|
39
|
+
* forms: 2D (`x% y%`) and 3D (`x% y% Zpx`). Z is omitted when undefined
|
|
40
|
+
* so 2D presets don't get a meaningless trailing `0px`.
|
|
41
|
+
*/
|
|
42
|
+
function originToCss(o: TransformOrigin): string {
|
|
43
|
+
const x = `${o.x * 100}%`;
|
|
44
|
+
const y = `${o.y * 100}%`;
|
|
45
|
+
return o.z === undefined ? `${x} ${y}` : `${x} ${y} ${o.z}px`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRange(v: TextValue): v is { min: number; max: number } {
|
|
49
|
+
return typeof v === "object";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function midpoint(v: TextValue): number {
|
|
53
|
+
return isRange(v) ? (v.min + v.max) / 2 : v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveValue(v: TextValue, rng: () => number): number {
|
|
57
|
+
if (!isRange(v)) return v;
|
|
58
|
+
if (v.min === v.max) return v.min;
|
|
59
|
+
return v.min + rng() * (v.max - v.min);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pure, time-point evaluation of a spec array against a single per-slice
|
|
64
|
+
* clock. Useful for tests and headless rendering. The live animateText
|
|
65
|
+
* renderer subtracts the per-spec stagger offset *before* calling the
|
|
66
|
+
* equivalent of this function, so pass the already-offset slice time.
|
|
67
|
+
*
|
|
68
|
+
* Range `from` / `to` values resolve deterministically to their midpoint
|
|
69
|
+
* here — the live runtime samples per slice via the rng option, but
|
|
70
|
+
* `evaluateSpecs` has no slice context, so it returns the spread's
|
|
71
|
+
* average. Tests that exercise per-slice scatter should drive the live
|
|
72
|
+
* renderer instead.
|
|
73
|
+
*/
|
|
74
|
+
export function evaluateSpecs(specs: TextAnimationSpec[], t: number): PropValues {
|
|
75
|
+
const vals: PropValues = {};
|
|
76
|
+
for (const spec of specs) {
|
|
77
|
+
const delay = spec.delay ?? 0;
|
|
78
|
+
if (t < delay) continue;
|
|
79
|
+
const duration = spec.duration ?? DEFAULT_DURATION;
|
|
80
|
+
const local = duration <= 0 ? 1 : Math.min(1, Math.max(0, (t - delay) / duration));
|
|
81
|
+
const eased = resolveEase(spec.ease)(local);
|
|
82
|
+
const from = midpoint(spec.from);
|
|
83
|
+
const to = midpoint(spec.to);
|
|
84
|
+
vals[spec.prop] = from + (to - from) * eased;
|
|
85
|
+
}
|
|
86
|
+
return vals;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function prefersReducedMotion(): boolean {
|
|
90
|
+
return (
|
|
91
|
+
typeof window !== "undefined" &&
|
|
92
|
+
typeof window.matchMedia === "function" &&
|
|
93
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type SpecPlan = {
|
|
98
|
+
spec: TextAnimationSpec;
|
|
99
|
+
/** Stagger offsets per slice (no jitter applied). */
|
|
100
|
+
staggerOffsets: number[];
|
|
101
|
+
/** Per-slice jitter delay (zero unless `spec.jitterDelay` is set). */
|
|
102
|
+
jitters: number[];
|
|
103
|
+
/** staggerOffsets[i] + jitters[i] — the slice's effective spec start. */
|
|
104
|
+
effectiveOffsets: number[];
|
|
105
|
+
/** Per-slice resolved `from` value (range-aware). */
|
|
106
|
+
fromValues: number[];
|
|
107
|
+
/** Per-slice resolved `to` value (range-aware). */
|
|
108
|
+
toValues: number[];
|
|
109
|
+
/** Easing resolved once at plan time; strings parsed via `@vysmo/easings`. */
|
|
110
|
+
ease: (t: number) => number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function planSpec(
|
|
114
|
+
spec: TextAnimationSpec,
|
|
115
|
+
sliceCount: number,
|
|
116
|
+
defaultStagger: number,
|
|
117
|
+
defaultOrder: StaggerOrder,
|
|
118
|
+
rng: () => number,
|
|
119
|
+
): SpecPlan {
|
|
120
|
+
const stagger = spec.stagger ?? defaultStagger;
|
|
121
|
+
const order = spec.staggerOrder ?? defaultOrder;
|
|
122
|
+
const staggerOffsets = computeStaggerDelays(sliceCount, stagger, order, rng);
|
|
123
|
+
|
|
124
|
+
const jitterMax = spec.jitterDelay ?? 0;
|
|
125
|
+
const jitters = new Array<number>(sliceCount);
|
|
126
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
127
|
+
jitters[i] = jitterMax > 0 ? rng() * jitterMax : 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fromValues = new Array<number>(sliceCount);
|
|
131
|
+
const toValues = new Array<number>(sliceCount);
|
|
132
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
133
|
+
fromValues[i] = resolveValue(spec.from, rng);
|
|
134
|
+
toValues[i] = resolveValue(spec.to, rng);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const effectiveOffsets = new Array<number>(sliceCount);
|
|
138
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
139
|
+
effectiveOffsets[i] = staggerOffsets[i]! + jitters[i]!;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
spec,
|
|
144
|
+
staggerOffsets,
|
|
145
|
+
jitters,
|
|
146
|
+
effectiveOffsets,
|
|
147
|
+
fromValues,
|
|
148
|
+
toValues,
|
|
149
|
+
ease: resolveEase(spec.ease),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function specEnd(plan: SpecPlan): number {
|
|
154
|
+
let maxOffset = 0;
|
|
155
|
+
for (const o of plan.effectiveOffsets) {
|
|
156
|
+
if (o > maxOffset) maxOffset = o;
|
|
157
|
+
}
|
|
158
|
+
const specLocalEnd = (plan.spec.delay ?? 0) + (plan.spec.duration ?? DEFAULT_DURATION);
|
|
159
|
+
return maxOffset + specLocalEnd;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function evaluateForSlice(plans: SpecPlan[], sliceIndex: number, masterT: number): PropValues {
|
|
163
|
+
const vals: PropValues = {};
|
|
164
|
+
const touched = new Set<string>();
|
|
165
|
+
// Forward pass: standard "later wins" for any spec whose window has opened.
|
|
166
|
+
for (const plan of plans) {
|
|
167
|
+
const { spec, effectiveOffsets, fromValues, toValues } = plan;
|
|
168
|
+
const offset = effectiveOffsets[sliceIndex] ?? 0;
|
|
169
|
+
const localT = masterT - offset;
|
|
170
|
+
const delay = spec.delay ?? 0;
|
|
171
|
+
if (localT < delay) continue;
|
|
172
|
+
const duration = spec.duration ?? DEFAULT_DURATION;
|
|
173
|
+
const progress =
|
|
174
|
+
duration <= 0 ? 1 : Math.min(1, Math.max(0, (localT - delay) / duration));
|
|
175
|
+
const eased = plan.ease(progress);
|
|
176
|
+
const from = fromValues[sliceIndex] ?? 0;
|
|
177
|
+
const to = toValues[sliceIndex] ?? 0;
|
|
178
|
+
vals[spec.prop] = from + (to - from) * eased;
|
|
179
|
+
touched.add(spec.prop);
|
|
180
|
+
}
|
|
181
|
+
// Back-fill: for any prop that hasn't touched yet on this slice (stagger
|
|
182
|
+
// hasn't fired, or the spec delay is still in the future), hold the FIRST
|
|
183
|
+
// spec's from-value so the transform/filter composition stays complete.
|
|
184
|
+
for (const plan of plans) {
|
|
185
|
+
if (!touched.has(plan.spec.prop)) {
|
|
186
|
+
vals[plan.spec.prop] = plan.fromValues[sliceIndex] ?? 0;
|
|
187
|
+
touched.add(plan.spec.prop);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return vals;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Walk the plans for a slice and return the latest opened spec's
|
|
195
|
+
* `transformOrigin`, or undefined if no override has fired yet. Used to
|
|
196
|
+
* implement last-write-wins per slice, evaluated each frame so seek()
|
|
197
|
+
* stays consistent without separate event state.
|
|
198
|
+
*/
|
|
199
|
+
function activeOriginForSlice(plans: SpecPlan[], sliceIndex: number, masterT: number): TransformOrigin | undefined {
|
|
200
|
+
let active: TransformOrigin | undefined;
|
|
201
|
+
for (const plan of plans) {
|
|
202
|
+
if (plan.spec.transformOrigin === undefined) continue;
|
|
203
|
+
const offset = plan.effectiveOffsets[sliceIndex] ?? 0;
|
|
204
|
+
const delay = plan.spec.delay ?? 0;
|
|
205
|
+
if (masterT - offset >= delay) active = plan.spec.transformOrigin;
|
|
206
|
+
}
|
|
207
|
+
return active;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The latest opened spec's `perspective` across all slices. A spec's
|
|
212
|
+
* window is considered open as soon as any slice has reached its
|
|
213
|
+
* effective start time, since `perspective` is container-scoped.
|
|
214
|
+
*/
|
|
215
|
+
function activePerspective(plans: SpecPlan[], masterT: number): number | undefined {
|
|
216
|
+
let active: number | undefined;
|
|
217
|
+
for (const plan of plans) {
|
|
218
|
+
if (plan.spec.perspective === undefined) continue;
|
|
219
|
+
let minOffset = Infinity;
|
|
220
|
+
for (const o of plan.effectiveOffsets) {
|
|
221
|
+
if (o < minOffset) minOffset = o;
|
|
222
|
+
}
|
|
223
|
+
if (minOffset === Infinity) continue;
|
|
224
|
+
const delay = plan.spec.delay ?? 0;
|
|
225
|
+
if (masterT - minOffset >= delay) active = plan.spec.perspective;
|
|
226
|
+
}
|
|
227
|
+
return active;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Animate an element's text with per-slice, per-property choreography.
|
|
232
|
+
*
|
|
233
|
+
* Each spec is an independent timeline — its own `duration`, `delay`,
|
|
234
|
+
* `ease`, `stagger`, and `staggerOrder`. Specs run on a single master
|
|
235
|
+
* clock so one rAF tick produces one DOM write per slice, and same-prop
|
|
236
|
+
* specs chain via `delay` (later wins once its window opens).
|
|
237
|
+
*
|
|
238
|
+
* For 3D transforms (rotateX/Y, translateZ) set `perspective` so children
|
|
239
|
+
* render with depth.
|
|
240
|
+
*/
|
|
241
|
+
export function animateText(
|
|
242
|
+
element: HTMLElement,
|
|
243
|
+
options: AnimateTextOptions = {},
|
|
244
|
+
): AnimateTextHandle {
|
|
245
|
+
const preset =
|
|
246
|
+
options.preset === undefined
|
|
247
|
+
? null
|
|
248
|
+
: typeof options.preset === "string"
|
|
249
|
+
? resolvePreset(options.preset)
|
|
250
|
+
: options.preset;
|
|
251
|
+
|
|
252
|
+
const splits = splitText(element, {
|
|
253
|
+
mode: options.split ?? preset?.split ?? "character",
|
|
254
|
+
...(options.locale !== undefined ? { locale: options.locale } : {}),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const specs: TextAnimationSpec[] = options.animations ?? preset?.animations ?? [];
|
|
258
|
+
const defaultStagger = options.stagger ?? preset?.stagger ?? DEFAULT_STAGGER;
|
|
259
|
+
const defaultOrder: StaggerOrder = options.staggerOrder ?? preset?.staggerOrder ?? "start";
|
|
260
|
+
const rng = options.rng ?? Math.random;
|
|
261
|
+
const sliceCount = splits.slices.length;
|
|
262
|
+
|
|
263
|
+
const plans = specs.map((s) => planSpec(s, sliceCount, defaultStagger, defaultOrder, rng));
|
|
264
|
+
const totalDuration = plans.length === 0 ? 0 : Math.max(...plans.map(specEnd));
|
|
265
|
+
|
|
266
|
+
const baselinePerspective = options.perspective ?? preset?.perspective;
|
|
267
|
+
const perspectiveOrigin = options.perspectiveOrigin;
|
|
268
|
+
const baselineTransformOrigin = options.transformOrigin ?? preset?.transformOrigin;
|
|
269
|
+
|
|
270
|
+
let containerPerspectiveApplied = false;
|
|
271
|
+
let containerPerspectiveOriginApplied = false;
|
|
272
|
+
if (baselinePerspective !== undefined) {
|
|
273
|
+
element.style.perspective = `${baselinePerspective}px`;
|
|
274
|
+
containerPerspectiveApplied = true;
|
|
275
|
+
}
|
|
276
|
+
if (perspectiveOrigin !== undefined) {
|
|
277
|
+
element.style.perspectiveOrigin = perspectiveOrigin;
|
|
278
|
+
containerPerspectiveOriginApplied = true;
|
|
279
|
+
}
|
|
280
|
+
if (baselineTransformOrigin !== undefined) {
|
|
281
|
+
const css = originToCss(baselineTransformOrigin);
|
|
282
|
+
for (const slice of splits.slices) slice.style.transformOrigin = css;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Track which slices we've ever written a per-spec transformOrigin to,
|
|
286
|
+
// so stop() can clear them even if the baseline didn't set one.
|
|
287
|
+
const sliceOriginDirty = new Array<boolean>(sliceCount).fill(false);
|
|
288
|
+
|
|
289
|
+
const autoPlay = options.autoPlay ?? true;
|
|
290
|
+
const respect = options.respectReducedMotion ?? true;
|
|
291
|
+
const reduced = respect && prefersReducedMotion();
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
294
|
+
applyProps(splits.slices[i]!, evaluateForSlice(plans, i, 0));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function applyOverrides(masterT: number): void {
|
|
298
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
299
|
+
const origin = activeOriginForSlice(plans, i, masterT);
|
|
300
|
+
if (origin !== undefined) {
|
|
301
|
+
splits.slices[i]!.style.transformOrigin = originToCss(origin);
|
|
302
|
+
sliceOriginDirty[i] = true;
|
|
303
|
+
} else if (sliceOriginDirty[i] && baselineTransformOrigin === undefined) {
|
|
304
|
+
// No override active and no baseline to fall back to — leave the
|
|
305
|
+
// last-written value in place. Per-spec rule: "don't reset between
|
|
306
|
+
// specs". Cleared on stop().
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const persp = activePerspective(plans, masterT);
|
|
310
|
+
if (persp !== undefined) {
|
|
311
|
+
element.style.perspective = `${persp}px`;
|
|
312
|
+
containerPerspectiveApplied = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function restoreContainer(): void {
|
|
317
|
+
if (containerPerspectiveApplied) {
|
|
318
|
+
element.style.removeProperty("perspective");
|
|
319
|
+
containerPerspectiveApplied = false;
|
|
320
|
+
}
|
|
321
|
+
if (containerPerspectiveOriginApplied) {
|
|
322
|
+
element.style.removeProperty("perspective-origin");
|
|
323
|
+
containerPerspectiveOriginApplied = false;
|
|
324
|
+
}
|
|
325
|
+
if (baselineTransformOrigin !== undefined) {
|
|
326
|
+
for (const slice of splits.slices) slice.style.removeProperty("transform-origin");
|
|
327
|
+
} else {
|
|
328
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
329
|
+
if (sliceOriginDirty[i]) {
|
|
330
|
+
splits.slices[i]!.style.removeProperty("transform-origin");
|
|
331
|
+
sliceOriginDirty[i] = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (plans.length === 0 || reduced || totalDuration <= 0) {
|
|
338
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
339
|
+
applyProps(splits.slices[i]!, evaluateForSlice(plans, i, totalDuration));
|
|
340
|
+
}
|
|
341
|
+
applyOverrides(totalDuration);
|
|
342
|
+
const handle: AnimateTextHandle = {
|
|
343
|
+
play() {
|
|
344
|
+
return handle;
|
|
345
|
+
},
|
|
346
|
+
pause() {
|
|
347
|
+
return handle;
|
|
348
|
+
},
|
|
349
|
+
stop() {
|
|
350
|
+
for (const slice of splits.slices) clearProps(slice);
|
|
351
|
+
restoreContainer();
|
|
352
|
+
return handle;
|
|
353
|
+
},
|
|
354
|
+
seek() {
|
|
355
|
+
return handle;
|
|
356
|
+
},
|
|
357
|
+
finished: Promise.resolve(),
|
|
358
|
+
splits,
|
|
359
|
+
};
|
|
360
|
+
return handle;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const initialDelay = Math.max(0, options.delay ?? 0);
|
|
364
|
+
const repeat = options.repeat ?? preset?.repeat ?? 1;
|
|
365
|
+
const totalCycles = repeat === "infinite" ? Infinity : Math.max(1, repeat);
|
|
366
|
+
const repeatDelay = Math.max(0, options.repeatDelay ?? preset?.repeatDelay ?? 0);
|
|
367
|
+
|
|
368
|
+
const schedulerOpt = options.scheduler !== undefined ? { scheduler: options.scheduler } : {};
|
|
369
|
+
|
|
370
|
+
function renderAt(masterT: number): void {
|
|
371
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
372
|
+
applyProps(splits.slices[i]!, evaluateForSlice(plans, i, masterT));
|
|
373
|
+
}
|
|
374
|
+
applyOverrides(masterT);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let stopped = false;
|
|
378
|
+
let cyclesLeft = totalCycles;
|
|
379
|
+
let currentCycle: AnimationHandle<number> | null = null;
|
|
380
|
+
let initialTimer: number | null = null;
|
|
381
|
+
let gapTimer: number | null = null;
|
|
382
|
+
let resolveFinished!: () => void;
|
|
383
|
+
const finished = new Promise<void>((res) => {
|
|
384
|
+
resolveFinished = res;
|
|
385
|
+
});
|
|
386
|
+
finished.catch(() => {});
|
|
387
|
+
|
|
388
|
+
function clearTimers(): void {
|
|
389
|
+
if (initialTimer !== null) {
|
|
390
|
+
window.clearTimeout(initialTimer);
|
|
391
|
+
initialTimer = null;
|
|
392
|
+
}
|
|
393
|
+
if (gapTimer !== null) {
|
|
394
|
+
window.clearTimeout(gapTimer);
|
|
395
|
+
gapTimer = null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function startCycle(): void {
|
|
400
|
+
if (stopped) return;
|
|
401
|
+
currentCycle = animate({
|
|
402
|
+
from: 0,
|
|
403
|
+
to: 1,
|
|
404
|
+
duration: totalDuration,
|
|
405
|
+
autoPlay: true,
|
|
406
|
+
...schedulerOpt,
|
|
407
|
+
onUpdate: (_v, p) => renderAt(p * totalDuration),
|
|
408
|
+
});
|
|
409
|
+
currentCycle.finished
|
|
410
|
+
.then(() => {
|
|
411
|
+
if (stopped) return;
|
|
412
|
+
cyclesLeft = cyclesLeft === Infinity ? Infinity : cyclesLeft - 1;
|
|
413
|
+
if (cyclesLeft <= 0) {
|
|
414
|
+
resolveFinished();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (repeatDelay > 0) {
|
|
418
|
+
gapTimer = window.setTimeout(startCycle, repeatDelay);
|
|
419
|
+
} else {
|
|
420
|
+
startCycle();
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
.catch(() => undefined);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function kickoff(): void {
|
|
427
|
+
if (initialDelay > 0) {
|
|
428
|
+
initialTimer = window.setTimeout(() => {
|
|
429
|
+
initialTimer = null;
|
|
430
|
+
startCycle();
|
|
431
|
+
}, initialDelay);
|
|
432
|
+
} else {
|
|
433
|
+
startCycle();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const handle: AnimateTextHandle = {
|
|
438
|
+
play() {
|
|
439
|
+
if (currentCycle === null) kickoff();
|
|
440
|
+
else currentCycle.play();
|
|
441
|
+
return handle;
|
|
442
|
+
},
|
|
443
|
+
pause() {
|
|
444
|
+
currentCycle?.pause();
|
|
445
|
+
return handle;
|
|
446
|
+
},
|
|
447
|
+
stop() {
|
|
448
|
+
stopped = true;
|
|
449
|
+
clearTimers();
|
|
450
|
+
currentCycle?.stop();
|
|
451
|
+
currentCycle = null;
|
|
452
|
+
for (const slice of splits.slices) clearProps(slice);
|
|
453
|
+
restoreContainer();
|
|
454
|
+
resolveFinished();
|
|
455
|
+
return handle;
|
|
456
|
+
},
|
|
457
|
+
seek(p) {
|
|
458
|
+
currentCycle?.seek(p);
|
|
459
|
+
return handle;
|
|
460
|
+
},
|
|
461
|
+
get finished() {
|
|
462
|
+
return finished;
|
|
463
|
+
},
|
|
464
|
+
splits,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (autoPlay) kickoff();
|
|
468
|
+
return handle;
|
|
469
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export { animateText, evaluateSpecs } from "./animate.js";
|
|
2
|
+
export { splitText } from "./split.js";
|
|
3
|
+
export { applyProps, clearProps } from "./properties.js";
|
|
4
|
+
export type { PropValues } from "./properties.js";
|
|
5
|
+
export { computeStaggerDelays } from "./stagger.js";
|
|
6
|
+
export type {
|
|
7
|
+
AnimateTextHandle,
|
|
8
|
+
AnimateTextOptions,
|
|
9
|
+
HandcuratedPresetName,
|
|
10
|
+
Preset,
|
|
11
|
+
PresetName,
|
|
12
|
+
SplitMode,
|
|
13
|
+
SplitOptions,
|
|
14
|
+
Splits,
|
|
15
|
+
StaggerOrder,
|
|
16
|
+
TextAnimationSpec,
|
|
17
|
+
TextProperty,
|
|
18
|
+
TextValue,
|
|
19
|
+
TransformOrigin,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
export {
|
|
22
|
+
listPresets,
|
|
23
|
+
resolvePreset,
|
|
24
|
+
fadeUp,
|
|
25
|
+
elasticRise,
|
|
26
|
+
blurIn,
|
|
27
|
+
scaleIn,
|
|
28
|
+
flipX,
|
|
29
|
+
depthZoom,
|
|
30
|
+
fadeDown,
|
|
31
|
+
scaleOut,
|
|
32
|
+
flipAway,
|
|
33
|
+
pulse,
|
|
34
|
+
shake,
|
|
35
|
+
wobble,
|
|
36
|
+
coinFlip,
|
|
37
|
+
spin,
|
|
38
|
+
} from "./presets/index.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Preset } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emphasis presets chain multiple specs on the same prop — later specs
|
|
5
|
+
* (with later delays) overwrite earlier ones once their window opens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const pulse: Preset = {
|
|
9
|
+
name: "emphasis/pulse",
|
|
10
|
+
split: "character",
|
|
11
|
+
stagger: 20,
|
|
12
|
+
repeat: 3,
|
|
13
|
+
repeatDelay: 400,
|
|
14
|
+
animations: [
|
|
15
|
+
{ prop: "scale", from: 1, to: 1.25, duration: 200, ease: "sine.out" },
|
|
16
|
+
{ prop: "scale", from: 1.25, to: 1, duration: 250, ease: "sine.in", delay: 200 },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const shake: Preset = {
|
|
21
|
+
name: "emphasis/shake",
|
|
22
|
+
split: "character",
|
|
23
|
+
stagger: 15,
|
|
24
|
+
repeat: 2,
|
|
25
|
+
repeatDelay: 300,
|
|
26
|
+
animations: [
|
|
27
|
+
{ prop: "translateX", from: 0, to: -6, duration: 80, ease: "sine.inOut" },
|
|
28
|
+
{ prop: "translateX", from: -6, to: 6, duration: 120, ease: "sine.inOut", delay: 80 },
|
|
29
|
+
{ prop: "translateX", from: 6, to: 0, duration: 100, ease: "sine.inOut", delay: 200 },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const wobble: Preset = {
|
|
34
|
+
name: "emphasis/wobble",
|
|
35
|
+
split: "character",
|
|
36
|
+
stagger: 25,
|
|
37
|
+
repeat: 2,
|
|
38
|
+
repeatDelay: 400,
|
|
39
|
+
animations: [
|
|
40
|
+
{ prop: "rotate", from: 0, to: -10, duration: 150, ease: "sine.inOut" },
|
|
41
|
+
{ prop: "rotate", from: -10, to: 10, duration: 200, ease: "sine.inOut", delay: 150 },
|
|
42
|
+
{ prop: "rotate", from: 10, to: 0, duration: 150, ease: "sine.inOut", delay: 350 },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const coinFlip: Preset = {
|
|
47
|
+
name: "emphasis/coin-flip",
|
|
48
|
+
split: "character",
|
|
49
|
+
stagger: 30,
|
|
50
|
+
perspective: 800,
|
|
51
|
+
transformOrigin: { x: 0.5, y: 0.5 },
|
|
52
|
+
animations: [{ prop: "rotateY", from: 0, to: 360, duration: 700, ease: "power2.inOut" }],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const spin: Preset = {
|
|
56
|
+
name: "emphasis/spin",
|
|
57
|
+
split: "character",
|
|
58
|
+
stagger: 20,
|
|
59
|
+
animations: [{ prop: "rotate", from: 0, to: 360, duration: 600, ease: "power2.inOut" }],
|
|
60
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Preset } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const fadeUp: Preset = {
|
|
4
|
+
name: "enter/fade-up",
|
|
5
|
+
split: "character",
|
|
6
|
+
stagger: 30,
|
|
7
|
+
animations: [
|
|
8
|
+
{ prop: "opacity", from: 0, to: 1, duration: 500, ease: "power2.out" },
|
|
9
|
+
{ prop: "translateY", from: 20, to: 0, duration: 500, ease: "power2.out" },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const elasticRise: Preset = {
|
|
14
|
+
name: "enter/elastic-rise",
|
|
15
|
+
split: "character",
|
|
16
|
+
stagger: 40,
|
|
17
|
+
animations: [
|
|
18
|
+
{ prop: "opacity", from: 0, to: 1, duration: 400, ease: "power2.out" },
|
|
19
|
+
{ prop: "translateY", from: 40, to: 0, duration: 800, ease: "elastic.out" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const blurIn: Preset = {
|
|
24
|
+
name: "enter/blur-in",
|
|
25
|
+
split: "word",
|
|
26
|
+
stagger: 60,
|
|
27
|
+
animations: [
|
|
28
|
+
{ prop: "opacity", from: 0, to: 1, duration: 500, ease: "sine.out" },
|
|
29
|
+
{ prop: "blur", from: 8, to: 0, duration: 500, ease: "sine.out" },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const scaleIn: Preset = {
|
|
34
|
+
name: "enter/scale-in",
|
|
35
|
+
split: "character",
|
|
36
|
+
stagger: 35,
|
|
37
|
+
animations: [
|
|
38
|
+
{ prop: "opacity", from: 0, to: 1, duration: 300, ease: "power2.out" },
|
|
39
|
+
{ prop: "scale", from: 0.3, to: 1, duration: 600, ease: "back.out" },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const flipX: Preset = {
|
|
44
|
+
name: "enter/flip-x",
|
|
45
|
+
split: "character",
|
|
46
|
+
stagger: 40,
|
|
47
|
+
perspective: 800,
|
|
48
|
+
transformOrigin: { x: 0.5, y: 1 },
|
|
49
|
+
animations: [
|
|
50
|
+
{ prop: "opacity", from: 0, to: 1, duration: 400, ease: "power2.out" },
|
|
51
|
+
{ prop: "rotateX", from: -90, to: 0, duration: 700, ease: "back.out" },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const depthZoom: Preset = {
|
|
56
|
+
name: "enter/depth-zoom",
|
|
57
|
+
split: "character",
|
|
58
|
+
stagger: 35,
|
|
59
|
+
perspective: 900,
|
|
60
|
+
animations: [
|
|
61
|
+
{ prop: "opacity", from: 0, to: 1, duration: 500, ease: "power2.out" },
|
|
62
|
+
{ prop: "translateZ", from: -400, to: 0, duration: 700, ease: "power3.out" },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Preset } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const fadeDown: Preset = {
|
|
4
|
+
name: "exit/fade-down",
|
|
5
|
+
split: "character",
|
|
6
|
+
stagger: 30,
|
|
7
|
+
animations: [
|
|
8
|
+
{ prop: "opacity", from: 1, to: 0, duration: 400, ease: "power2.in" },
|
|
9
|
+
{ prop: "translateY", from: 0, to: 20, duration: 400, ease: "power2.in" },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const scaleOut: Preset = {
|
|
14
|
+
name: "exit/scale-out",
|
|
15
|
+
split: "character",
|
|
16
|
+
stagger: 30,
|
|
17
|
+
animations: [
|
|
18
|
+
{ prop: "opacity", from: 1, to: 0, duration: 300, ease: "power2.in" },
|
|
19
|
+
{ prop: "scale", from: 1, to: 0.3, duration: 450, ease: "back.in" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const flipAway: Preset = {
|
|
24
|
+
name: "exit/flip-away",
|
|
25
|
+
split: "character",
|
|
26
|
+
stagger: 35,
|
|
27
|
+
perspective: 800,
|
|
28
|
+
transformOrigin: { x: 0.5, y: 0.5 },
|
|
29
|
+
animations: [
|
|
30
|
+
{ prop: "opacity", from: 1, to: 0, duration: 400, ease: "power2.in", delay: 150 },
|
|
31
|
+
{ prop: "rotateY", from: 0, to: 90, duration: 550, ease: "back.in" },
|
|
32
|
+
],
|
|
33
|
+
};
|