@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Preset, PresetName } from "../types.js";
|
|
2
|
+
import { blurIn, depthZoom, elasticRise, fadeUp, flipX, scaleIn } from "./enter.js";
|
|
3
|
+
import { fadeDown, flipAway, scaleOut } from "./exit.js";
|
|
4
|
+
import { coinFlip, pulse, shake, spin, wobble } from "./emphasis.js";
|
|
5
|
+
import { ALL_GENERATED } from "./generated.js";
|
|
6
|
+
|
|
7
|
+
const HANDCURATED: Record<string, Preset> = {
|
|
8
|
+
"enter/fade-up": fadeUp,
|
|
9
|
+
"enter/elastic-rise": elasticRise,
|
|
10
|
+
"enter/blur-in": blurIn,
|
|
11
|
+
"enter/scale-in": scaleIn,
|
|
12
|
+
"enter/flip-x": flipX,
|
|
13
|
+
"enter/depth-zoom": depthZoom,
|
|
14
|
+
"exit/fade-down": fadeDown,
|
|
15
|
+
"exit/scale-out": scaleOut,
|
|
16
|
+
"exit/flip-away": flipAway,
|
|
17
|
+
"emphasis/pulse": pulse,
|
|
18
|
+
"emphasis/shake": shake,
|
|
19
|
+
"emphasis/wobble": wobble,
|
|
20
|
+
"emphasis/coin-flip": coinFlip,
|
|
21
|
+
"emphasis/spin": spin,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Merge handcurated + generated into a single runtime registry. Generated
|
|
25
|
+
// entries don't shadow handcurated ones — the catalog grows by addition.
|
|
26
|
+
const PRESETS: Record<string, Preset> = { ...HANDCURATED };
|
|
27
|
+
for (const { name, preset } of ALL_GENERATED) PRESETS[name] = preset;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The hand-curated seed catalog, keyed by the closed
|
|
31
|
+
* `HandcuratedPresetName` literal union. Useful for tests + tooling
|
|
32
|
+
* that want to assert against the seed set without picking up
|
|
33
|
+
* generated entries (which grow dynamically via `ALL_GENERATED`).
|
|
34
|
+
*/
|
|
35
|
+
export const HANDCURATED_NAMES: readonly string[] = Object.keys(HANDCURATED);
|
|
36
|
+
|
|
37
|
+
export function resolvePreset(name: PresetName): Preset {
|
|
38
|
+
const p = PRESETS[name];
|
|
39
|
+
if (!p) throw new Error(`@vysmo/text: unknown preset "${name}"`);
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function listPresets(): PresetName[] {
|
|
44
|
+
return Object.keys(PRESETS) as PresetName[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
fadeUp,
|
|
49
|
+
elasticRise,
|
|
50
|
+
blurIn,
|
|
51
|
+
scaleIn,
|
|
52
|
+
flipX,
|
|
53
|
+
depthZoom,
|
|
54
|
+
fadeDown,
|
|
55
|
+
scaleOut,
|
|
56
|
+
flipAway,
|
|
57
|
+
pulse,
|
|
58
|
+
shake,
|
|
59
|
+
wobble,
|
|
60
|
+
coinFlip,
|
|
61
|
+
spin,
|
|
62
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TextProperty } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type PropValues = Partial<Record<TextProperty, number>>;
|
|
4
|
+
|
|
5
|
+
const TRANSFORM_PROPS: ReadonlyArray<TextProperty> = [
|
|
6
|
+
"translateX",
|
|
7
|
+
"translateY",
|
|
8
|
+
"translateZ",
|
|
9
|
+
"rotate",
|
|
10
|
+
"rotateX",
|
|
11
|
+
"rotateY",
|
|
12
|
+
"rotateZ",
|
|
13
|
+
"scale",
|
|
14
|
+
"scaleX",
|
|
15
|
+
"scaleY",
|
|
16
|
+
"skewX",
|
|
17
|
+
"skewY",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const FILTER_PROPS: ReadonlyArray<TextProperty> = [
|
|
21
|
+
"blur",
|
|
22
|
+
"brightness",
|
|
23
|
+
"contrast",
|
|
24
|
+
"saturate",
|
|
25
|
+
"hueRotate",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compose numeric per-axis values into the CSS strings the browser wants.
|
|
30
|
+
* `transform` and `filter` are each composed as a single declaration so
|
|
31
|
+
* successive writes don't leak stale parts from previous frames.
|
|
32
|
+
*/
|
|
33
|
+
export function applyProps(el: HTMLElement, vals: PropValues): void {
|
|
34
|
+
if (vals.opacity !== undefined) {
|
|
35
|
+
el.style.opacity = String(vals.opacity);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hasTransform = TRANSFORM_PROPS.some((p) => vals[p] !== undefined);
|
|
39
|
+
if (hasTransform) {
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
if (
|
|
42
|
+
vals.translateX !== undefined ||
|
|
43
|
+
vals.translateY !== undefined ||
|
|
44
|
+
vals.translateZ !== undefined
|
|
45
|
+
) {
|
|
46
|
+
parts.push(
|
|
47
|
+
`translate3d(${vals.translateX ?? 0}px, ${vals.translateY ?? 0}px, ${vals.translateZ ?? 0}px)`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (vals.rotate !== undefined) parts.push(`rotate(${vals.rotate}deg)`);
|
|
51
|
+
if (vals.rotateX !== undefined) parts.push(`rotateX(${vals.rotateX}deg)`);
|
|
52
|
+
if (vals.rotateY !== undefined) parts.push(`rotateY(${vals.rotateY}deg)`);
|
|
53
|
+
if (vals.rotateZ !== undefined) parts.push(`rotateZ(${vals.rotateZ}deg)`);
|
|
54
|
+
if (vals.scale !== undefined) parts.push(`scale(${vals.scale})`);
|
|
55
|
+
if (vals.scaleX !== undefined) parts.push(`scaleX(${vals.scaleX})`);
|
|
56
|
+
if (vals.scaleY !== undefined) parts.push(`scaleY(${vals.scaleY})`);
|
|
57
|
+
if (vals.skewX !== undefined) parts.push(`skewX(${vals.skewX}deg)`);
|
|
58
|
+
if (vals.skewY !== undefined) parts.push(`skewY(${vals.skewY}deg)`);
|
|
59
|
+
el.style.transform = parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hasFilter = FILTER_PROPS.some((p) => vals[p] !== undefined);
|
|
63
|
+
if (hasFilter) {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (vals.blur !== undefined) parts.push(`blur(${vals.blur}px)`);
|
|
66
|
+
if (vals.brightness !== undefined) parts.push(`brightness(${vals.brightness})`);
|
|
67
|
+
if (vals.contrast !== undefined) parts.push(`contrast(${vals.contrast})`);
|
|
68
|
+
if (vals.saturate !== undefined) parts.push(`saturate(${vals.saturate})`);
|
|
69
|
+
if (vals.hueRotate !== undefined) parts.push(`hue-rotate(${vals.hueRotate}deg)`);
|
|
70
|
+
el.style.filter = parts.join(" ");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearProps(el: HTMLElement): void {
|
|
75
|
+
el.style.removeProperty("opacity");
|
|
76
|
+
el.style.removeProperty("transform");
|
|
77
|
+
el.style.removeProperty("filter");
|
|
78
|
+
}
|
package/src/split.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { SplitMode, SplitOptions, Splits } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const WHITESPACE_ONLY = /^\s+$/;
|
|
4
|
+
|
|
5
|
+
function hasSegmenter(): boolean {
|
|
6
|
+
return typeof Intl !== "undefined" && typeof Intl.Segmenter === "function";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function graphemeSegments(text: string, locale: string | undefined): string[] {
|
|
10
|
+
if (hasSegmenter()) {
|
|
11
|
+
const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
|
|
12
|
+
return Array.from(seg.segment(text), (s) => s.segment);
|
|
13
|
+
}
|
|
14
|
+
return Array.from(text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wordSegments(
|
|
18
|
+
text: string,
|
|
19
|
+
locale: string | undefined,
|
|
20
|
+
): Array<{ segment: string; isWordLike: boolean }> {
|
|
21
|
+
if (hasSegmenter()) {
|
|
22
|
+
const seg = new Intl.Segmenter(locale, { granularity: "word" });
|
|
23
|
+
return Array.from(seg.segment(text), (s) => ({
|
|
24
|
+
segment: s.segment,
|
|
25
|
+
isWordLike: s.isWordLike ?? false,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
const parts = text.split(/(\s+)/).filter((s) => s.length > 0);
|
|
29
|
+
return parts.map((segment) => ({
|
|
30
|
+
segment,
|
|
31
|
+
isWordLike: !WHITESPACE_ONLY.test(segment),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeSliceSpan(text: string, kind: SplitMode): HTMLElement {
|
|
36
|
+
const span = document.createElement("span");
|
|
37
|
+
span.textContent = text;
|
|
38
|
+
span.style.display = "inline-block";
|
|
39
|
+
span.style.willChange = "transform, opacity, filter";
|
|
40
|
+
span.setAttribute("data-text-slice", kind);
|
|
41
|
+
span.setAttribute("aria-hidden", "true");
|
|
42
|
+
return span;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendScreenReaderCopy(element: HTMLElement, text: string): void {
|
|
46
|
+
const sr = document.createElement("span");
|
|
47
|
+
sr.textContent = text;
|
|
48
|
+
sr.setAttribute("data-text-sr", "");
|
|
49
|
+
const s = sr.style;
|
|
50
|
+
s.position = "absolute";
|
|
51
|
+
s.width = "1px";
|
|
52
|
+
s.height = "1px";
|
|
53
|
+
s.padding = "0";
|
|
54
|
+
s.margin = "-1px";
|
|
55
|
+
s.overflow = "hidden";
|
|
56
|
+
s.clip = "rect(0, 0, 0, 0)";
|
|
57
|
+
s.whiteSpace = "nowrap";
|
|
58
|
+
s.border = "0";
|
|
59
|
+
element.appendChild(sr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Split an element's text into per-slice spans suitable for independent
|
|
64
|
+
* transform/filter/opacity animation. Grapheme-safe via `Intl.Segmenter`
|
|
65
|
+
* when available (falls back to `Array.from(text)` for character mode and
|
|
66
|
+
* a whitespace-preserving regex for word mode).
|
|
67
|
+
*
|
|
68
|
+
* Line mode requires the element to be in the DOM and laid out — line
|
|
69
|
+
* boundaries are detected via `getBoundingClientRect().top` after words
|
|
70
|
+
* have been inserted.
|
|
71
|
+
*
|
|
72
|
+
* Script compatibility:
|
|
73
|
+
* - LTR and RTL (Arabic, Hebrew): word and line modes work correctly; the
|
|
74
|
+
* browser's bidi algorithm places inline-block siblings in visual order.
|
|
75
|
+
* - Connected / contextually-shaped scripts (Arabic, Devanagari, Lao,
|
|
76
|
+
* Khmer, …): character mode breaks shaping because each grapheme lands
|
|
77
|
+
* in its own inline-block box, preventing letters from joining. Prefer
|
|
78
|
+
* word or line mode for these scripts.
|
|
79
|
+
*/
|
|
80
|
+
export function splitText(element: HTMLElement, options: SplitOptions = {}): Splits {
|
|
81
|
+
if (typeof document === "undefined") {
|
|
82
|
+
throw new Error("splitText: requires a browser environment");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mode: SplitMode = options.mode ?? "character";
|
|
86
|
+
const original = element.textContent ?? "";
|
|
87
|
+
|
|
88
|
+
element.textContent = "";
|
|
89
|
+
appendScreenReaderCopy(element, original);
|
|
90
|
+
|
|
91
|
+
const slices: HTMLElement[] = [];
|
|
92
|
+
|
|
93
|
+
if (mode === "character") {
|
|
94
|
+
for (const g of graphemeSegments(original, options.locale)) {
|
|
95
|
+
if (WHITESPACE_ONLY.test(g)) {
|
|
96
|
+
element.appendChild(document.createTextNode(g));
|
|
97
|
+
} else {
|
|
98
|
+
const span = makeSliceSpan(g, "character");
|
|
99
|
+
element.appendChild(span);
|
|
100
|
+
slices.push(span);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if (mode === "word") {
|
|
104
|
+
// Wrap every non-whitespace segment (words AND punctuation) so nothing
|
|
105
|
+
// is left behind at opacity 0 / blur. Intl.Segmenter's word granularity
|
|
106
|
+
// classifies commas/exclamations/etc as `isWordLike: false`; relying on
|
|
107
|
+
// that would leave punctuation unanimated while its neighbours faded.
|
|
108
|
+
for (const { segment } of wordSegments(original, options.locale)) {
|
|
109
|
+
if (WHITESPACE_ONLY.test(segment)) {
|
|
110
|
+
element.appendChild(document.createTextNode(segment));
|
|
111
|
+
} else {
|
|
112
|
+
const span = makeSliceSpan(segment, "word");
|
|
113
|
+
element.appendChild(span);
|
|
114
|
+
slices.push(span);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// Line mode: insert temporary word spans, measure, wrap each visual
|
|
119
|
+
// line into a single line span, then demote inner word spans back to
|
|
120
|
+
// plain text so only the line span carries animatable style.
|
|
121
|
+
const tempWords: HTMLElement[] = [];
|
|
122
|
+
for (const { segment } of wordSegments(original, options.locale)) {
|
|
123
|
+
if (WHITESPACE_ONLY.test(segment)) {
|
|
124
|
+
element.appendChild(document.createTextNode(segment));
|
|
125
|
+
} else {
|
|
126
|
+
const span = makeSliceSpan(segment, "word");
|
|
127
|
+
element.appendChild(span);
|
|
128
|
+
tempWords.push(span);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const groups: HTMLElement[][] = [];
|
|
133
|
+
let currentTop: number | null = null;
|
|
134
|
+
let currentGroup: HTMLElement[] = [];
|
|
135
|
+
for (const span of tempWords) {
|
|
136
|
+
const top = Math.round(span.getBoundingClientRect().top);
|
|
137
|
+
if (currentTop === null || Math.abs(top - currentTop) > 1) {
|
|
138
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
139
|
+
currentGroup = [];
|
|
140
|
+
currentTop = top;
|
|
141
|
+
}
|
|
142
|
+
currentGroup.push(span);
|
|
143
|
+
}
|
|
144
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
145
|
+
|
|
146
|
+
for (const group of groups) {
|
|
147
|
+
const firstWord = group[0]!;
|
|
148
|
+
const lastWord = group[group.length - 1]!;
|
|
149
|
+
const lineWrap = makeSliceSpan("", "line");
|
|
150
|
+
lineWrap.textContent = "";
|
|
151
|
+
firstWord.parentNode!.insertBefore(lineWrap, firstWord);
|
|
152
|
+
let node: ChildNode | null = firstWord;
|
|
153
|
+
while (node) {
|
|
154
|
+
const next: ChildNode | null = node.nextSibling;
|
|
155
|
+
lineWrap.appendChild(node);
|
|
156
|
+
if (node === lastWord) break;
|
|
157
|
+
node = next;
|
|
158
|
+
}
|
|
159
|
+
// Demote inner word spans to plain text — only the line span animates.
|
|
160
|
+
for (const word of group) {
|
|
161
|
+
word.replaceWith(document.createTextNode(word.textContent ?? ""));
|
|
162
|
+
}
|
|
163
|
+
slices.push(lineWrap);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let restored = false;
|
|
168
|
+
const splits: Splits = {
|
|
169
|
+
slices,
|
|
170
|
+
mode,
|
|
171
|
+
original,
|
|
172
|
+
restore() {
|
|
173
|
+
if (restored) return;
|
|
174
|
+
restored = true;
|
|
175
|
+
element.textContent = original;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return splits;
|
|
180
|
+
}
|
package/src/stagger.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { StaggerOrder } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Given N slices, a per-step gap, and an order strategy, return the
|
|
5
|
+
* start-delay (in ms) to apply to each slice — index-aligned.
|
|
6
|
+
*
|
|
7
|
+
* - "start": 0, 1g, 2g, 3g, …
|
|
8
|
+
* - "end": reversed
|
|
9
|
+
* - "center": grows outward from the center index
|
|
10
|
+
* - "edges": grows inward from the edges toward the center
|
|
11
|
+
* - "random": a uniform random permutation of the above ranks
|
|
12
|
+
*/
|
|
13
|
+
export function computeStaggerDelays(
|
|
14
|
+
count: number,
|
|
15
|
+
stagger: number,
|
|
16
|
+
order: StaggerOrder,
|
|
17
|
+
rng: () => number = Math.random,
|
|
18
|
+
): number[] {
|
|
19
|
+
if (count <= 0) return [];
|
|
20
|
+
if (stagger <= 0) return new Array<number>(count).fill(0);
|
|
21
|
+
|
|
22
|
+
const ranks = new Array<number>(count);
|
|
23
|
+
|
|
24
|
+
switch (order) {
|
|
25
|
+
case "start":
|
|
26
|
+
for (let i = 0; i < count; i++) ranks[i] = i;
|
|
27
|
+
break;
|
|
28
|
+
case "end":
|
|
29
|
+
for (let i = 0; i < count; i++) ranks[i] = count - 1 - i;
|
|
30
|
+
break;
|
|
31
|
+
case "center": {
|
|
32
|
+
const mid = (count - 1) / 2;
|
|
33
|
+
for (let i = 0; i < count; i++) ranks[i] = Math.round(Math.abs(i - mid));
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "edges": {
|
|
37
|
+
const mid = (count - 1) / 2;
|
|
38
|
+
for (let i = 0; i < count; i++) ranks[i] = Math.round(mid - Math.abs(i - mid));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "random": {
|
|
42
|
+
const perm = Array.from({ length: count }, (_, i) => i);
|
|
43
|
+
for (let i = perm.length - 1; i > 0; i--) {
|
|
44
|
+
const j = Math.floor(rng() * (i + 1));
|
|
45
|
+
const tmp = perm[i]!;
|
|
46
|
+
perm[i] = perm[j]!;
|
|
47
|
+
perm[j] = tmp;
|
|
48
|
+
}
|
|
49
|
+
for (let rank = 0; rank < count; rank++) ranks[perm[rank]!] = rank;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ranks.map((r) => r * stagger);
|
|
55
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { EasingFn } from "@vysmo/easings";
|
|
2
|
+
import type { Scheduler } from "@vysmo/animations";
|
|
3
|
+
|
|
4
|
+
export type SplitMode = "character" | "word" | "line";
|
|
5
|
+
|
|
6
|
+
export type SplitOptions = {
|
|
7
|
+
/** Granularity of the split. Default "character". */
|
|
8
|
+
mode?: SplitMode;
|
|
9
|
+
/** BCP-47 locale for Intl.Segmenter. Falls back to the environment default. */
|
|
10
|
+
locale?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Splits = {
|
|
14
|
+
/** Slice elements in document order (characters, words, or lines). */
|
|
15
|
+
readonly slices: HTMLElement[];
|
|
16
|
+
/** The split granularity. */
|
|
17
|
+
readonly mode: SplitMode;
|
|
18
|
+
/** The original text captured before the split. */
|
|
19
|
+
readonly original: string;
|
|
20
|
+
/** Restore the element's original text. Idempotent. */
|
|
21
|
+
restore(): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The animatable axes understood by animateText. Numeric-only by design — the
|
|
26
|
+
* runtime composes these into `transform` / `filter` / `opacity` strings per
|
|
27
|
+
* frame, so shape mismatches are impossible and per-slice blending stays cheap.
|
|
28
|
+
*/
|
|
29
|
+
export type TextProperty =
|
|
30
|
+
| "opacity"
|
|
31
|
+
| "translateX"
|
|
32
|
+
| "translateY"
|
|
33
|
+
| "translateZ"
|
|
34
|
+
| "scale"
|
|
35
|
+
| "scaleX"
|
|
36
|
+
| "scaleY"
|
|
37
|
+
| "rotate"
|
|
38
|
+
| "rotateX"
|
|
39
|
+
| "rotateY"
|
|
40
|
+
| "rotateZ"
|
|
41
|
+
| "skewX"
|
|
42
|
+
| "skewY"
|
|
43
|
+
| "blur"
|
|
44
|
+
| "brightness"
|
|
45
|
+
| "contrast"
|
|
46
|
+
| "saturate"
|
|
47
|
+
| "hueRotate";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A scalar or a uniform range. Range values resolve **per slice** at
|
|
51
|
+
* animate-start: every slice samples its own value, so animations like
|
|
52
|
+
* "letters scatter from random offsets and converge on 0" become a
|
|
53
|
+
* one-line spec change. `{ min: x, max: x }` resolves to the constant
|
|
54
|
+
* `x` without consuming an rng draw.
|
|
55
|
+
*/
|
|
56
|
+
export type TextValue = number | { min: number; max: number };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transform-origin in normalized form. `x`/`y` are fractions (0..1, where
|
|
60
|
+
* 0 is left/top and 1 is right/bottom — `{ x: 0.5, y: 1 }` = bottom
|
|
61
|
+
* center). Optional `z` is in pixels and lets 3D transforms pivot in
|
|
62
|
+
* front of / behind the slice. Stored as data instead of a CSS string so
|
|
63
|
+
* the same preset can drive a future canvas/Skia runtime without parsing.
|
|
64
|
+
*/
|
|
65
|
+
export type TransformOrigin = {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
z?: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type TextAnimationSpec = {
|
|
72
|
+
prop: TextProperty;
|
|
73
|
+
from: TextValue;
|
|
74
|
+
to: TextValue;
|
|
75
|
+
/** Duration in milliseconds. Default 600. */
|
|
76
|
+
duration?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Delay (ms) from the slice's stagger offset before this spec begins.
|
|
79
|
+
* Sequential specs targeting the same prop chain via their delays.
|
|
80
|
+
*/
|
|
81
|
+
delay?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Easing for this spec. Default linear.
|
|
84
|
+
*
|
|
85
|
+
* Two forms accepted:
|
|
86
|
+
* - **String** (preferred for catalog presets) — a GSAP-style spec like
|
|
87
|
+
* `"power2.out"`, `"back.out(2)"`, `"elastic.out(1.2, 0.4)"`,
|
|
88
|
+
* `"cubic-bezier(0.42, 0, 0.58, 1)"`. Resolved at animate-start via
|
|
89
|
+
* `parseEasing()` from `@vysmo/easings`. Strings are JSON-serializable
|
|
90
|
+
* so the same preset data can drive a future canvas/Skia runtime
|
|
91
|
+
* without losing fidelity.
|
|
92
|
+
* - **Function** — any `EasingFn`/`(t: number) => number`. Use this when
|
|
93
|
+
* you bring a custom curve that isn't in the catalog.
|
|
94
|
+
*/
|
|
95
|
+
ease?: string | EasingFn | ((t: number) => number);
|
|
96
|
+
/**
|
|
97
|
+
* Override the root stagger for this spec only. Enables a per-prop cadence
|
|
98
|
+
* — e.g. translateY staggered at 30ms while opacity staggers at 10ms —
|
|
99
|
+
* which is the main knob the authoring studio uses to explore variety.
|
|
100
|
+
*/
|
|
101
|
+
stagger?: number;
|
|
102
|
+
/** Override the root staggerOrder for this spec only. */
|
|
103
|
+
staggerOrder?: StaggerOrder;
|
|
104
|
+
/**
|
|
105
|
+
* Per-slice random delay in ms added on top of the slice's stagger
|
|
106
|
+
* offset. Each slice gets its own value uniformly sampled from
|
|
107
|
+
* `[0, jitterDelay]` at animate-start. Default 0. Useful for breaking
|
|
108
|
+
* the "metronome" feel of pure stagger by spraying start times.
|
|
109
|
+
*/
|
|
110
|
+
jitterDelay?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Override the preset / option-level `transformOrigin` for the
|
|
113
|
+
* duration of this spec. Written to each slice's inline style at the
|
|
114
|
+
* moment the spec's window opens for that slice; persists until the
|
|
115
|
+
* next override on the same slice (or `stop()`). When two specs with
|
|
116
|
+
* different origins overlap on a slice, the most recently-opened
|
|
117
|
+
* spec wins (last-write-wins).
|
|
118
|
+
*/
|
|
119
|
+
transformOrigin?: TransformOrigin;
|
|
120
|
+
/**
|
|
121
|
+
* Override the container `perspective` (in px) for the duration of
|
|
122
|
+
* this spec. Written to the container element when the spec's window
|
|
123
|
+
* first opens for any slice; persists until the next override (or
|
|
124
|
+
* `stop()`). Container-scoped, so it affects every slice — same
|
|
125
|
+
* scope as the option / preset-level `perspective`.
|
|
126
|
+
*/
|
|
127
|
+
perspective?: number;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type StaggerOrder = "start" | "end" | "center" | "edges" | "random";
|
|
131
|
+
|
|
132
|
+
export type AnimateTextOptions = {
|
|
133
|
+
/** Split granularity. Overridden by the preset's split when unset. Default "character". */
|
|
134
|
+
split?: SplitMode;
|
|
135
|
+
/** BCP-47 locale for Intl.Segmenter. */
|
|
136
|
+
locale?: string;
|
|
137
|
+
/** Milliseconds between consecutive slices starting to animate. Default 30. */
|
|
138
|
+
stagger?: number;
|
|
139
|
+
/** Order in which slices receive their stagger offset. Default "start". */
|
|
140
|
+
staggerOrder?: StaggerOrder;
|
|
141
|
+
/** One or more animated properties running in parallel per slice. */
|
|
142
|
+
animations?: TextAnimationSpec[];
|
|
143
|
+
/**
|
|
144
|
+
* Preset to apply. Accepts either the catalog name (convenience, pulls
|
|
145
|
+
* the whole registry) or a `Preset` object reference (tree-shakable —
|
|
146
|
+
* the unused 14/15/… presets stay out of the bundle).
|
|
147
|
+
*/
|
|
148
|
+
preset?: PresetName | Preset;
|
|
149
|
+
/**
|
|
150
|
+
* CSS perspective (px) applied to the container so children's rotateX /
|
|
151
|
+
* rotateY / translateZ render with depth. Required for 3D transforms to
|
|
152
|
+
* look 3D — without it, rotateY(90deg) is just a 1px-wide line.
|
|
153
|
+
*/
|
|
154
|
+
perspective?: number;
|
|
155
|
+
/** CSS perspective-origin string applied to the container (e.g., "50% 30%"). */
|
|
156
|
+
perspectiveOrigin?: string;
|
|
157
|
+
/** Transform-origin applied to every slice. `{ x: 0.5, y: 0 }` = top center. */
|
|
158
|
+
transformOrigin?: TransformOrigin;
|
|
159
|
+
/** Begin playing automatically. Default true. */
|
|
160
|
+
autoPlay?: boolean;
|
|
161
|
+
/** When true, skip animation under prefers-reduced-motion. Default true. */
|
|
162
|
+
respectReducedMotion?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Delay in ms before the first play begins. Useful for sequencing an
|
|
165
|
+
* entry after some other animation completes.
|
|
166
|
+
*/
|
|
167
|
+
delay?: number;
|
|
168
|
+
/**
|
|
169
|
+
* How many times the whole choreography plays. `1` (default) = play
|
|
170
|
+
* once. A number > 1 = that many cycles. `"infinite"` = loop forever
|
|
171
|
+
* (or until `.stop()`). Emphasis presets like pulse typically want
|
|
172
|
+
* `repeat: 3, repeatDelay: 400` for a "triple-tap" feel.
|
|
173
|
+
*/
|
|
174
|
+
repeat?: number | "infinite";
|
|
175
|
+
/** Delay between successive cycles when `repeat > 1`. Default 0. */
|
|
176
|
+
repeatDelay?: number;
|
|
177
|
+
/** Override the time source for deterministic playback in tests. */
|
|
178
|
+
scheduler?: Scheduler;
|
|
179
|
+
/** Deterministic RNG for "random" stagger order. Defaults to Math.random. */
|
|
180
|
+
rng?: () => number;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export type AnimateTextHandle = {
|
|
184
|
+
/** Start or resume playback. */
|
|
185
|
+
play(): AnimateTextHandle;
|
|
186
|
+
/** Pause playback, preserving progress. */
|
|
187
|
+
pause(): AnimateTextHandle;
|
|
188
|
+
/** Stop playback and reset slices to their un-animated style. */
|
|
189
|
+
stop(): AnimateTextHandle;
|
|
190
|
+
/** Jump every slice to the given progress in [0, 1]. */
|
|
191
|
+
seek(progress: number): AnimateTextHandle;
|
|
192
|
+
/** Resolves when every slice has finished naturally. */
|
|
193
|
+
readonly finished: Promise<void>;
|
|
194
|
+
/** The split result backing this animation. */
|
|
195
|
+
readonly splits: Splits;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export type Preset = {
|
|
199
|
+
name: PresetName;
|
|
200
|
+
/** Split granularity this preset was tuned against. */
|
|
201
|
+
split?: SplitMode;
|
|
202
|
+
/** Default stagger; callers can override. */
|
|
203
|
+
stagger: number;
|
|
204
|
+
/** Stagger order this preset was tuned against. */
|
|
205
|
+
staggerOrder?: StaggerOrder;
|
|
206
|
+
animations: TextAnimationSpec[];
|
|
207
|
+
/** Container perspective (px) required for this preset's 3D transforms. */
|
|
208
|
+
perspective?: number;
|
|
209
|
+
/** Slice-level transform-origin this preset was tuned against. */
|
|
210
|
+
transformOrigin?: TransformOrigin;
|
|
211
|
+
/** Default play count for this preset (e.g. emphasis/pulse repeating 3×). */
|
|
212
|
+
repeat?: number | "infinite";
|
|
213
|
+
/** Default gap between cycles in ms. */
|
|
214
|
+
repeatDelay?: number;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Hand-curated preset names — the seed catalog. These autocomplete in
|
|
219
|
+
* IDEs and stay typed in tests / consumer code.
|
|
220
|
+
*/
|
|
221
|
+
export type HandcuratedPresetName =
|
|
222
|
+
| "enter/fade-up"
|
|
223
|
+
| "enter/elastic-rise"
|
|
224
|
+
| "enter/blur-in"
|
|
225
|
+
| "enter/scale-in"
|
|
226
|
+
| "enter/flip-x"
|
|
227
|
+
| "enter/depth-zoom"
|
|
228
|
+
| "exit/fade-down"
|
|
229
|
+
| "exit/scale-out"
|
|
230
|
+
| "exit/flip-away"
|
|
231
|
+
| "emphasis/pulse"
|
|
232
|
+
| "emphasis/shake"
|
|
233
|
+
| "emphasis/wobble"
|
|
234
|
+
| "emphasis/coin-flip"
|
|
235
|
+
| "emphasis/spin";
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Any registered preset name — handcurated entries autocomplete via the
|
|
239
|
+
* literal union; the `string & {}` branch keeps the type open for the
|
|
240
|
+
* generated catalog (300+ entries authored via the Studio's random
|
|
241
|
+
* generator). Lookup at runtime is a string key against `PRESETS`, so
|
|
242
|
+
* unknown names throw at `resolvePreset` rather than at the type
|
|
243
|
+
* boundary.
|
|
244
|
+
*/
|
|
245
|
+
export type PresetName = HandcuratedPresetName | (string & {});
|