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