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/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/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(
|
|
43
|
-
const cs = getComputedStyle(
|
|
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
|
|
55
|
-
if (
|
|
55
|
+
for (const element of root.querySelectorAll("*")) {
|
|
56
|
+
if (element.classList.contains(LINE))
|
|
56
57
|
continue;
|
|
57
|
-
const hasText = [...
|
|
58
|
-
if (hasText && range(
|
|
59
|
-
out.push(
|
|
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(
|
|
65
|
-
const walker = document.createTreeWalker(
|
|
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
|
|
72
|
-
rangeOf.setStart(node,
|
|
73
|
-
rangeOf.setEnd(node,
|
|
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((
|
|
76
|
+
const line = lines.find((candidate) => Math.abs(candidate.top - rect.top) < 1);
|
|
76
77
|
if (line)
|
|
77
|
-
line.words.push(
|
|
78
|
+
line.words.push(word[0]);
|
|
78
79
|
else
|
|
79
|
-
lines.push({ top: rect.top, words: [
|
|
80
|
+
lines.push({ top: rect.top, words: [word[0]] });
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
return lines.sort((
|
|
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(
|
|
89
|
-
const source = sources.get(
|
|
90
|
-
if (source &&
|
|
91
|
-
|
|
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(
|
|
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((
|
|
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((
|
|
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((_,
|
|
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,
|
|
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[
|
|
215
|
+
open.text += parts[index] ?? "";
|
|
215
216
|
open.parts += 1;
|
|
216
217
|
}
|
|
217
218
|
else
|
|
218
|
-
units.push({ text: parts[
|
|
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,
|
|
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((
|
|
227
|
+
const made = units.map((group, index) => {
|
|
227
228
|
const span = document.createElement("span");
|
|
228
229
|
span.className = CHAR;
|
|
229
|
-
span.textContent =
|
|
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(
|
|
233
|
-
values(span, "w", boxes[
|
|
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:
|
|
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
|
|
240
|
-
|
|
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
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
248
|
-
|
|
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(
|
|
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(
|
|
253
|
-
|
|
254
|
-
restore(
|
|
255
|
-
const texts = readLines(
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
267
|
-
const text = texts[
|
|
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 = (
|
|
271
|
-
span.style.fontWeight = String(
|
|
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
|
|
295
|
-
const width = widthAt(
|
|
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((
|
|
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,
|
|
726
|
+
unit(span, from, to, index);
|
|
303
727
|
span.style.removeProperty("font-weight");
|
|
304
728
|
done();
|
|
305
|
-
return { text, target, widths, spacing, delayCount:
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
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) {
|