animate-text-weight 0.1.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/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(el: Element): { from: number; to: number; by: Mode } | null {
83
- const cs = getComputedStyle(el);
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 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);
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(el: HTMLElement): string[] {
104
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
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 m = re.exec(text); m; m = re.exec(text)) {
111
- rangeOf.setStart(node, m.index);
112
- rangeOf.setEnd(node, m.index + m[0].length);
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((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]] });
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((a, b) => a.top - b.top).map((l) => l.words.join(" "));
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(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)));
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((v, k) => span.style.setProperty(`--atw-${name}-${k}`, `${v.toFixed(3)}px`));
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((p) => p.segment);
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((_, k) => advances.reduce((sum, a) => sum + (a[k] ?? 0), 0));
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, i) => {
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[i] ?? "";
275
+ open.text += parts[index] ?? "";
266
276
  open.parts += 1;
267
- } else units.push({ text: parts[i] ?? "", start: i, parts: 1 });
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, a) => sum + a, 0)),
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((u, i) => {
289
+ const made = units.map((group, index) => {
280
290
  const span = document.createElement("span");
281
291
  span.className = CHAR;
282
- span.textContent = u.text;
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(u.text) ? ++count : Math.max(count, 0);
286
- values(span, "w", boxes[i] ?? []);
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: u.text, target: kerned[i]?.[0] ?? 0, boxes: boxes[i] ?? [], delayCount } };
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.replaceChildren(...made.map((m) => m.span));
294
- return { units: made.map((m) => m.out), widths };
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
- /** 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));
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(el).letterSpacing) || 0;
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(el).fontWeight) !== from) el.style.fontWeight = String(from);
752
+ if (Number(getComputedStyle(element).fontWeight) !== from) element.style.fontWeight = String(from);
307
753
 
308
- restore(el);
309
- const texts = readLines(el);
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
- el.replaceChildren(...spans.flatMap((s, i) => (i ? [document.createElement("br"), s] : [s])));
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 = r.by;
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, i): Line => {
322
- const text = texts[i] ?? "";
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 = (w: number) => {
326
- span.style.fontWeight = String(w);
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 w of weights) {
352
- const width = widthAt(w);
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((w, k) => base + (target - w) / (counts[k] ?? 1));
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, i);
808
+ unit(span, from, to, index);
360
809
  span.style.removeProperty("font-weight");
361
810
  done();
362
- return { text, target, widths, spacing, delayCount: i, units: [] };
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 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;
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. */