animate-text-weight 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/README.md +76 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +330 -0
- package/package.json +34 -0
- package/src/animateTextWeight.css +218 -0
- package/src/index.ts +388 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# animateTextWeight
|
|
2
|
+
|
|
3
|
+
Animate a headline, button or link between two weights of a variable font.
|
|
4
|
+
The text can span one line or several, but it works best on short text.
|
|
5
|
+
Body text animates fine in Chrome, but jitters in Firefox.
|
|
6
|
+
|
|
7
|
+
- The element keeps its default `from-weight` width, so no line moves and no line break changes.
|
|
8
|
+
- The `from` state keeps your letter-spacing. The `to` state gets
|
|
9
|
+
spacing matched per line, so every line ends where it started.
|
|
10
|
+
|
|
11
|
+
Everything you write lives in CSS, in your components. There is no
|
|
12
|
+
class needed on the elements you want to animate, because the script finds the
|
|
13
|
+
animated elements on its own. That also makes it work with any CMS: nothing is
|
|
14
|
+
pre-rendered, the browser measures at runtime.
|
|
15
|
+
|
|
16
|
+
## Sandbox
|
|
17
|
+
|
|
18
|
+
Run `npm run demo` and open `http://localhost:3000/demo/`. Type any text,
|
|
19
|
+
set width, size and the two weights, hover the preview.
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
Import the stylesheet and start the script once per page:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import "animate-text-weight/style.css";
|
|
27
|
+
import { init } from "animate-text-weight";
|
|
28
|
+
init();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Set the `to` weight on the element, then switch it on wherever you like.
|
|
32
|
+
The element's own `font-weight` is the `from` weight:
|
|
33
|
+
|
|
34
|
+
```css
|
|
35
|
+
h1 {
|
|
36
|
+
font-weight: 200;
|
|
37
|
+
--animate-weight-to: 700;
|
|
38
|
+
}
|
|
39
|
+
section:hover h1,
|
|
40
|
+
section.active h1 {
|
|
41
|
+
--animate-weight: true;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--animate-weight` inherits, so you can set it on the section instead of
|
|
46
|
+
each heading. `--animate-weight-from` overrides the `from` weight when you
|
|
47
|
+
need it. Duration and easing: `--animate-weight-duration` and
|
|
48
|
+
`--animate-weight-easing` on `:root`.
|
|
49
|
+
|
|
50
|
+
## Animate other properties with the weight animation
|
|
51
|
+
|
|
52
|
+
The span that animates carries class `animate-text-weight-unit` and tweens
|
|
53
|
+
`--animate-weight-progress` on itself from 0 to 1. Read that value to tie any
|
|
54
|
+
other property to the same curve — here the text fades in as it gets bolder:
|
|
55
|
+
|
|
56
|
+
```css
|
|
57
|
+
.animate-text-weight-unit {
|
|
58
|
+
opacity: clamp(0, var(--animate-weight-progress), 1);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This works for character animations too, with nothing added. In character mode
|
|
63
|
+
the unit is the character, so each one carries its own progress and its own
|
|
64
|
+
delay, and the fade arrives as a wave:
|
|
65
|
+
|
|
66
|
+
```css
|
|
67
|
+
h1 {
|
|
68
|
+
font-weight: 200;
|
|
69
|
+
--animate-weight-to: 700;
|
|
70
|
+
--animate-weight-by: character;
|
|
71
|
+
--animate-weight-delay: 40ms;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Clamp anything you read, the way the library does: an easing may overshoot 0
|
|
76
|
+
or 1.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Class of one rendered line, styled by animateTextWeight.css. */
|
|
2
|
+
export declare const LINE = "animate-text-weight-line";
|
|
3
|
+
/** Class of whatever animates: the line, or one character inside it. */
|
|
4
|
+
export declare const UNIT = "animate-text-weight-unit";
|
|
5
|
+
/** Class of one character, which gives it a box of its own. */
|
|
6
|
+
export declare const CHAR = "animate-text-weight-char";
|
|
7
|
+
/** What one unit is: a whole line, or a single character. */
|
|
8
|
+
export type Mode = "line" | "character";
|
|
9
|
+
export interface Unit {
|
|
10
|
+
text: string;
|
|
11
|
+
/** Its kerned advance at the from weight, px. */
|
|
12
|
+
target: number;
|
|
13
|
+
/** The box it is given at each stop, px. These add up to the line's target. */
|
|
14
|
+
boxes: number[];
|
|
15
|
+
/** How many delays this unit waits before it starts. */
|
|
16
|
+
delayCount: number;
|
|
17
|
+
}
|
|
18
|
+
export interface Line {
|
|
19
|
+
text: string;
|
|
20
|
+
/** The width the line is held at, px. */
|
|
21
|
+
target: number;
|
|
22
|
+
/** What the line would measure at each stop, left alone, px. */
|
|
23
|
+
widths: number[];
|
|
24
|
+
/** Line mode: its letter-spacing at each stop, px. Empty in character mode. */
|
|
25
|
+
spacing: number[];
|
|
26
|
+
/** How many delays it waits. Zero in character mode: its characters wait. */
|
|
27
|
+
delayCount: number;
|
|
28
|
+
/** One per character in character mode, empty in line mode. */
|
|
29
|
+
units: Unit[];
|
|
30
|
+
}
|
|
31
|
+
export interface Report {
|
|
32
|
+
from: number;
|
|
33
|
+
to: number;
|
|
34
|
+
/** What was actually split, which can be less than asked for. */
|
|
35
|
+
by: Mode;
|
|
36
|
+
/** The weight at each stop, from first to last. */
|
|
37
|
+
weights: number[];
|
|
38
|
+
lines: Line[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The weight range set in CSS, or null when the element does not animate.
|
|
42
|
+
* --animate-weight-from is optional: the element's font-weight is the from weight.
|
|
43
|
+
*/
|
|
44
|
+
export declare function range(el: Element): {
|
|
45
|
+
from: number;
|
|
46
|
+
to: number;
|
|
47
|
+
by: Mode;
|
|
48
|
+
} | null;
|
|
49
|
+
/** Elements under root that carry a weight range and hold text directly. */
|
|
50
|
+
export declare function findAll(root?: ParentNode): HTMLElement[];
|
|
51
|
+
/** Words of the live element grouped by rendered line. */
|
|
52
|
+
export declare function readLines(el: HTMLElement): string[];
|
|
53
|
+
/** Split one element into its lines, then its units, and write their values. */
|
|
54
|
+
export declare function calibrate(el: HTMLElement): Report | null;
|
|
55
|
+
/** Calibrate every animated element under root, now and after each resize. */
|
|
56
|
+
export declare function calibrateAll(root?: ParentNode): HTMLElement[];
|
|
57
|
+
/** Wait for the fonts, then calibrate the page. */
|
|
58
|
+
export declare function init(root?: ParentNode): Promise<HTMLElement[]>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* animateTextWeight: measure each rendered line of an animated element and
|
|
3
|
+
* write the numbers that keep it at its width at every weight.
|
|
4
|
+
*
|
|
5
|
+
* A string's width at a weight depends on its letters, so no table per font
|
|
6
|
+
* can hold every text to the same width, and one spacing per element can
|
|
7
|
+
* hold one line only. So the element is split into its rendered lines, one
|
|
8
|
+
* span per line. A line that animates whole gets eleven --atw-ls-<stop>
|
|
9
|
+
* letter-spacings; a line that animates by character is split again and each
|
|
10
|
+
* character gets eleven --atw-w-<stop> box widths, which add up to the line's
|
|
11
|
+
* width at every stop. animateTextWeight.css reads them.
|
|
12
|
+
*
|
|
13
|
+
* The element says what it wants in CSS: --animate-weight-from and
|
|
14
|
+
* --animate-weight-to. The script finds every element that carries both.
|
|
15
|
+
*
|
|
16
|
+
* Line breaks are read from the live element with a Range. The spans are
|
|
17
|
+
* nowrap and joined by <br>, so the lines are frozen: they can get wider or
|
|
18
|
+
* narrower, never reflow. Runs again on resize, from the original content,
|
|
19
|
+
* which is kept aside.
|
|
20
|
+
*/
|
|
21
|
+
/** Class of one rendered line, styled by animateTextWeight.css. */
|
|
22
|
+
export const LINE = "animate-text-weight-line";
|
|
23
|
+
/** Class of whatever animates: the line, or one character inside it. */
|
|
24
|
+
export const UNIT = "animate-text-weight-unit";
|
|
25
|
+
/** Class of one character, which gives it a box of its own. */
|
|
26
|
+
export const CHAR = "animate-text-weight-char";
|
|
27
|
+
/**
|
|
28
|
+
* The CSS map has STOPS values, --atw-ls-0 to --atw-ls-(STOPS - 1).
|
|
29
|
+
*
|
|
30
|
+
* An odd number, so the middle weight is one of them. A variable font is drawn
|
|
31
|
+
* at a few weights and interpolated between them, and the middle of the range
|
|
32
|
+
* is very often one of those drawings: measure it and the line holds all the
|
|
33
|
+
* way through, miss it and the line dips by about two pixels halfway.
|
|
34
|
+
*/
|
|
35
|
+
const STOPS = 11;
|
|
36
|
+
/** Original child nodes of each split element, so it can be split again. */
|
|
37
|
+
const sources = new WeakMap();
|
|
38
|
+
/**
|
|
39
|
+
* The weight range set in CSS, or null when the element does not animate.
|
|
40
|
+
* --animate-weight-from is optional: the element's font-weight is the from weight.
|
|
41
|
+
*/
|
|
42
|
+
export function range(el) {
|
|
43
|
+
const cs = getComputedStyle(el);
|
|
44
|
+
const from = Number(cs.getPropertyValue("--animate-weight-from")) || Number(cs.fontWeight);
|
|
45
|
+
const to = Number(cs.getPropertyValue("--animate-weight-to"));
|
|
46
|
+
if (!from || !to || from === to)
|
|
47
|
+
return null;
|
|
48
|
+
const by = cs.getPropertyValue("--animate-weight-by").trim() === "character" ? "character" : "line";
|
|
49
|
+
return { from, to, by };
|
|
50
|
+
}
|
|
51
|
+
/** Elements under root that carry a weight range and hold text directly. */
|
|
52
|
+
export function findAll(root = document) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const el of root.querySelectorAll("*")) {
|
|
55
|
+
if (el.classList.contains(LINE))
|
|
56
|
+
continue;
|
|
57
|
+
const hasText = [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && /\S/.test(n.textContent ?? ""));
|
|
58
|
+
if (hasText && range(el))
|
|
59
|
+
out.push(el);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Words of the live element grouped by rendered line. */
|
|
64
|
+
export function readLines(el) {
|
|
65
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
66
|
+
const lines = [];
|
|
67
|
+
const rangeOf = document.createRange();
|
|
68
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
69
|
+
const text = node.textContent ?? "";
|
|
70
|
+
const re = /\S+/g;
|
|
71
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
72
|
+
rangeOf.setStart(node, m.index);
|
|
73
|
+
rangeOf.setEnd(node, m.index + m[0].length);
|
|
74
|
+
const rect = rangeOf.getBoundingClientRect();
|
|
75
|
+
const line = lines.find((l) => Math.abs(l.top - rect.top) < 1);
|
|
76
|
+
if (line)
|
|
77
|
+
line.words.push(m[0]);
|
|
78
|
+
else
|
|
79
|
+
lines.push({ top: rect.top, words: [m[0]] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines.sort((a, b) => a.top - b.top).map((l) => l.words.join(" "));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Put the original content back, so lines can be read afresh. Content that
|
|
86
|
+
* is not split any more was replaced from outside: that is the new source.
|
|
87
|
+
*/
|
|
88
|
+
function restore(el) {
|
|
89
|
+
const source = sources.get(el);
|
|
90
|
+
if (source && el.querySelector(`.${LINE}`))
|
|
91
|
+
el.replaceChildren(...source.map((n) => n.cloneNode(true)));
|
|
92
|
+
else
|
|
93
|
+
sources.set(el, [...el.childNodes].map((n) => n.cloneNode(true)));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One value per stop, as --atw-<name>-<stop>.
|
|
97
|
+
*
|
|
98
|
+
* Three decimals. More makes Firefox shimmer, fewer leaves 0.04px gaps.
|
|
99
|
+
*/
|
|
100
|
+
function values(span, name, list) {
|
|
101
|
+
list.forEach((v, k) => span.style.setProperty(`--atw-${name}-${k}`, `${v.toFixed(3)}px`));
|
|
102
|
+
}
|
|
103
|
+
/** Switch a span on: the weights it moves between, and its place in the wave. */
|
|
104
|
+
function unit(span, from, to, delayCount) {
|
|
105
|
+
span.style.setProperty("--atw-from", String(from));
|
|
106
|
+
span.style.setProperty("--atw-to", String(to));
|
|
107
|
+
span.style.setProperty("--atw-delay-count", String(delayCount));
|
|
108
|
+
span.classList.add(UNIT);
|
|
109
|
+
}
|
|
110
|
+
/** Graphemes of a text, so an accent or an emoji stays whole. */
|
|
111
|
+
function graphemes(text) {
|
|
112
|
+
if (typeof Intl.Segmenter !== "function")
|
|
113
|
+
return null;
|
|
114
|
+
const parts = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text);
|
|
115
|
+
return [...parts].map((p) => p.segment);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* How far each part of a text node sits from the one before it, kerning and
|
|
119
|
+
* all. Read as growing prefixes: the widths then add up to the line's own
|
|
120
|
+
* width by construction, and right-to-left text works, where subtracting
|
|
121
|
+
* left edges would give nonsense.
|
|
122
|
+
*/
|
|
123
|
+
function advances(node, parts) {
|
|
124
|
+
const span = document.createRange();
|
|
125
|
+
span.setStart(node, 0);
|
|
126
|
+
let end = 0;
|
|
127
|
+
let before = 0;
|
|
128
|
+
return parts.map((part) => {
|
|
129
|
+
end += part.length;
|
|
130
|
+
span.setEnd(node, end);
|
|
131
|
+
const upToHere = span.getBoundingClientRect().width;
|
|
132
|
+
const advance = upToHere - before;
|
|
133
|
+
before = upToHere;
|
|
134
|
+
return advance;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* How many places a span takes its letter-spacing, counted by probing rather
|
|
139
|
+
* than by counting letters. A browser puts one after every character the font
|
|
140
|
+
* draws, and a ligature is one character however many letters went into it, so
|
|
141
|
+
* the text alone does not say. width is the span's width at rest spacing.
|
|
142
|
+
*/
|
|
143
|
+
function slots(span, rest, width) {
|
|
144
|
+
const probe = 10;
|
|
145
|
+
span.style.letterSpacing = `${rest + probe}px`;
|
|
146
|
+
const wide = span.getBoundingClientRect().width;
|
|
147
|
+
span.style.letterSpacing = `${rest}px`;
|
|
148
|
+
return Math.max(Math.round((wide - width) / probe), 1);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* How much room every unit asks for at every stop, and what the line would
|
|
152
|
+
* measure at each stop if nothing held it.
|
|
153
|
+
*
|
|
154
|
+
* A unit asks for its own kerned advance at that stop, less a reserve. The
|
|
155
|
+
* reserve is one number for the whole line, so a unit's request depends on
|
|
156
|
+
* nothing but itself and its own weight — which is what lets the characters
|
|
157
|
+
* run out of step without upsetting the line.
|
|
158
|
+
*
|
|
159
|
+
* Asking for too little is the point. Whatever a line has left over, flexbox
|
|
160
|
+
* hands out in equal parts, so each unit ends up with its own advance less an
|
|
161
|
+
* even share of what the line is over by right now. At rest that share is
|
|
162
|
+
* nothing, at the end it is what line mode's letter-spacing takes off, and in
|
|
163
|
+
* between it is whatever it has to be for the line to stay the same width.
|
|
164
|
+
*
|
|
165
|
+
* The reserve itself cancels out of that, so its size does not matter as long
|
|
166
|
+
* as there is always something left to hand out. It is taken from the widest
|
|
167
|
+
* every unit ever gets, one by one, because they do not all get widest at the
|
|
168
|
+
* same weight: a word space is at its widest at the lightest.
|
|
169
|
+
*/
|
|
170
|
+
function fit(advances, target) {
|
|
171
|
+
// At least one unit: splitChars returns early on a line that has none.
|
|
172
|
+
const count = advances.length;
|
|
173
|
+
const widths = (advances[0] ?? []).map((_, k) => advances.reduce((sum, a) => sum + (a[k] ?? 0), 0));
|
|
174
|
+
const widest = advances.reduce((sum, own) => sum + Math.max(...own), 0);
|
|
175
|
+
const reserve = Math.max(widest - target, 0) / count;
|
|
176
|
+
return { boxes: advances.map((own) => own.map((advance) => advance - reserve)), widths };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Split one line into character units and write their stops, or null when the
|
|
180
|
+
* text cannot be split safely and the line must stay whole.
|
|
181
|
+
*
|
|
182
|
+
* Kerning is measured before the split and handed back after it, as the box
|
|
183
|
+
* each character is given: the box is the advance the character had while the
|
|
184
|
+
* line was still one piece, at that weight, less its share of the line's
|
|
185
|
+
* growth. See fit().
|
|
186
|
+
*/
|
|
187
|
+
function splitChars(line, text, weights, from, to, base, target) {
|
|
188
|
+
const parts = graphemes(text);
|
|
189
|
+
const node = line.firstChild;
|
|
190
|
+
if (!parts || !(node instanceof Text))
|
|
191
|
+
return null;
|
|
192
|
+
// Every grapheme's kerned advance at every stop, while the line is still
|
|
193
|
+
// one piece. One layout per stop, and the prefix sums are exact by
|
|
194
|
+
// construction.
|
|
195
|
+
//
|
|
196
|
+
// Ligatures off, because each character will be drawn in a box of its own
|
|
197
|
+
// and cannot ligate there. Measured with them on, an fi would give two boxes
|
|
198
|
+
// of half a ligature each, to hold a whole f and a whole i.
|
|
199
|
+
line.style.fontFeatureSettings = '"liga" 0, "clig" 0';
|
|
200
|
+
const perStop = weights.map((weight) => {
|
|
201
|
+
line.style.fontWeight = String(weight);
|
|
202
|
+
return advances(node, parts);
|
|
203
|
+
});
|
|
204
|
+
line.style.removeProperty("font-weight");
|
|
205
|
+
line.style.removeProperty("font-feature-settings");
|
|
206
|
+
// A grapheme that takes no advance of its own belongs to the one before it
|
|
207
|
+
// and shares its box. It still carries the element's own spacing, so what
|
|
208
|
+
// counts as no advance is measured against that. The grouping is decided at
|
|
209
|
+
// the from state and holds for every stop.
|
|
210
|
+
const units = [];
|
|
211
|
+
(perStop[0] ?? []).forEach((advance, i) => {
|
|
212
|
+
const open = units.at(-1);
|
|
213
|
+
if (open && advance - base < 0.01) {
|
|
214
|
+
open.text += parts[i] ?? "";
|
|
215
|
+
open.parts += 1;
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
units.push({ text: parts[i] ?? "", start: i, parts: 1 });
|
|
219
|
+
});
|
|
220
|
+
if (!units.length)
|
|
221
|
+
return null;
|
|
222
|
+
// A unit's advance is its graphemes', added up.
|
|
223
|
+
const kerned = units.map((unit) => perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum, a) => sum + a, 0)));
|
|
224
|
+
const { boxes, widths } = fit(kerned, target);
|
|
225
|
+
let count = -1;
|
|
226
|
+
const made = units.map((u, i) => {
|
|
227
|
+
const span = document.createElement("span");
|
|
228
|
+
span.className = CHAR;
|
|
229
|
+
span.textContent = u.text;
|
|
230
|
+
// A space keeps the count of the letter before it, so the wave holds its
|
|
231
|
+
// beat across a word gap instead of stalling on something invisible.
|
|
232
|
+
const delayCount = /\S/.test(u.text) ? ++count : Math.max(count, 0);
|
|
233
|
+
values(span, "w", boxes[i] ?? []);
|
|
234
|
+
unit(span, from, to, delayCount);
|
|
235
|
+
return { span, out: { text: u.text, target: kerned[i]?.[0] ?? 0, boxes: boxes[i] ?? [], delayCount } };
|
|
236
|
+
});
|
|
237
|
+
// The width the line is held at, and the only thing that decides it.
|
|
238
|
+
line.style.setProperty("--atw-width", `${target.toFixed(3)}px`);
|
|
239
|
+
line.replaceChildren(...made.map((m) => m.span));
|
|
240
|
+
return { units: made.map((m) => m.out), widths };
|
|
241
|
+
}
|
|
242
|
+
/** Split one element into its lines, then its units, and write their values. */
|
|
243
|
+
export function calibrate(el) {
|
|
244
|
+
const r = range(el);
|
|
245
|
+
if (!r)
|
|
246
|
+
return null;
|
|
247
|
+
const { from, to } = r;
|
|
248
|
+
const weights = Array.from({ length: STOPS }, (_, k) => from + ((to - from) * k) / (STOPS - 1));
|
|
249
|
+
// The element's own spacing is the from state; every stop adds to it.
|
|
250
|
+
const base = parseFloat(getComputedStyle(el).letterSpacing) || 0;
|
|
251
|
+
// Lines break at the from weight, so the element must sit there.
|
|
252
|
+
if (Number(getComputedStyle(el).fontWeight) !== from)
|
|
253
|
+
el.style.fontWeight = String(from);
|
|
254
|
+
restore(el);
|
|
255
|
+
const texts = readLines(el);
|
|
256
|
+
const spans = texts.map((text) => {
|
|
257
|
+
const span = document.createElement("span");
|
|
258
|
+
span.className = LINE;
|
|
259
|
+
span.textContent = text;
|
|
260
|
+
return span;
|
|
261
|
+
});
|
|
262
|
+
el.replaceChildren(...spans.flatMap((s, i) => (i ? [document.createElement("br"), s] : [s])));
|
|
263
|
+
// Falls back to whole lines if a line cannot be split into characters.
|
|
264
|
+
let by = r.by;
|
|
265
|
+
// Measure in place, before the next paint, with the tween switched off.
|
|
266
|
+
const lines = spans.map((span, i) => {
|
|
267
|
+
const text = texts[i] ?? "";
|
|
268
|
+
span.style.transition = "none";
|
|
269
|
+
span.style.letterSpacing = `${base}px`;
|
|
270
|
+
const widthAt = (w) => {
|
|
271
|
+
span.style.fontWeight = String(w);
|
|
272
|
+
return span.getBoundingClientRect().width;
|
|
273
|
+
};
|
|
274
|
+
const target = widthAt(from);
|
|
275
|
+
span.style.removeProperty("font-weight");
|
|
276
|
+
const split = by === "character" ? splitChars(span, text, weights, from, to, base, target) : null;
|
|
277
|
+
if (by === "character" && !split)
|
|
278
|
+
by = "line";
|
|
279
|
+
const done = () => {
|
|
280
|
+
span.style.removeProperty("letter-spacing");
|
|
281
|
+
span.style.removeProperty("transition");
|
|
282
|
+
};
|
|
283
|
+
if (split) {
|
|
284
|
+
done();
|
|
285
|
+
// The line only holds the break now; its characters carry the tween, and
|
|
286
|
+
// their boxes carry the width.
|
|
287
|
+
return { text, target, widths: split.widths, spacing: [], delayCount: 0, units: split.units };
|
|
288
|
+
}
|
|
289
|
+
// The slot count is measured at every stop, not counted from the text: a
|
|
290
|
+
// ligature takes the spacing once, and whether the font makes one can
|
|
291
|
+
// depend on the weight.
|
|
292
|
+
const widths = [];
|
|
293
|
+
const counts = [];
|
|
294
|
+
for (const w of weights) {
|
|
295
|
+
const width = widthAt(w);
|
|
296
|
+
widths.push(width);
|
|
297
|
+
counts.push(slots(span, base, width));
|
|
298
|
+
}
|
|
299
|
+
const spacing = widths.map((w, k) => base + (target - w) / (counts[k] ?? 1));
|
|
300
|
+
values(span, "ls", spacing);
|
|
301
|
+
// One line, one delay: the block arrives line by line.
|
|
302
|
+
unit(span, from, to, i);
|
|
303
|
+
span.style.removeProperty("font-weight");
|
|
304
|
+
done();
|
|
305
|
+
return { text, target, widths, spacing, delayCount: i, units: [] };
|
|
306
|
+
});
|
|
307
|
+
return { from, to, by, weights, lines };
|
|
308
|
+
}
|
|
309
|
+
/** Calibrate every animated element under root, now and after each resize. */
|
|
310
|
+
export function calibrateAll(root = document) {
|
|
311
|
+
const els = findAll(root);
|
|
312
|
+
els.forEach((el) => calibrate(el));
|
|
313
|
+
let width = innerWidth;
|
|
314
|
+
let pending = 0;
|
|
315
|
+
// A line is laid out once per stop, and character mode reads every character
|
|
316
|
+
// out of each of those, so a dragged window edge must not recalibrate on
|
|
317
|
+
// every event it fires.
|
|
318
|
+
addEventListener("resize", () => {
|
|
319
|
+
if (innerWidth === width)
|
|
320
|
+
return;
|
|
321
|
+
width = innerWidth;
|
|
322
|
+
clearTimeout(pending);
|
|
323
|
+
pending = setTimeout(() => els.forEach((el) => calibrate(el)), 150);
|
|
324
|
+
});
|
|
325
|
+
return els;
|
|
326
|
+
}
|
|
327
|
+
/** Wait for the fonts, then calibrate the page. */
|
|
328
|
+
export function init(root = document) {
|
|
329
|
+
return document.fonts.ready.then(() => calibrateAll(root));
|
|
330
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "animate-text-weight",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Animate short text between two weights of a variable font without moving a single line.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Tim Schoch",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/timschoch/animateTextWeight.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./style.css": "./src/animateTextWeight.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"prepare": "husky && tsc",
|
|
26
|
+
"demo": "tsc && npx -y serve -l 3000 ."
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"husky": "^9.1.7",
|
|
30
|
+
"lint-staged": "^17.5.0",
|
|
31
|
+
"prettier": "^3.9.6",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* animateTextWeight: tween a variable font between two weights and keep
|
|
3
|
+
* every line at its width.
|
|
4
|
+
*
|
|
5
|
+
* You write three things, all in CSS, on the element:
|
|
6
|
+
* --animate-weight-from: 200; the weight it animates from
|
|
7
|
+
* --animate-weight-to: 700; the weight it animates to
|
|
8
|
+
* --animate-weight: true; on any state or ancestor: switch it on
|
|
9
|
+
*
|
|
10
|
+
* And two more if you want the text to arrive in a wave:
|
|
11
|
+
* --animate-weight-by: character; one unit per character, not per line
|
|
12
|
+
* --animate-weight-delay: 40ms; one unit waits this long after the one before it
|
|
13
|
+
*
|
|
14
|
+
* The script (index.ts) splits the element into one span per rendered
|
|
15
|
+
* line, class .animate-text-weight-line, and in character mode splits each
|
|
16
|
+
* of those again into one span per character. Whichever span animates gets
|
|
17
|
+
* class .animate-text-weight-unit and carries the two weights, its delay
|
|
18
|
+
* count, and eleven values, one per stop between the two weights. A unit
|
|
19
|
+
* tweens --animate-weight-progress from 0 to 1, and its weight follows.
|
|
20
|
+
*
|
|
21
|
+
* How the width is held differs by mode.
|
|
22
|
+
*
|
|
23
|
+
* A line holds its own, with eleven letter-spacing values, --atw-ls-0 to
|
|
24
|
+
* --atw-ls-10, that take back what the line gains as it gets bolder.
|
|
25
|
+
*
|
|
26
|
+
* A character asks for room instead, eleven widths, --atw-w-0 to --atw-w-10,
|
|
27
|
+
* used as its flex basis. Its line is a flex line of a fixed width,
|
|
28
|
+
* --atw-width, and every character asks for slightly less than it needs, so
|
|
29
|
+
* there is always room left over for flexbox to hand out in equal parts. The
|
|
30
|
+
* line's width is a number, so nothing the characters do can move it, and the
|
|
31
|
+
* equal parts are what a letter-spacing on a whole line does.
|
|
32
|
+
*/
|
|
33
|
+
@property --animate-weight-from {
|
|
34
|
+
syntax: "<number>";
|
|
35
|
+
inherits: false;
|
|
36
|
+
initial-value: 0;
|
|
37
|
+
}
|
|
38
|
+
@property --animate-weight-to {
|
|
39
|
+
syntax: "<number>";
|
|
40
|
+
inherits: false;
|
|
41
|
+
initial-value: 0;
|
|
42
|
+
}
|
|
43
|
+
@property --animate-weight {
|
|
44
|
+
syntax: "true | false";
|
|
45
|
+
inherits: true;
|
|
46
|
+
initial-value: false;
|
|
47
|
+
}
|
|
48
|
+
@property --animate-weight-progress {
|
|
49
|
+
syntax: "<number>";
|
|
50
|
+
inherits: false;
|
|
51
|
+
initial-value: 0;
|
|
52
|
+
}
|
|
53
|
+
/* These two are set on the element but read on a unit inside it. */
|
|
54
|
+
@property --animate-weight-by {
|
|
55
|
+
syntax: "line | character";
|
|
56
|
+
inherits: true;
|
|
57
|
+
initial-value: line;
|
|
58
|
+
}
|
|
59
|
+
@property --animate-weight-delay {
|
|
60
|
+
syntax: "<time>";
|
|
61
|
+
inherits: true;
|
|
62
|
+
initial-value: 0s;
|
|
63
|
+
}
|
|
64
|
+
/* How many delays this unit waits: the count of the one before it, plus one. */
|
|
65
|
+
@property --atw-delay-count {
|
|
66
|
+
syntax: "<number>";
|
|
67
|
+
inherits: false;
|
|
68
|
+
initial-value: 0;
|
|
69
|
+
}
|
|
70
|
+
/* The width a line is held at. */
|
|
71
|
+
@property --atw-width {
|
|
72
|
+
syntax: "<length>";
|
|
73
|
+
inherits: false;
|
|
74
|
+
initial-value: 0px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
:root {
|
|
78
|
+
--animate-weight-duration: 300ms;
|
|
79
|
+
--animate-weight-easing: ease;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.animate-text-weight-line {
|
|
83
|
+
/* A line can get wider or narrower, never reflow. */
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
/* Fractional glyph advances, so Firefox does not snap letters mid-tween. */
|
|
86
|
+
text-rendering: geometricPrecision;
|
|
87
|
+
/*
|
|
88
|
+
* A browser drops common ligatures the moment a letter-spacing is anything
|
|
89
|
+
* but zero. The spacing here is zero at the from weight and not zero after
|
|
90
|
+
* it, so an fi would come apart on the first stop and the line would change
|
|
91
|
+
* shape as it moves — and lose the width the lock is holding. Asking for the
|
|
92
|
+
* features by name is what holds them: font-variant-ligatures does not
|
|
93
|
+
* (Chrome 143). Inherited, so a merged unit keeps its ligature too.
|
|
94
|
+
*/
|
|
95
|
+
font-feature-settings:
|
|
96
|
+
"liga" 1,
|
|
97
|
+
"clig" 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* A line whose characters carry the tween is a flex line of a fixed width.
|
|
102
|
+
*
|
|
103
|
+
* That is what holds it. Every character asks for a little less room than it
|
|
104
|
+
* needs (see the basis below), so the line always has some room left over, and
|
|
105
|
+
* flexbox hands that room out in equal parts. Equal parts is what one
|
|
106
|
+
* letter-spacing over a line does, so the letters fit the way line mode fits
|
|
107
|
+
* them — and because the line's width is a number, not a sum, nothing the
|
|
108
|
+
* characters do inside can move it.
|
|
109
|
+
*/
|
|
110
|
+
.animate-text-weight-line:has(> .animate-text-weight-char) {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: baseline;
|
|
113
|
+
width: var(--atw-width);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* One character, in a box of its own. The box stops neighbouring glyphs kerning
|
|
118
|
+
* into each other, so what was measured is what is drawn; the kerning they give
|
|
119
|
+
* up is already in the widths below.
|
|
120
|
+
*/
|
|
121
|
+
.animate-text-weight-char {
|
|
122
|
+
display: inline-block;
|
|
123
|
+
/* A character can be a lone space, and a lone space must not collapse. */
|
|
124
|
+
white-space: pre;
|
|
125
|
+
/* Take an equal part of whatever room the line has left. */
|
|
126
|
+
flex: 1 1 auto;
|
|
127
|
+
min-width: 0;
|
|
128
|
+
/* The element's own spacing is inside the box widths already. */
|
|
129
|
+
letter-spacing: 0;
|
|
130
|
+
/*
|
|
131
|
+
* No ligatures. A character has its own box, so two of them can never join
|
|
132
|
+
* into one glyph anyway; saying so here is what makes the measurement agree,
|
|
133
|
+
* because the line is still one piece while it is measured and would ligate.
|
|
134
|
+
* A box sized for half of an fi, holding a whole f, would crowd its
|
|
135
|
+
* neighbour. This is the one thing character mode gives up.
|
|
136
|
+
*/
|
|
137
|
+
font-feature-settings:
|
|
138
|
+
"liga" 0,
|
|
139
|
+
"clig" 0;
|
|
140
|
+
/*
|
|
141
|
+
* How much room the character asks for: its own kerned advance at its own
|
|
142
|
+
* weight, less a reserve. It is always a little short, which is what leaves
|
|
143
|
+
* the line room to hand out. See fit() in index.ts.
|
|
144
|
+
*
|
|
145
|
+
* Piecewise-linear over the eleven stops. Each clamp() is the progress inside
|
|
146
|
+
* one tenth, 0 to 1; the sum walks the stops up to the current progress and
|
|
147
|
+
* adds each one's change of width.
|
|
148
|
+
*/
|
|
149
|
+
flex-basis: calc(
|
|
150
|
+
var(--atw-w-0) + (var(--atw-w-1) - var(--atw-w-0)) *
|
|
151
|
+
clamp(0, var(--animate-weight-progress) * 10 - 0, 1) +
|
|
152
|
+
(var(--atw-w-2) - var(--atw-w-1)) * clamp(0, var(--animate-weight-progress) * 10 - 1, 1) +
|
|
153
|
+
(var(--atw-w-3) - var(--atw-w-2)) * clamp(0, var(--animate-weight-progress) * 10 - 2, 1) +
|
|
154
|
+
(var(--atw-w-4) - var(--atw-w-3)) * clamp(0, var(--animate-weight-progress) * 10 - 3, 1) +
|
|
155
|
+
(var(--atw-w-5) - var(--atw-w-4)) * clamp(0, var(--animate-weight-progress) * 10 - 4, 1) +
|
|
156
|
+
(var(--atw-w-6) - var(--atw-w-5)) * clamp(0, var(--animate-weight-progress) * 10 - 5, 1) +
|
|
157
|
+
(var(--atw-w-7) - var(--atw-w-6)) * clamp(0, var(--animate-weight-progress) * 10 - 6, 1) +
|
|
158
|
+
(var(--atw-w-8) - var(--atw-w-7)) * clamp(0, var(--animate-weight-progress) * 10 - 7, 1) +
|
|
159
|
+
(var(--atw-w-9) - var(--atw-w-8)) * clamp(0, var(--animate-weight-progress) * 10 - 8, 1) +
|
|
160
|
+
(var(--atw-w-10) - var(--atw-w-9)) * clamp(0, var(--animate-weight-progress) * 10 - 9, 1)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* Whatever animates: the line in line mode, one character in character mode. */
|
|
165
|
+
.animate-text-weight-unit {
|
|
166
|
+
/*
|
|
167
|
+
* Clamped, because an easing may overshoot: the spacing below stops at the
|
|
168
|
+
* last stop, so an unclamped weight would keep going and break the width.
|
|
169
|
+
*/
|
|
170
|
+
font-weight: calc(
|
|
171
|
+
var(--atw-from) + (var(--atw-to) - var(--atw-from)) *
|
|
172
|
+
clamp(0, var(--animate-weight-progress), 1)
|
|
173
|
+
);
|
|
174
|
+
transition: --animate-weight-progress var(--animate-weight-duration) var(--animate-weight-easing);
|
|
175
|
+
/* After the shorthand, which would reset it. */
|
|
176
|
+
transition-delay: calc(var(--atw-delay-count) * var(--animate-weight-delay));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/*
|
|
180
|
+
* A line that animates as one piece holds its width the other way: one
|
|
181
|
+
* letter-spacing, taking back what the whole line gains. Piecewise-linear over
|
|
182
|
+
* the eleven stops, driven by the line's own progress — it is the only unit
|
|
183
|
+
* here, so there is nothing to keep in step with.
|
|
184
|
+
*/
|
|
185
|
+
.animate-text-weight-line.animate-text-weight-unit {
|
|
186
|
+
letter-spacing: calc(
|
|
187
|
+
var(--atw-ls-0) + (var(--atw-ls-1) - var(--atw-ls-0)) *
|
|
188
|
+
clamp(0, var(--animate-weight-progress) * 10 - 0, 1) +
|
|
189
|
+
(var(--atw-ls-2) - var(--atw-ls-1)) * clamp(0, var(--animate-weight-progress) * 10 - 1, 1) +
|
|
190
|
+
(var(--atw-ls-3) - var(--atw-ls-2)) * clamp(0, var(--animate-weight-progress) * 10 - 2, 1) +
|
|
191
|
+
(var(--atw-ls-4) - var(--atw-ls-3)) * clamp(0, var(--animate-weight-progress) * 10 - 3, 1) +
|
|
192
|
+
(var(--atw-ls-5) - var(--atw-ls-4)) * clamp(0, var(--animate-weight-progress) * 10 - 4, 1) +
|
|
193
|
+
(var(--atw-ls-6) - var(--atw-ls-5)) * clamp(0, var(--animate-weight-progress) * 10 - 5, 1) +
|
|
194
|
+
(var(--atw-ls-7) - var(--atw-ls-6)) * clamp(0, var(--animate-weight-progress) * 10 - 6, 1) +
|
|
195
|
+
(var(--atw-ls-8) - var(--atw-ls-7)) * clamp(0, var(--animate-weight-progress) * 10 - 7, 1) +
|
|
196
|
+
(var(--atw-ls-9) - var(--atw-ls-8)) * clamp(0, var(--animate-weight-progress) * 10 - 8, 1) +
|
|
197
|
+
(var(--atw-ls-10) - var(--atw-ls-9)) * clamp(0, var(--animate-weight-progress) * 10 - 9, 1)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* The switch. The element is the unit's style container. */
|
|
202
|
+
@container style(--animate-weight: true) {
|
|
203
|
+
.animate-text-weight-unit {
|
|
204
|
+
--animate-weight-progress: 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* A delay multiplies the time the text spends moving by the number of units,
|
|
210
|
+
* so this matters more here than in most places.
|
|
211
|
+
*/
|
|
212
|
+
@media (prefers-reduced-motion: reduce) {
|
|
213
|
+
/* On the unit, so an element that sets its own values loses to this. */
|
|
214
|
+
.animate-text-weight-unit {
|
|
215
|
+
--animate-weight-duration: 0s;
|
|
216
|
+
--animate-weight-delay: 0s;
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* animateTextWeight: measure each rendered line of an animated element and
|
|
3
|
+
* write the numbers that keep it at its width at every weight.
|
|
4
|
+
*
|
|
5
|
+
* A string's width at a weight depends on its letters, so no table per font
|
|
6
|
+
* can hold every text to the same width, and one spacing per element can
|
|
7
|
+
* hold one line only. So the element is split into its rendered lines, one
|
|
8
|
+
* span per line. A line that animates whole gets eleven --atw-ls-<stop>
|
|
9
|
+
* letter-spacings; a line that animates by character is split again and each
|
|
10
|
+
* character gets eleven --atw-w-<stop> box widths, which add up to the line's
|
|
11
|
+
* width at every stop. animateTextWeight.css reads them.
|
|
12
|
+
*
|
|
13
|
+
* The element says what it wants in CSS: --animate-weight-from and
|
|
14
|
+
* --animate-weight-to. The script finds every element that carries both.
|
|
15
|
+
*
|
|
16
|
+
* Line breaks are read from the live element with a Range. The spans are
|
|
17
|
+
* nowrap and joined by <br>, so the lines are frozen: they can get wider or
|
|
18
|
+
* narrower, never reflow. Runs again on resize, from the original content,
|
|
19
|
+
* which is kept aside.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Class of one rendered line, styled by animateTextWeight.css. */
|
|
23
|
+
export const LINE = "animate-text-weight-line";
|
|
24
|
+
/** Class of whatever animates: the line, or one character inside it. */
|
|
25
|
+
export const UNIT = "animate-text-weight-unit";
|
|
26
|
+
/** Class of one character, which gives it a box of its own. */
|
|
27
|
+
export const CHAR = "animate-text-weight-char";
|
|
28
|
+
/**
|
|
29
|
+
* The CSS map has STOPS values, --atw-ls-0 to --atw-ls-(STOPS - 1).
|
|
30
|
+
*
|
|
31
|
+
* An odd number, so the middle weight is one of them. A variable font is drawn
|
|
32
|
+
* at a few weights and interpolated between them, and the middle of the range
|
|
33
|
+
* is very often one of those drawings: measure it and the line holds all the
|
|
34
|
+
* way through, miss it and the line dips by about two pixels halfway.
|
|
35
|
+
*/
|
|
36
|
+
const STOPS = 11;
|
|
37
|
+
|
|
38
|
+
/** What one unit is: a whole line, or a single character. */
|
|
39
|
+
export type Mode = "line" | "character";
|
|
40
|
+
|
|
41
|
+
/** Original child nodes of each split element, so it can be split again. */
|
|
42
|
+
const sources = new WeakMap<HTMLElement, Node[]>();
|
|
43
|
+
|
|
44
|
+
export interface Unit {
|
|
45
|
+
text: string;
|
|
46
|
+
/** Its kerned advance at the from weight, px. */
|
|
47
|
+
target: number;
|
|
48
|
+
/** The box it is given at each stop, px. These add up to the line's target. */
|
|
49
|
+
boxes: number[];
|
|
50
|
+
/** How many delays this unit waits before it starts. */
|
|
51
|
+
delayCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Line {
|
|
55
|
+
text: string;
|
|
56
|
+
/** The width the line is held at, px. */
|
|
57
|
+
target: number;
|
|
58
|
+
/** What the line would measure at each stop, left alone, px. */
|
|
59
|
+
widths: number[];
|
|
60
|
+
/** Line mode: its letter-spacing at each stop, px. Empty in character mode. */
|
|
61
|
+
spacing: number[];
|
|
62
|
+
/** How many delays it waits. Zero in character mode: its characters wait. */
|
|
63
|
+
delayCount: number;
|
|
64
|
+
/** One per character in character mode, empty in line mode. */
|
|
65
|
+
units: Unit[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Report {
|
|
69
|
+
from: number;
|
|
70
|
+
to: number;
|
|
71
|
+
/** What was actually split, which can be less than asked for. */
|
|
72
|
+
by: Mode;
|
|
73
|
+
/** The weight at each stop, from first to last. */
|
|
74
|
+
weights: number[];
|
|
75
|
+
lines: Line[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The weight range set in CSS, or null when the element does not animate.
|
|
80
|
+
* --animate-weight-from is optional: the element's font-weight is the from weight.
|
|
81
|
+
*/
|
|
82
|
+
export function range(el: Element): { from: number; to: number; by: Mode } | null {
|
|
83
|
+
const cs = getComputedStyle(el);
|
|
84
|
+
const from = Number(cs.getPropertyValue("--animate-weight-from")) || Number(cs.fontWeight);
|
|
85
|
+
const to = Number(cs.getPropertyValue("--animate-weight-to"));
|
|
86
|
+
if (!from || !to || from === to) return null;
|
|
87
|
+
const by = cs.getPropertyValue("--animate-weight-by").trim() === "character" ? "character" : "line";
|
|
88
|
+
return { from, to, by };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Elements under root that carry a weight range and hold text directly. */
|
|
92
|
+
export function findAll(root: ParentNode = document): HTMLElement[] {
|
|
93
|
+
const out: HTMLElement[] = [];
|
|
94
|
+
for (const el of root.querySelectorAll<HTMLElement>("*")) {
|
|
95
|
+
if (el.classList.contains(LINE)) continue;
|
|
96
|
+
const hasText = [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && /\S/.test(n.textContent ?? ""));
|
|
97
|
+
if (hasText && range(el)) out.push(el);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Words of the live element grouped by rendered line. */
|
|
103
|
+
export function readLines(el: HTMLElement): string[] {
|
|
104
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
105
|
+
const lines: { top: number; words: string[] }[] = [];
|
|
106
|
+
const rangeOf = document.createRange();
|
|
107
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
108
|
+
const text = node.textContent ?? "";
|
|
109
|
+
const re = /\S+/g;
|
|
110
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
111
|
+
rangeOf.setStart(node, m.index);
|
|
112
|
+
rangeOf.setEnd(node, m.index + m[0].length);
|
|
113
|
+
const rect = rangeOf.getBoundingClientRect();
|
|
114
|
+
const line = lines.find((l) => Math.abs(l.top - rect.top) < 1);
|
|
115
|
+
if (line) line.words.push(m[0]);
|
|
116
|
+
else lines.push({ top: rect.top, words: [m[0]] });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lines.sort((a, b) => a.top - b.top).map((l) => l.words.join(" "));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Put the original content back, so lines can be read afresh. Content that
|
|
124
|
+
* is not split any more was replaced from outside: that is the new source.
|
|
125
|
+
*/
|
|
126
|
+
function restore(el: HTMLElement): void {
|
|
127
|
+
const source = sources.get(el);
|
|
128
|
+
if (source && el.querySelector(`.${LINE}`)) el.replaceChildren(...source.map((n) => n.cloneNode(true)));
|
|
129
|
+
else sources.set(el, [...el.childNodes].map((n) => n.cloneNode(true)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One value per stop, as --atw-<name>-<stop>.
|
|
134
|
+
*
|
|
135
|
+
* Three decimals. More makes Firefox shimmer, fewer leaves 0.04px gaps.
|
|
136
|
+
*/
|
|
137
|
+
function values(span: HTMLElement, name: string, list: number[]): void {
|
|
138
|
+
list.forEach((v, k) => span.style.setProperty(`--atw-${name}-${k}`, `${v.toFixed(3)}px`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Switch a span on: the weights it moves between, and its place in the wave. */
|
|
142
|
+
function unit(span: HTMLElement, from: number, to: number, delayCount: number): void {
|
|
143
|
+
span.style.setProperty("--atw-from", String(from));
|
|
144
|
+
span.style.setProperty("--atw-to", String(to));
|
|
145
|
+
span.style.setProperty("--atw-delay-count", String(delayCount));
|
|
146
|
+
span.classList.add(UNIT);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Graphemes of a text, so an accent or an emoji stays whole. */
|
|
150
|
+
function graphemes(text: string): string[] | null {
|
|
151
|
+
if (typeof Intl.Segmenter !== "function") return null;
|
|
152
|
+
const parts = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text);
|
|
153
|
+
return [...parts].map((p) => p.segment);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How far each part of a text node sits from the one before it, kerning and
|
|
158
|
+
* all. Read as growing prefixes: the widths then add up to the line's own
|
|
159
|
+
* width by construction, and right-to-left text works, where subtracting
|
|
160
|
+
* left edges would give nonsense.
|
|
161
|
+
*/
|
|
162
|
+
function advances(node: Text, parts: string[]): number[] {
|
|
163
|
+
const span = document.createRange();
|
|
164
|
+
span.setStart(node, 0);
|
|
165
|
+
let end = 0;
|
|
166
|
+
let before = 0;
|
|
167
|
+
return parts.map((part) => {
|
|
168
|
+
end += part.length;
|
|
169
|
+
span.setEnd(node, end);
|
|
170
|
+
const upToHere = span.getBoundingClientRect().width;
|
|
171
|
+
const advance = upToHere - before;
|
|
172
|
+
before = upToHere;
|
|
173
|
+
return advance;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many places a span takes its letter-spacing, counted by probing rather
|
|
179
|
+
* than by counting letters. A browser puts one after every character the font
|
|
180
|
+
* draws, and a ligature is one character however many letters went into it, so
|
|
181
|
+
* the text alone does not say. width is the span's width at rest spacing.
|
|
182
|
+
*/
|
|
183
|
+
function slots(span: HTMLElement, rest: number, width: number): number {
|
|
184
|
+
const probe = 10;
|
|
185
|
+
span.style.letterSpacing = `${rest + probe}px`;
|
|
186
|
+
const wide = span.getBoundingClientRect().width;
|
|
187
|
+
span.style.letterSpacing = `${rest}px`;
|
|
188
|
+
return Math.max(Math.round((wide - width) / probe), 1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How much room every unit asks for at every stop, and what the line would
|
|
193
|
+
* measure at each stop if nothing held it.
|
|
194
|
+
*
|
|
195
|
+
* A unit asks for its own kerned advance at that stop, less a reserve. The
|
|
196
|
+
* reserve is one number for the whole line, so a unit's request depends on
|
|
197
|
+
* nothing but itself and its own weight — which is what lets the characters
|
|
198
|
+
* run out of step without upsetting the line.
|
|
199
|
+
*
|
|
200
|
+
* Asking for too little is the point. Whatever a line has left over, flexbox
|
|
201
|
+
* hands out in equal parts, so each unit ends up with its own advance less an
|
|
202
|
+
* even share of what the line is over by right now. At rest that share is
|
|
203
|
+
* nothing, at the end it is what line mode's letter-spacing takes off, and in
|
|
204
|
+
* between it is whatever it has to be for the line to stay the same width.
|
|
205
|
+
*
|
|
206
|
+
* The reserve itself cancels out of that, so its size does not matter as long
|
|
207
|
+
* as there is always something left to hand out. It is taken from the widest
|
|
208
|
+
* every unit ever gets, one by one, because they do not all get widest at the
|
|
209
|
+
* same weight: a word space is at its widest at the lightest.
|
|
210
|
+
*/
|
|
211
|
+
function fit(advances: number[][], target: number): { boxes: number[][]; widths: number[] } {
|
|
212
|
+
// At least one unit: splitChars returns early on a line that has none.
|
|
213
|
+
const count = advances.length;
|
|
214
|
+
const widths = (advances[0] ?? []).map((_, k) => advances.reduce((sum, a) => sum + (a[k] ?? 0), 0));
|
|
215
|
+
const widest = advances.reduce((sum, own) => sum + Math.max(...own), 0);
|
|
216
|
+
const reserve = Math.max(widest - target, 0) / count;
|
|
217
|
+
return { boxes: advances.map((own) => own.map((advance) => advance - reserve)), widths };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Split one line into character units and write their stops, or null when the
|
|
222
|
+
* text cannot be split safely and the line must stay whole.
|
|
223
|
+
*
|
|
224
|
+
* Kerning is measured before the split and handed back after it, as the box
|
|
225
|
+
* each character is given: the box is the advance the character had while the
|
|
226
|
+
* line was still one piece, at that weight, less its share of the line's
|
|
227
|
+
* growth. See fit().
|
|
228
|
+
*/
|
|
229
|
+
function splitChars(
|
|
230
|
+
line: HTMLElement,
|
|
231
|
+
text: string,
|
|
232
|
+
weights: number[],
|
|
233
|
+
from: number,
|
|
234
|
+
to: number,
|
|
235
|
+
base: number,
|
|
236
|
+
target: number,
|
|
237
|
+
): { units: Unit[]; widths: number[] } | null {
|
|
238
|
+
const parts = graphemes(text);
|
|
239
|
+
const node = line.firstChild;
|
|
240
|
+
if (!parts || !(node instanceof Text)) return null;
|
|
241
|
+
|
|
242
|
+
// Every grapheme's kerned advance at every stop, while the line is still
|
|
243
|
+
// one piece. One layout per stop, and the prefix sums are exact by
|
|
244
|
+
// construction.
|
|
245
|
+
//
|
|
246
|
+
// Ligatures off, because each character will be drawn in a box of its own
|
|
247
|
+
// and cannot ligate there. Measured with them on, an fi would give two boxes
|
|
248
|
+
// of half a ligature each, to hold a whole f and a whole i.
|
|
249
|
+
line.style.fontFeatureSettings = '"liga" 0, "clig" 0';
|
|
250
|
+
const perStop = weights.map((weight) => {
|
|
251
|
+
line.style.fontWeight = String(weight);
|
|
252
|
+
return advances(node, parts);
|
|
253
|
+
});
|
|
254
|
+
line.style.removeProperty("font-weight");
|
|
255
|
+
line.style.removeProperty("font-feature-settings");
|
|
256
|
+
|
|
257
|
+
// A grapheme that takes no advance of its own belongs to the one before it
|
|
258
|
+
// and shares its box. It still carries the element's own spacing, so what
|
|
259
|
+
// counts as no advance is measured against that. The grouping is decided at
|
|
260
|
+
// the from state and holds for every stop.
|
|
261
|
+
const units: { text: string; start: number; parts: number }[] = [];
|
|
262
|
+
(perStop[0] ?? []).forEach((advance, i) => {
|
|
263
|
+
const open = units.at(-1);
|
|
264
|
+
if (open && advance - base < 0.01) {
|
|
265
|
+
open.text += parts[i] ?? "";
|
|
266
|
+
open.parts += 1;
|
|
267
|
+
} else units.push({ text: parts[i] ?? "", start: i, parts: 1 });
|
|
268
|
+
});
|
|
269
|
+
if (!units.length) return null;
|
|
270
|
+
|
|
271
|
+
// A unit's advance is its graphemes', added up.
|
|
272
|
+
const kerned = units.map((unit) =>
|
|
273
|
+
perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum, a) => sum + a, 0)),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const { boxes, widths } = fit(kerned, target);
|
|
277
|
+
|
|
278
|
+
let count = -1;
|
|
279
|
+
const made = units.map((u, i) => {
|
|
280
|
+
const span = document.createElement("span");
|
|
281
|
+
span.className = CHAR;
|
|
282
|
+
span.textContent = u.text;
|
|
283
|
+
// A space keeps the count of the letter before it, so the wave holds its
|
|
284
|
+
// beat across a word gap instead of stalling on something invisible.
|
|
285
|
+
const delayCount = /\S/.test(u.text) ? ++count : Math.max(count, 0);
|
|
286
|
+
values(span, "w", boxes[i] ?? []);
|
|
287
|
+
unit(span, from, to, delayCount);
|
|
288
|
+
return { span, out: { text: u.text, target: kerned[i]?.[0] ?? 0, boxes: boxes[i] ?? [], delayCount } };
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// The width the line is held at, and the only thing that decides it.
|
|
292
|
+
line.style.setProperty("--atw-width", `${target.toFixed(3)}px`);
|
|
293
|
+
line.replaceChildren(...made.map((m) => m.span));
|
|
294
|
+
return { units: made.map((m) => m.out), widths };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Split one element into its lines, then its units, and write their values. */
|
|
298
|
+
export function calibrate(el: HTMLElement): Report | null {
|
|
299
|
+
const r = range(el);
|
|
300
|
+
if (!r) return null;
|
|
301
|
+
const { from, to } = r;
|
|
302
|
+
const weights = Array.from({ length: STOPS }, (_, k) => from + ((to - from) * k) / (STOPS - 1));
|
|
303
|
+
// The element's own spacing is the from state; every stop adds to it.
|
|
304
|
+
const base = parseFloat(getComputedStyle(el).letterSpacing) || 0;
|
|
305
|
+
// Lines break at the from weight, so the element must sit there.
|
|
306
|
+
if (Number(getComputedStyle(el).fontWeight) !== from) el.style.fontWeight = String(from);
|
|
307
|
+
|
|
308
|
+
restore(el);
|
|
309
|
+
const texts = readLines(el);
|
|
310
|
+
const spans = texts.map((text) => {
|
|
311
|
+
const span = document.createElement("span");
|
|
312
|
+
span.className = LINE;
|
|
313
|
+
span.textContent = text;
|
|
314
|
+
return span;
|
|
315
|
+
});
|
|
316
|
+
el.replaceChildren(...spans.flatMap((s, i) => (i ? [document.createElement("br"), s] : [s])));
|
|
317
|
+
|
|
318
|
+
// Falls back to whole lines if a line cannot be split into characters.
|
|
319
|
+
let by: Mode = r.by;
|
|
320
|
+
// Measure in place, before the next paint, with the tween switched off.
|
|
321
|
+
const lines = spans.map((span, i): Line => {
|
|
322
|
+
const text = texts[i] ?? "";
|
|
323
|
+
span.style.transition = "none";
|
|
324
|
+
span.style.letterSpacing = `${base}px`;
|
|
325
|
+
const widthAt = (w: number) => {
|
|
326
|
+
span.style.fontWeight = String(w);
|
|
327
|
+
return span.getBoundingClientRect().width;
|
|
328
|
+
};
|
|
329
|
+
const target = widthAt(from);
|
|
330
|
+
span.style.removeProperty("font-weight");
|
|
331
|
+
|
|
332
|
+
const split = by === "character" ? splitChars(span, text, weights, from, to, base, target) : null;
|
|
333
|
+
if (by === "character" && !split) by = "line";
|
|
334
|
+
const done = () => {
|
|
335
|
+
span.style.removeProperty("letter-spacing");
|
|
336
|
+
span.style.removeProperty("transition");
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (split) {
|
|
340
|
+
done();
|
|
341
|
+
// The line only holds the break now; its characters carry the tween, and
|
|
342
|
+
// their boxes carry the width.
|
|
343
|
+
return { text, target, widths: split.widths, spacing: [], delayCount: 0, units: split.units };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The slot count is measured at every stop, not counted from the text: a
|
|
347
|
+
// ligature takes the spacing once, and whether the font makes one can
|
|
348
|
+
// depend on the weight.
|
|
349
|
+
const widths: number[] = [];
|
|
350
|
+
const counts: number[] = [];
|
|
351
|
+
for (const w of weights) {
|
|
352
|
+
const width = widthAt(w);
|
|
353
|
+
widths.push(width);
|
|
354
|
+
counts.push(slots(span, base, width));
|
|
355
|
+
}
|
|
356
|
+
const spacing = widths.map((w, k) => base + (target - w) / (counts[k] ?? 1));
|
|
357
|
+
values(span, "ls", spacing);
|
|
358
|
+
// One line, one delay: the block arrives line by line.
|
|
359
|
+
unit(span, from, to, i);
|
|
360
|
+
span.style.removeProperty("font-weight");
|
|
361
|
+
done();
|
|
362
|
+
return { text, target, widths, spacing, delayCount: i, units: [] };
|
|
363
|
+
});
|
|
364
|
+
return { from, to, by, weights, lines };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Calibrate every animated element under root, now and after each resize. */
|
|
368
|
+
export function calibrateAll(root: ParentNode = document): HTMLElement[] {
|
|
369
|
+
const els = findAll(root);
|
|
370
|
+
els.forEach((el) => calibrate(el));
|
|
371
|
+
let width = innerWidth;
|
|
372
|
+
let pending = 0;
|
|
373
|
+
// A line is laid out once per stop, and character mode reads every character
|
|
374
|
+
// out of each of those, so a dragged window edge must not recalibrate on
|
|
375
|
+
// every event it fires.
|
|
376
|
+
addEventListener("resize", () => {
|
|
377
|
+
if (innerWidth === width) return;
|
|
378
|
+
width = innerWidth;
|
|
379
|
+
clearTimeout(pending);
|
|
380
|
+
pending = setTimeout(() => els.forEach((el) => calibrate(el)), 150);
|
|
381
|
+
});
|
|
382
|
+
return els;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Wait for the fonts, then calibrate the page. */
|
|
386
|
+
export function init(root: ParentNode = document): Promise<HTMLElement[]> {
|
|
387
|
+
return document.fonts.ready.then(() => calibrateAll(root));
|
|
388
|
+
}
|