animate-text-weight 1.0.0 → 1.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 +216 -39
- package/dist/index.d.ts +7 -4
- package/dist/index.js +484 -72
- package/dist/wave-nav.d.ts +12 -0
- package/dist/wave-nav.js +96 -0
- package/dist/waves.d.ts +58 -0
- package/dist/waves.js +52 -0
- package/package.json +10 -2
- package/src/animateTextWeight.css +288 -10
- package/src/index.ts +506 -68
- package/src/wave-nav.ts +113 -0
- package/src/waves.ts +73 -0
package/src/index.ts
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
* which is kept aside.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import {
|
|
23
|
+
entered,
|
|
24
|
+
left,
|
|
25
|
+
settled,
|
|
26
|
+
target,
|
|
27
|
+
type End,
|
|
28
|
+
type Way,
|
|
29
|
+
type Waves,
|
|
30
|
+
} from "./waves.js";
|
|
31
|
+
|
|
22
32
|
/** Class of one rendered line, styled by animateTextWeight.css. */
|
|
23
33
|
export const LINE = "animate-text-weight-line";
|
|
24
34
|
/** Class of whatever animates: the line, or one character inside it. */
|
|
@@ -79,8 +89,8 @@ export interface Report {
|
|
|
79
89
|
* The weight range set in CSS, or null when the element does not animate.
|
|
80
90
|
* --animate-weight-from is optional: the element's font-weight is the from weight.
|
|
81
91
|
*/
|
|
82
|
-
export function range(
|
|
83
|
-
const cs = getComputedStyle(
|
|
92
|
+
export function range(element: Element): { from: number; to: number; by: Mode } | null {
|
|
93
|
+
const cs = getComputedStyle(element);
|
|
84
94
|
const from = Number(cs.getPropertyValue("--animate-weight-from")) || Number(cs.fontWeight);
|
|
85
95
|
const to = Number(cs.getPropertyValue("--animate-weight-to"));
|
|
86
96
|
if (!from || !to || from === to) return null;
|
|
@@ -91,42 +101,42 @@ export function range(el: Element): { from: number; to: number; by: Mode } | nul
|
|
|
91
101
|
/** Elements under root that carry a weight range and hold text directly. */
|
|
92
102
|
export function findAll(root: ParentNode = document): HTMLElement[] {
|
|
93
103
|
const out: HTMLElement[] = [];
|
|
94
|
-
for (const
|
|
95
|
-
if (
|
|
96
|
-
const hasText = [...
|
|
97
|
-
if (hasText && range(
|
|
104
|
+
for (const element of root.querySelectorAll<HTMLElement>("*")) {
|
|
105
|
+
if (element.classList.contains(LINE)) continue;
|
|
106
|
+
const hasText = [...element.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && /\S/.test(child.textContent ?? ""));
|
|
107
|
+
if (hasText && range(element)) out.push(element);
|
|
98
108
|
}
|
|
99
109
|
return out;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
/** Words of the live element grouped by rendered line. */
|
|
103
|
-
export function readLines(
|
|
104
|
-
const walker = document.createTreeWalker(
|
|
113
|
+
export function readLines(element: HTMLElement): string[] {
|
|
114
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
105
115
|
const lines: { top: number; words: string[] }[] = [];
|
|
106
116
|
const rangeOf = document.createRange();
|
|
107
117
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
108
118
|
const text = node.textContent ?? "";
|
|
109
119
|
const re = /\S+/g;
|
|
110
|
-
for (let
|
|
111
|
-
rangeOf.setStart(node,
|
|
112
|
-
rangeOf.setEnd(node,
|
|
120
|
+
for (let word = re.exec(text); word; word = re.exec(text)) {
|
|
121
|
+
rangeOf.setStart(node, word.index);
|
|
122
|
+
rangeOf.setEnd(node, word.index + word[0].length);
|
|
113
123
|
const rect = rangeOf.getBoundingClientRect();
|
|
114
|
-
const line = lines.find((
|
|
115
|
-
if (line) line.words.push(
|
|
116
|
-
else lines.push({ top: rect.top, words: [
|
|
124
|
+
const line = lines.find((candidate) => Math.abs(candidate.top - rect.top) < 1);
|
|
125
|
+
if (line) line.words.push(word[0]);
|
|
126
|
+
else lines.push({ top: rect.top, words: [word[0]] });
|
|
117
127
|
}
|
|
118
128
|
}
|
|
119
|
-
return lines.sort((
|
|
129
|
+
return lines.sort((above, below) => above.top - below.top).map((line) => line.words.join(" "));
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
/**
|
|
123
133
|
* Put the original content back, so lines can be read afresh. Content that
|
|
124
134
|
* is not split any more was replaced from outside: that is the new source.
|
|
125
135
|
*/
|
|
126
|
-
function restore(
|
|
127
|
-
const source = sources.get(
|
|
128
|
-
if (source &&
|
|
129
|
-
else sources.set(
|
|
136
|
+
function restore(element: HTMLElement): void {
|
|
137
|
+
const source = sources.get(element);
|
|
138
|
+
if (source && element.querySelector(`.${LINE}`)) element.replaceChildren(...source.map((child) => child.cloneNode(true)));
|
|
139
|
+
else sources.set(element, [...element.childNodes].map((child) => child.cloneNode(true)));
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
/**
|
|
@@ -135,7 +145,7 @@ function restore(el: HTMLElement): void {
|
|
|
135
145
|
* Three decimals. More makes Firefox shimmer, fewer leaves 0.04px gaps.
|
|
136
146
|
*/
|
|
137
147
|
function values(span: HTMLElement, name: string, list: number[]): void {
|
|
138
|
-
list.forEach((
|
|
148
|
+
list.forEach((value, stop) => span.style.setProperty(`--atw-${name}-${stop}`, `${value.toFixed(3)}px`));
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
/** Switch a span on: the weights it moves between, and its place in the wave. */
|
|
@@ -150,7 +160,7 @@ function unit(span: HTMLElement, from: number, to: number, delayCount: number):
|
|
|
150
160
|
function graphemes(text: string): string[] | null {
|
|
151
161
|
if (typeof Intl.Segmenter !== "function") return null;
|
|
152
162
|
const parts = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text);
|
|
153
|
-
return [...parts].map((
|
|
163
|
+
return [...parts].map((part) => part.segment);
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
/**
|
|
@@ -211,7 +221,7 @@ function slots(span: HTMLElement, rest: number, width: number): number {
|
|
|
211
221
|
function fit(advances: number[][], target: number): { boxes: number[][]; widths: number[] } {
|
|
212
222
|
// At least one unit: splitChars returns early on a line that has none.
|
|
213
223
|
const count = advances.length;
|
|
214
|
-
const widths = (advances[0] ?? []).map((_,
|
|
224
|
+
const widths = (advances[0] ?? []).map((_, stop) => advances.reduce((sum, own) => sum + (own[stop] ?? 0), 0));
|
|
215
225
|
const widest = advances.reduce((sum, own) => sum + Math.max(...own), 0);
|
|
216
226
|
const reserve = Math.max(widest - target, 0) / count;
|
|
217
227
|
return { boxes: advances.map((own) => own.map((advance) => advance - reserve)), widths };
|
|
@@ -259,71 +269,510 @@ function splitChars(
|
|
|
259
269
|
// counts as no advance is measured against that. The grouping is decided at
|
|
260
270
|
// the from state and holds for every stop.
|
|
261
271
|
const units: { text: string; start: number; parts: number }[] = [];
|
|
262
|
-
(perStop[0] ?? []).forEach((advance,
|
|
272
|
+
(perStop[0] ?? []).forEach((advance, index) => {
|
|
263
273
|
const open = units.at(-1);
|
|
264
274
|
if (open && advance - base < 0.01) {
|
|
265
|
-
open.text += parts[
|
|
275
|
+
open.text += parts[index] ?? "";
|
|
266
276
|
open.parts += 1;
|
|
267
|
-
} else units.push({ text: parts[
|
|
277
|
+
} else units.push({ text: parts[index] ?? "", start: index, parts: 1 });
|
|
268
278
|
});
|
|
269
279
|
if (!units.length) return null;
|
|
270
280
|
|
|
271
281
|
// A unit's advance is its graphemes', added up.
|
|
272
282
|
const kerned = units.map((unit) =>
|
|
273
|
-
perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum,
|
|
283
|
+
perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum, advance) => sum + advance, 0)),
|
|
274
284
|
);
|
|
275
285
|
|
|
276
286
|
const { boxes, widths } = fit(kerned, target);
|
|
277
287
|
|
|
278
288
|
let count = -1;
|
|
279
|
-
const made = units.map((
|
|
289
|
+
const made = units.map((group, index) => {
|
|
280
290
|
const span = document.createElement("span");
|
|
281
291
|
span.className = CHAR;
|
|
282
|
-
span.textContent =
|
|
292
|
+
span.textContent = group.text;
|
|
283
293
|
// A space keeps the count of the letter before it, so the wave holds its
|
|
284
294
|
// beat across a word gap instead of stalling on something invisible.
|
|
285
|
-
const delayCount = /\S/.test(
|
|
286
|
-
values(span, "w", boxes[
|
|
295
|
+
const delayCount = /\S/.test(group.text) ? ++count : Math.max(count, 0);
|
|
296
|
+
values(span, "w", boxes[index] ?? []);
|
|
287
297
|
unit(span, from, to, delayCount);
|
|
288
|
-
return { span, out: { text:
|
|
298
|
+
return { span, out: { text: group.text, target: kerned[index]?.[0] ?? 0, boxes: boxes[index] ?? [], delayCount } };
|
|
289
299
|
});
|
|
290
300
|
|
|
291
301
|
// The width the line is held at, and the only thing that decides it.
|
|
292
302
|
line.style.setProperty("--atw-width", `${target.toFixed(3)}px`);
|
|
293
|
-
line
|
|
294
|
-
|
|
303
|
+
// The far end of this line's wave, for a direction that counts from there.
|
|
304
|
+
line.style.setProperty("--atw-delay-total", String(Math.max(...made.map((piece) => piece.out.delayCount))));
|
|
305
|
+
line.replaceChildren(...made.map((piece) => piece.span));
|
|
306
|
+
return { units: made.map((piece) => piece.out), widths };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Which end of an element a wave starts at. */
|
|
310
|
+
type Side = "start" | "end";
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* What the element asked its direction to follow, if it asked for anything.
|
|
314
|
+
*
|
|
315
|
+
* A list, because an element can follow both. Each watcher writes the same
|
|
316
|
+
* one end, so whichever moved last has the say: scroll the page under a
|
|
317
|
+
* resting pointer and the page decides, cross the element and the pointer
|
|
318
|
+
* does.
|
|
319
|
+
*/
|
|
320
|
+
function follows(element: HTMLElement): Set<string> {
|
|
321
|
+
const asked = getComputedStyle(element).getPropertyValue("--animate-weight-direction").trim();
|
|
322
|
+
return new Set(asked ? asked.split(/\s+/) : []);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The other one of the two. */
|
|
326
|
+
const OTHER_END: Record<Side, Side> = { start: "end", end: "start" };
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* What a flip reads as for a given end, which is inverted while the pair of
|
|
330
|
+
* waves is running back down.
|
|
331
|
+
*
|
|
332
|
+
* A wave climbing 0 to 1 lets go of the unit with the shortest wait first; the
|
|
333
|
+
* same wave falling 1 to 0 lets go of the longest wait first, so it crosses
|
|
334
|
+
* the text the other way. The flip has to invert with it, or a visit that runs
|
|
335
|
+
* the pair home would put its band on backwards.
|
|
336
|
+
*/
|
|
337
|
+
const flipFor = (element: HTMLElement, side: Side): string =>
|
|
338
|
+
(side === "end") !== (target(waves(element).way) === "0") ? "1" : "0";
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Point a wave at one of its ends, for animateTextWeight.css to read.
|
|
342
|
+
*
|
|
343
|
+
* A flip rather than a word, because that is what the units count from, and
|
|
344
|
+
* they inherit it straight off this element. A word the stylesheet had to turn
|
|
345
|
+
* into a flip would go through a style container query, which settles one pass
|
|
346
|
+
* later than an inherited value does.
|
|
347
|
+
*/
|
|
348
|
+
function aim(element: HTMLElement, wave: "in" | "out", side: Side): void {
|
|
349
|
+
const name = `--atw-flip-${wave}`;
|
|
350
|
+
const flip = flipFor(element, side);
|
|
351
|
+
// The same end again would still throw the element's style away and have it
|
|
352
|
+
// worked out afresh, for nothing.
|
|
353
|
+
if (element.style.getPropertyValue(name) === flip) return;
|
|
354
|
+
element.style.setProperty(name, flip);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The end one of an element's waves points at. Nothing written is a flip of 0. */
|
|
358
|
+
function aimed(element: HTMLElement, wave: "in" | "out" = "in"): Side {
|
|
359
|
+
const flip = element.style.getPropertyValue(`--atw-flip-${wave}`) || "0";
|
|
360
|
+
return flip === flipFor(element, "end") ? "end" : "start";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* An element's waves that are still on their way somewhere, either the leading
|
|
365
|
+
* one or both.
|
|
366
|
+
*/
|
|
367
|
+
function running(element: HTMLElement, which: "in" | "both"): Animation[] {
|
|
368
|
+
const wanted = which === "in" ? "--atw-wave-in" : "--atw-wave";
|
|
369
|
+
return element
|
|
370
|
+
.getAnimations({ subtree: true })
|
|
371
|
+
.filter((run) =>
|
|
372
|
+
String((run as { transitionProperty?: string }).transitionProperty).startsWith(wanted),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether one of an element's waves is parked at an end on every line, which
|
|
378
|
+
* is the only place its own flip may be moved.
|
|
379
|
+
*
|
|
380
|
+
* Parked, every unit reads that wave as the same number, so counting it from
|
|
381
|
+
* the other end changes nothing. Anywhere in between the units disagree, and
|
|
382
|
+
* the flip re-sorts the line in a single frame.
|
|
383
|
+
*/
|
|
384
|
+
function parked(element: HTMLElement, which: "in" | "out"): boolean {
|
|
385
|
+
return [...element.querySelectorAll<HTMLElement>(`.${LINE}`)].every((line) => {
|
|
386
|
+
const wave = Number(getComputedStyle(line).getPropertyValue(`--atw-wave-${which}`));
|
|
387
|
+
// Not 0 and 1 exactly: a crossing can land a frame after the wave left an
|
|
388
|
+
// end, and that early the units still nearly agree.
|
|
389
|
+
return wave < 0.02 || wave > 0.98;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The end each element's leading wave is to point at, once it may be moved. */
|
|
394
|
+
const turning = new WeakMap<HTMLElement, Side>();
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Point an element's leading wave at one of its ends, as soon as it may be.
|
|
398
|
+
*
|
|
399
|
+
* Every unit works its own share out of the flip live, so moving the flip
|
|
400
|
+
* under a running wave re-sorts the whole line in a single frame: the letters
|
|
401
|
+
* the wave has reached and the ones it has not trade places on the spot.
|
|
402
|
+
* Parked at either end the units all agree, and only there is the flip free.
|
|
403
|
+
* So a turn that lands mid-wave waits for the wave to arrive somewhere.
|
|
404
|
+
*
|
|
405
|
+
* The leading wave only, and never the trailing one. This end is the one the
|
|
406
|
+
* rise counts from, so what it costs is decided by where the rise is and by
|
|
407
|
+
* nothing else. Waiting on a trailing wave as well would hold the end back for
|
|
408
|
+
* a whole further crossing, and it would land in the middle of the next one.
|
|
409
|
+
*/
|
|
410
|
+
function turn(element: HTMLElement, side: Side): void {
|
|
411
|
+
if (aimed(element) === side) return void turning.delete(element);
|
|
412
|
+
turning.set(element, side);
|
|
413
|
+
const later = (): void => {
|
|
414
|
+
// A turn since has asked for something else, and it does the waiting.
|
|
415
|
+
if (turning.get(element) !== side) return;
|
|
416
|
+
const waves = running(element, "in");
|
|
417
|
+
// Parked, or nothing left to arrive because a wave was dropped where it
|
|
418
|
+
// stood: either way the flip is as cheap now as it will ever be.
|
|
419
|
+
if (parked(element, "in") || !waves.length) {
|
|
420
|
+
turning.delete(element);
|
|
421
|
+
return aim(element, "in", side);
|
|
422
|
+
}
|
|
423
|
+
// allSettled, because a wave turned round on the way rejects rather than
|
|
424
|
+
// resolves. Either way it is nearer an end than it was, so look again.
|
|
425
|
+
void Promise.allSettled(waves.map((run) => run.finished)).then(later);
|
|
426
|
+
};
|
|
427
|
+
later();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Where an element's waves were last sent, off its own inline style. Nothing
|
|
432
|
+
* written reads as a fresh element: both waves home, running up from the start.
|
|
433
|
+
*/
|
|
434
|
+
function waves(element: HTMLElement): Waves {
|
|
435
|
+
const end = (name: string): End =>
|
|
436
|
+
element.style.getPropertyValue(name) === "1" ? "1" : "0";
|
|
437
|
+
const way: Way = element.style.getPropertyValue("--atw-way") === "-1" ? "-1" : "1";
|
|
438
|
+
return { in: end("--atw-in"), out: end("--atw-out"), way };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Send an element's waves where a crossing says. All three every time: one
|
|
443
|
+
* already in place writes nothing new, and saying only the half that changed
|
|
444
|
+
* would mean guessing which half that is.
|
|
445
|
+
*/
|
|
446
|
+
function send(element: HTMLElement, sent: Waves): void {
|
|
447
|
+
element.style.setProperty("--atw-in", sent.in);
|
|
448
|
+
element.style.setProperty("--atw-out", sent.out);
|
|
449
|
+
element.style.setProperty("--atw-way", sent.way);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Wait for a band to come off the text, then turn the pair round ready for the
|
|
454
|
+
* next one.
|
|
455
|
+
*
|
|
456
|
+
* Nothing is put back. The waves finish a visit at the end they were sent to,
|
|
457
|
+
* and the next visit runs them home again; --atw-way is the sign that keeps
|
|
458
|
+
* the gap between them the right way up either way round. Putting them back
|
|
459
|
+
* instead would mean a whole further crossing of nothing to look at, and any
|
|
460
|
+
* crossing that landed in it would find both waves mid-flight, where neither
|
|
461
|
+
* end may be moved.
|
|
462
|
+
*
|
|
463
|
+
* Worked out from where the waves were sent rather than turned over, so that
|
|
464
|
+
* saying it twice says the same thing: a pointer that comes and goes during
|
|
465
|
+
* one band leaves a wait behind for every leaving, and they all finish on the
|
|
466
|
+
* same answer. And only where the two were sent to the same end, which is what
|
|
467
|
+
* a band that came off leaves behind and the only state this may read. A
|
|
468
|
+
* pointer sitting on the text has them at opposite ends holding it at full
|
|
469
|
+
* weight, and turning the pair round under that would let go of all of it.
|
|
470
|
+
*/
|
|
471
|
+
function settle(element: HTMLElement): void {
|
|
472
|
+
const runs = running(element, "both");
|
|
473
|
+
if (runs.length) {
|
|
474
|
+
void Promise.allSettled(runs.map((run) => run.finished)).then(() => settle(element));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// A flip means an end only for the way the pair is running, so turning the
|
|
478
|
+
// pair round writes both again for the ends they meant. Left alone, a page
|
|
479
|
+
// that switches the text on next would send it from the wrong end.
|
|
480
|
+
const ends = { in: aimed(element, "in"), out: aimed(element, "out") };
|
|
481
|
+
send(element, settled(waves(element)));
|
|
482
|
+
aim(element, "in", ends.in);
|
|
483
|
+
aim(element, "out", ends.out);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Elements whose wave follows the page. */
|
|
487
|
+
const scrollers = new Set<HTMLElement>();
|
|
488
|
+
/** The end the page last moved toward, which is what a new scroller starts at. */
|
|
489
|
+
let scrolledToward: Side = "start";
|
|
490
|
+
/**
|
|
491
|
+
* The order things happened in: each one takes the next number. A switch can
|
|
492
|
+
* be flipped by the pointer or by the page, and which moved last says which.
|
|
493
|
+
*/
|
|
494
|
+
let clock = 0;
|
|
495
|
+
/** When the page last scrolled, on that clock. */
|
|
496
|
+
let scrolledAt = 0;
|
|
497
|
+
|
|
498
|
+
let watchingScroll = false;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Watch the page, once, for all of them. An element no longer in the document
|
|
502
|
+
* is dropped, not turned.
|
|
503
|
+
*/
|
|
504
|
+
function watchScroll(): void {
|
|
505
|
+
if (watchingScroll) return;
|
|
506
|
+
watchingScroll = true;
|
|
507
|
+
let at = scrollY;
|
|
508
|
+
addEventListener(
|
|
509
|
+
"scroll",
|
|
510
|
+
() => {
|
|
511
|
+
const moved = scrollY - at;
|
|
512
|
+
at = scrollY;
|
|
513
|
+
if (!moved) return;
|
|
514
|
+
scrolledAt = ++clock;
|
|
515
|
+
// Down the page reads the way the text does, from the start; back up
|
|
516
|
+
// reads from the end. Only a turn is worth writing.
|
|
517
|
+
const now: Side = moved > 0 ? "start" : "end";
|
|
518
|
+
if (now === scrolledToward) return;
|
|
519
|
+
scrolledToward = now;
|
|
520
|
+
for (const element of scrollers) {
|
|
521
|
+
if (!element.isConnected) forget(element);
|
|
522
|
+
else if (follows(element).has("scroll")) turn(element, now);
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{ passive: true },
|
|
526
|
+
);
|
|
295
527
|
}
|
|
296
528
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Elements whose waves the script sends, what takes their listeners down, and
|
|
531
|
+
* whether the pointer is one of the things they follow.
|
|
532
|
+
*/
|
|
533
|
+
const sending = new Map<HTMLElement, { off: AbortController; pointer: boolean }>();
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Follow the pointer across one element: a wave in after it and one out
|
|
537
|
+
* behind it.
|
|
538
|
+
*
|
|
539
|
+
* Going in points the leading wave at the end the pointer crossed, so the
|
|
540
|
+
* weight runs after the pointer. Coming out sends the trailing wave from the
|
|
541
|
+
* far end, so the weight lets go behind it. Cross a word left to right and one
|
|
542
|
+
* band of weight travels left to right; turn back at the edge you came in by
|
|
543
|
+
* and the band comes back with you.
|
|
544
|
+
*
|
|
545
|
+
* Writing this on the crossing itself only works because no tween starts on a
|
|
546
|
+
* crossing any more. The units read the flips in the open, every frame, so a
|
|
547
|
+
* wave that is already running turns round when one does. The browser is
|
|
548
|
+
* welcome to switch :hover on before it says a word about it.
|
|
549
|
+
*
|
|
550
|
+
* One band at a time, and the waves themselves say which one. Both at rest is
|
|
551
|
+
* a fresh visit, where either end may be moved. Anything else is the last band
|
|
552
|
+
* still coming off, and a switch that comes back on then turns the trailing
|
|
553
|
+
* wave round rather than starting a second: no end moves, nothing jumps, and
|
|
554
|
+
* the weight fills back in behind the pointer in the order it let go.
|
|
555
|
+
*
|
|
556
|
+
* The pointer only aims. What sends the waves is the page's switch, heard
|
|
557
|
+
* through --atw-on, because a hover is only one of the things that hold it: a
|
|
558
|
+
* nav link the page marks as current stays bold after the pointer has been
|
|
559
|
+
* and gone, and goes light when the page lets go of it, from the end the page
|
|
560
|
+
* scrolled toward.
|
|
561
|
+
*
|
|
562
|
+
* So an element that follows only the page hears the switch the same way, with
|
|
563
|
+
* no pointer to aim for it. Left to the stylesheet, its two waves would run
|
|
564
|
+
* together, and a switch that a jump by End flips on and off again a frame
|
|
565
|
+
* later would show nothing at all; sent from here, it gets the least band.
|
|
566
|
+
*/
|
|
567
|
+
function cross(element: HTMLElement, signal: AbortSignal, pointer: boolean): void {
|
|
568
|
+
// On the clock, so the switch can tell which of them flipped it.
|
|
569
|
+
let enteredAt = 0;
|
|
570
|
+
let leftAt = 0;
|
|
571
|
+
let onAt = 0;
|
|
572
|
+
// When the switch last came on, in ms: how long the band has had.
|
|
573
|
+
let arrived = 0;
|
|
574
|
+
// The end the trailing wave is to come off from, if the pointer lets go.
|
|
575
|
+
let exit: Side = "start";
|
|
576
|
+
const near = (event: PointerEvent): Side => {
|
|
577
|
+
const box = element.getBoundingClientRect();
|
|
578
|
+
// Line by line the wave runs top to bottom, so the height the pointer
|
|
579
|
+
// crossed at is what says which end it is nearest.
|
|
580
|
+
if (!element.querySelector(`.${CHAR}`)) return event.clientY < box.top + box.height / 2 ? "start" : "end";
|
|
581
|
+
// Along a line, the left half is the start unless the text runs the other way.
|
|
582
|
+
const rtl = getComputedStyle(element).direction === "rtl";
|
|
583
|
+
return event.clientX < box.left + box.width / 2 !== rtl ? "start" : "end";
|
|
584
|
+
};
|
|
585
|
+
// Taken down when the element is dropped: one that comes back gets a fresh
|
|
586
|
+
// set, never a second one on top.
|
|
587
|
+
if (pointer) {
|
|
588
|
+
element.addEventListener("pointerenter", (event) => {
|
|
589
|
+
enteredAt = ++clock;
|
|
590
|
+
// Only where the leading wave is parked: every unit reads it as the same
|
|
591
|
+
// number there, so counting it from the other end changes nothing.
|
|
592
|
+
if (parked(element, "in")) aim(element, "in", near(event));
|
|
593
|
+
}, { signal });
|
|
594
|
+
element.addEventListener("pointerleave", (event) => {
|
|
595
|
+
leftAt = ++clock;
|
|
596
|
+
exit = OTHER_END[near(event)];
|
|
597
|
+
}, { signal });
|
|
598
|
+
}
|
|
599
|
+
/*
|
|
600
|
+
* The doorbell rings on transitionrun. A switch that flips back before the
|
|
601
|
+
* browser has drawn a frame, which a jump by Home or End does to every
|
|
602
|
+
* section it passes, finds the doorbell still at its old value: the browser
|
|
603
|
+
* cancels it rather than running it again. The cancel is the same news.
|
|
604
|
+
*/
|
|
605
|
+
const hear = (event: TransitionEvent): void => {
|
|
606
|
+
// One per line, and the first speaks for all of them.
|
|
607
|
+
if (event.propertyName !== "--atw-on" || event.target !== element.querySelector(`.${LINE}`)) return;
|
|
608
|
+
const scroll = scrollers.has(element);
|
|
609
|
+
if (switchedOn(element)) {
|
|
610
|
+
onAt = ++clock;
|
|
611
|
+
arrived = event.timeStamp;
|
|
612
|
+
// The page switched it on, not the pointer: from the end it scrolled
|
|
613
|
+
// toward, whatever the last pointer to pass left behind.
|
|
614
|
+
if (scroll && scrolledAt > enteredAt && parked(element, "in")) aim(element, "in", scrolledToward);
|
|
615
|
+
// Leading wave to the far end, trailing wave home again, every time. On a
|
|
616
|
+
// fresh visit the trailing one is already home and only the leading one
|
|
617
|
+
// moves, which opens the band; mid-band the leading one has already
|
|
618
|
+
// arrived and only the trailing one moves, which sends the weight back in
|
|
619
|
+
// in the order it let go. Saying both either way is what stops this
|
|
620
|
+
// being a no-op, which is a switch that does nothing at all.
|
|
621
|
+
element.style.removeProperty("--atw-out-wait");
|
|
622
|
+
send(element, entered(waves(element)));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// Only where the trailing wave is parked: one sent home again mid-band
|
|
626
|
+
// keeps the end it had, and an end moved mid-crossing re-sorts the line.
|
|
627
|
+
// The pointer's end if leaving is what let go, else the page's.
|
|
628
|
+
const end = leftAt > Math.max(onAt, scrolledAt) ? exit : scroll ? scrolledToward : aimed(element);
|
|
629
|
+
if (parked(element, "out")) aim(element, "out", end);
|
|
630
|
+
// How far the trailing wave follows the leading one by: however long the
|
|
631
|
+
// switch was on, and never less than --atw-wave-min-delay. Brush a link in
|
|
632
|
+
// a moment and the band would otherwise be a letter wide and show nothing.
|
|
633
|
+
// The subtraction is left to CSS, which knows what that floor is.
|
|
634
|
+
element.style.setProperty(
|
|
635
|
+
"--atw-out-wait",
|
|
636
|
+
`max(0s, calc(var(--atw-wave-min-delay) - ${Math.round(event.timeStamp - arrived)}ms))`,
|
|
637
|
+
);
|
|
638
|
+
send(element, left(waves(element)));
|
|
639
|
+
settle(element);
|
|
640
|
+
};
|
|
641
|
+
element.addEventListener("transitionrun", hear, { signal });
|
|
642
|
+
element.addEventListener("transitioncancel", hear, { signal });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Whether the page's switch is on for an element right now. */
|
|
646
|
+
const switchedOn = (element: HTMLElement): boolean =>
|
|
647
|
+
getComputedStyle(element).getPropertyValue("--animate-weight").trim() === "true";
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Follow whatever the element asked its direction to follow. Both watchers
|
|
651
|
+
* write one end and nothing else, so the wave can turn round at any moment
|
|
652
|
+
* and nothing is measured again.
|
|
653
|
+
*
|
|
654
|
+
* Each is joined on its own, and an element told to follow something else
|
|
655
|
+
* later is simply dropped from the one it left.
|
|
656
|
+
*/
|
|
657
|
+
function watch(element: HTMLElement): void {
|
|
658
|
+
const modes = follows(element);
|
|
659
|
+
const pointer = modes.has("pointer");
|
|
660
|
+
const scroll = modes.has("scroll");
|
|
661
|
+
if (scroll) {
|
|
662
|
+
watchScroll();
|
|
663
|
+
scrollers.add(element);
|
|
664
|
+
turn(element, scrolledToward);
|
|
665
|
+
} else {
|
|
666
|
+
scrollers.delete(element);
|
|
667
|
+
}
|
|
668
|
+
// Calibration runs again on every resize; the listeners stay for as long as
|
|
669
|
+
// the element follows the same things, so they are hung once for that.
|
|
670
|
+
const had = sending.get(element);
|
|
671
|
+
if (had && had.pointer !== pointer) {
|
|
672
|
+
had.off.abort();
|
|
673
|
+
sending.delete(element);
|
|
674
|
+
}
|
|
675
|
+
if ((pointer || scroll) && !sending.has(element)) {
|
|
676
|
+
const off = new AbortController();
|
|
677
|
+
sending.set(element, { off, pointer });
|
|
678
|
+
cross(element, off.signal, pointer);
|
|
679
|
+
// Already on, and nothing flips it to say so: send the waves where the
|
|
680
|
+
// switch would have, so the first time it goes off has a band to lift.
|
|
681
|
+
if (switchedOn(element)) send(element, entered(waves(element)));
|
|
682
|
+
}
|
|
683
|
+
if (!pointer && !scroll) {
|
|
684
|
+
sending.get(element)?.off.abort();
|
|
685
|
+
sending.delete(element);
|
|
686
|
+
// The waves were the script's to send. Left where it last sent them, they
|
|
687
|
+
// would hold the text there, whatever the page's switch says after.
|
|
688
|
+
for (const name of ["--atw-in", "--atw-out", "--atw-out-wait", "--atw-way"])
|
|
689
|
+
element.style.removeProperty(name);
|
|
690
|
+
// A fixed end again: drop what the script last wrote, or the stylesheet
|
|
691
|
+
// would still be read through it. The waiting turn first, or it would put
|
|
692
|
+
// an end back after the rest had gone.
|
|
693
|
+
turning.delete(element);
|
|
694
|
+
element.style.removeProperty("--atw-flip-in");
|
|
695
|
+
element.style.removeProperty("--atw-flip-out");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Elements calibrated again when the page changes width. */
|
|
700
|
+
const resized = new Set<HTMLElement>();
|
|
701
|
+
let watchingResize = false;
|
|
702
|
+
|
|
703
|
+
/** Let go of an element that has left the document, listeners and all. */
|
|
704
|
+
function forget(element: HTMLElement): void {
|
|
705
|
+
resized.delete(element);
|
|
706
|
+
scrollers.delete(element);
|
|
707
|
+
sending.get(element)?.off.abort();
|
|
708
|
+
sending.delete(element);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Watch the page width, once, for all of them. An element no longer in the
|
|
713
|
+
* document is dropped, not calibrated.
|
|
714
|
+
*/
|
|
715
|
+
function watchResize(): void {
|
|
716
|
+
if (watchingResize) return;
|
|
717
|
+
watchingResize = true;
|
|
718
|
+
let width = innerWidth;
|
|
719
|
+
let pending = 0;
|
|
720
|
+
// A line is laid out once per stop, and character mode reads every character
|
|
721
|
+
// out of each of those, so a dragged window edge must not recalibrate on
|
|
722
|
+
// every event it fires.
|
|
723
|
+
addEventListener("resize", () => {
|
|
724
|
+
if (innerWidth === width) return;
|
|
725
|
+
width = innerWidth;
|
|
726
|
+
clearTimeout(pending);
|
|
727
|
+
pending = setTimeout(() => {
|
|
728
|
+
for (const element of resized) {
|
|
729
|
+
if (element.isConnected) calibrate(element);
|
|
730
|
+
else forget(element);
|
|
731
|
+
}
|
|
732
|
+
}, 150);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Split one element into its lines, then its units, and write their values.
|
|
738
|
+
* Done again after each resize, for as long as the element is in the document.
|
|
739
|
+
*/
|
|
740
|
+
export function calibrate(element: HTMLElement): Report | null {
|
|
741
|
+
const asked = range(element);
|
|
742
|
+
if (!asked) return null;
|
|
743
|
+
for (const old of resized) if (!old.isConnected) forget(old);
|
|
744
|
+
resized.add(element);
|
|
745
|
+
watchResize();
|
|
746
|
+
watch(element);
|
|
747
|
+
const { from, to } = asked;
|
|
748
|
+
const weights = Array.from({ length: STOPS }, (_, stop) => from + ((to - from) * stop) / (STOPS - 1));
|
|
303
749
|
// The element's own spacing is the from state; every stop adds to it.
|
|
304
|
-
const base = parseFloat(getComputedStyle(
|
|
750
|
+
const base = parseFloat(getComputedStyle(element).letterSpacing) || 0;
|
|
305
751
|
// Lines break at the from weight, so the element must sit there.
|
|
306
|
-
if (Number(getComputedStyle(
|
|
752
|
+
if (Number(getComputedStyle(element).fontWeight) !== from) element.style.fontWeight = String(from);
|
|
307
753
|
|
|
308
|
-
restore(
|
|
309
|
-
const texts = readLines(
|
|
754
|
+
restore(element);
|
|
755
|
+
const texts = readLines(element);
|
|
310
756
|
const spans = texts.map((text) => {
|
|
311
757
|
const span = document.createElement("span");
|
|
312
758
|
span.className = LINE;
|
|
313
759
|
span.textContent = text;
|
|
314
760
|
return span;
|
|
315
761
|
});
|
|
316
|
-
|
|
762
|
+
element.replaceChildren(...spans.flatMap((span, index) => (index ? [document.createElement("br"), span] : [span])));
|
|
763
|
+
// The far end of a line-by-line wave. A line split into characters sets its
|
|
764
|
+
// own, which is the one its characters then see.
|
|
765
|
+
element.style.setProperty("--atw-delay-total", String(spans.length - 1));
|
|
317
766
|
|
|
318
767
|
// Falls back to whole lines if a line cannot be split into characters.
|
|
319
|
-
let by: Mode =
|
|
768
|
+
let by: Mode = asked.by;
|
|
320
769
|
// Measure in place, before the next paint, with the tween switched off.
|
|
321
|
-
const lines = spans.map((span,
|
|
322
|
-
const text = texts[
|
|
770
|
+
const lines = spans.map((span, index): Line => {
|
|
771
|
+
const text = texts[index] ?? "";
|
|
323
772
|
span.style.transition = "none";
|
|
324
773
|
span.style.letterSpacing = `${base}px`;
|
|
325
|
-
const widthAt = (
|
|
326
|
-
span.style.fontWeight = String(
|
|
774
|
+
const widthAt = (weight: number) => {
|
|
775
|
+
span.style.fontWeight = String(weight);
|
|
327
776
|
return span.getBoundingClientRect().width;
|
|
328
777
|
};
|
|
329
778
|
const target = widthAt(from);
|
|
@@ -348,38 +797,27 @@ export function calibrate(el: HTMLElement): Report | null {
|
|
|
348
797
|
// depend on the weight.
|
|
349
798
|
const widths: number[] = [];
|
|
350
799
|
const counts: number[] = [];
|
|
351
|
-
for (const
|
|
352
|
-
const width = widthAt(
|
|
800
|
+
for (const weight of weights) {
|
|
801
|
+
const width = widthAt(weight);
|
|
353
802
|
widths.push(width);
|
|
354
803
|
counts.push(slots(span, base, width));
|
|
355
804
|
}
|
|
356
|
-
const spacing = widths.map((
|
|
805
|
+
const spacing = widths.map((width, stop) => base + (target - width) / (counts[stop] ?? 1));
|
|
357
806
|
values(span, "ls", spacing);
|
|
358
807
|
// One line, one delay: the block arrives line by line.
|
|
359
|
-
unit(span, from, to,
|
|
808
|
+
unit(span, from, to, index);
|
|
360
809
|
span.style.removeProperty("font-weight");
|
|
361
810
|
done();
|
|
362
|
-
return { text, target, widths, spacing, delayCount:
|
|
811
|
+
return { text, target, widths, spacing, delayCount: index, units: [] };
|
|
363
812
|
});
|
|
364
813
|
return { from, to, by, weights, lines };
|
|
365
814
|
}
|
|
366
815
|
|
|
367
816
|
/** Calibrate every animated element under root, now and after each resize. */
|
|
368
817
|
export function calibrateAll(root: ParentNode = document): HTMLElement[] {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
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;
|
|
818
|
+
const elements = findAll(root);
|
|
819
|
+
elements.forEach((element) => calibrate(element));
|
|
820
|
+
return elements;
|
|
383
821
|
}
|
|
384
822
|
|
|
385
823
|
/** Wait for the fonts, then calibrate the page. */
|