@xingwangzhe/stalux 1.46.1 → 1.46.2
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/package.json +1 -1
- package/src/scripts/list-reveal.ts +187 -34
- package/src/scripts/swup-router.ts +27 -0
package/package.json
CHANGED
|
@@ -20,6 +20,138 @@ const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
|
20
20
|
const BLEED = "-10px";
|
|
21
21
|
const UNROLL_START = `inset(${BLEED} ${BLEED} 100% ${BLEED})`;
|
|
22
22
|
const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
|
|
23
|
+
const UNROLL_KEYFRAMES: Keyframe[] = [
|
|
24
|
+
{
|
|
25
|
+
clipPath: UNROLL_START,
|
|
26
|
+
opacity: 0.4,
|
|
27
|
+
transform: "translate3d(0, -10px, 0)",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
clipPath: UNROLL_END,
|
|
31
|
+
opacity: 1,
|
|
32
|
+
transform: "translate3d(0, 0, 0)",
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
// A swap can leave a row waiting at the start frame until it is scrolled into
|
|
36
|
+
// view. Keep the bookkeeping bounded; a runaway map would pin removed nodes.
|
|
37
|
+
const MAX_HELD = MAX_CONTAINERS * MAX_ITEMS;
|
|
38
|
+
// A held row that never reaches the viewport would sit clipped forever if the
|
|
39
|
+
// visit were abandoned before the observer ever ran. Releasing it after this
|
|
40
|
+
// long is safe: an unheld row falls back to the original path, which starts the
|
|
41
|
+
// wipe on its own without a flash.
|
|
42
|
+
const HOLD_TIMEOUT_MS = 5000;
|
|
43
|
+
|
|
44
|
+
interface Held {
|
|
45
|
+
animation: Animation;
|
|
46
|
+
stagger: number;
|
|
47
|
+
timer: ReturnType<typeof setTimeout>;
|
|
48
|
+
}
|
|
49
|
+
const held = new Map<HTMLElement, Held>();
|
|
50
|
+
|
|
51
|
+
function canReveal(): boolean {
|
|
52
|
+
if (typeof IntersectionObserver !== "function") return false;
|
|
53
|
+
if (typeof document === "undefined") return false;
|
|
54
|
+
if (document.hidden) return false;
|
|
55
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
|
|
56
|
+
// Returning to a history entry restores what the reader already saw.
|
|
57
|
+
return document.documentElement.dataset.staluxVtPreset !== "restore";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function groups(): HTMLElement[] {
|
|
61
|
+
return [...document.querySelectorAll<HTMLElement>(CONTAINER)]
|
|
62
|
+
.filter((element) => !element.closest("[data-swup-persist]"))
|
|
63
|
+
.slice(0, MAX_CONTAINERS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rows(group: HTMLElement): HTMLElement[] {
|
|
67
|
+
return [...group.querySelectorAll<HTMLElement>(ITEM)].slice(0, MAX_ITEMS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Only a group the reader can already see is worth holding.
|
|
72
|
+
*
|
|
73
|
+
* Every held row costs a live effect on the compositor. An archive page carries
|
|
74
|
+
* one group per month, so holding all of them at swap time built hundreds of
|
|
75
|
+
* paused effects inside the swap frame — a 200-400ms task that dropped the frame
|
|
76
|
+
* the entry cue needed. Groups further down the page keep the authored visible
|
|
77
|
+
* state and start their wipe when they scroll in, which never flashed anyway.
|
|
78
|
+
*/
|
|
79
|
+
function nearViewport(group: HTMLElement): boolean {
|
|
80
|
+
const rect = group.getBoundingClientRect();
|
|
81
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
82
|
+
const margin = window.innerHeight * 0.5;
|
|
83
|
+
return rect.top < window.innerHeight + margin && rect.bottom > -margin;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function staggerOf(index: number): number {
|
|
87
|
+
return Math.min(index * STAGGER_MS, MAX_DELAY_MS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function leadIn(): number {
|
|
91
|
+
// A route cue leaves its preset on the document element while it runs.
|
|
92
|
+
return document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hold every incoming row on the wipe's first frame the moment the router has
|
|
97
|
+
* its new document, before the browser paints it.
|
|
98
|
+
*
|
|
99
|
+
* Without this the swap paints the finished list for one frame, the observer
|
|
100
|
+
* fires a frame later, and the rows appear to flash, vanish, then unroll. A
|
|
101
|
+
* paused effect still paints its `backwards` fill, so priming here makes the
|
|
102
|
+
* first painted frame the *start* of the wipe: the reader only ever sees the
|
|
103
|
+
* sheet hidden, then unrolling.
|
|
104
|
+
*/
|
|
105
|
+
export function primeListReveal(): void {
|
|
106
|
+
if (!canReveal()) return;
|
|
107
|
+
// Rows from the document that was just replaced can never be revealed
|
|
108
|
+
// again; drop their paused effects instead of pinning detached nodes.
|
|
109
|
+
for (const [row, entry] of held) {
|
|
110
|
+
if (row.isConnected) continue;
|
|
111
|
+
held.delete(row);
|
|
112
|
+
clearTimeout(entry.timer);
|
|
113
|
+
entry.animation.cancel();
|
|
114
|
+
}
|
|
115
|
+
for (const group of groups()) {
|
|
116
|
+
// A group that is not laid out (the desktop tag cloud hides the mobile
|
|
117
|
+
// card groups, and vice versa) could never play its effect, and a group
|
|
118
|
+
// that is still off-screen must not add effects to the swap frame.
|
|
119
|
+
if (!nearViewport(group)) continue;
|
|
120
|
+
for (const [index, row] of rows(group).entries()) {
|
|
121
|
+
if (held.has(row) || held.size >= MAX_HELD) continue;
|
|
122
|
+
if (typeof row.animate !== "function") continue;
|
|
123
|
+
try {
|
|
124
|
+
const animation = row.animate(UNROLL_KEYFRAMES, {
|
|
125
|
+
duration: DURATION_MS,
|
|
126
|
+
delay: 0,
|
|
127
|
+
easing: EASING,
|
|
128
|
+
// The paused effect still paints this first frame, which is
|
|
129
|
+
// what removes the flash.
|
|
130
|
+
fill: "backwards",
|
|
131
|
+
});
|
|
132
|
+
animation.pause();
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
// The visit was abandoned: release the row so the authored
|
|
135
|
+
// visible state returns rather than staying clipped.
|
|
136
|
+
if (!held.has(row)) return;
|
|
137
|
+
held.delete(row);
|
|
138
|
+
animation.cancel();
|
|
139
|
+
}, HOLD_TIMEOUT_MS);
|
|
140
|
+
held.set(row, { animation, stagger: staggerOf(index), timer });
|
|
141
|
+
} catch {
|
|
142
|
+
/* An unsupported effect leaves the authored visible row. */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function cancelHeld(): void {
|
|
149
|
+
for (const [row, entry] of held) {
|
|
150
|
+
held.delete(row);
|
|
151
|
+
clearTimeout(entry.timer);
|
|
152
|
+
entry.animation.cancel();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
23
155
|
|
|
24
156
|
/**
|
|
25
157
|
* Rows unroll into place like paper: each one wipes open from its top edge
|
|
@@ -32,50 +164,50 @@ const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
|
|
|
32
164
|
* clipped state can outlive the animation.
|
|
33
165
|
*/
|
|
34
166
|
export function mountListReveal(): (() => void) | undefined {
|
|
35
|
-
if (
|
|
167
|
+
if (!canReveal()) return;
|
|
36
168
|
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
37
|
-
|
|
38
|
-
// Returning to a history entry restores what the reader already saw.
|
|
39
|
-
if (document.documentElement.dataset.staluxVtPreset === "restore") return;
|
|
40
|
-
const containers = [...document.querySelectorAll<HTMLElement>(CONTAINER)]
|
|
41
|
-
.filter((element) => !element.closest("[data-swup-persist]"))
|
|
42
|
-
.slice(0, MAX_CONTAINERS);
|
|
169
|
+
const containers = groups();
|
|
43
170
|
if (!containers.length) return;
|
|
44
|
-
// A route cue leaves its preset on the document element while it runs.
|
|
45
|
-
const leadIn = document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
|
|
46
171
|
|
|
47
172
|
const active = new Set<Animation>();
|
|
48
|
-
|
|
173
|
+
// Disposal runs on every same-document remount (page-runtime remounts on
|
|
174
|
+
// page-load). Releasing the held rows there would paint the finished list
|
|
175
|
+
// for a frame and clip it again on the next mount, which is the very flash
|
|
176
|
+
// this file removes. Held rows are released by the observer, by the timeout
|
|
177
|
+
// or by a reduced-motion switch instead.
|
|
178
|
+
const cancelActive = () => {
|
|
49
179
|
for (const animation of active) animation.cancel();
|
|
50
180
|
active.clear();
|
|
51
181
|
};
|
|
52
182
|
const reveal = (container: HTMLElement) => {
|
|
53
183
|
if (document.hidden || preference.matches) return;
|
|
54
|
-
const
|
|
55
|
-
for (const [index,
|
|
56
|
-
if (typeof
|
|
184
|
+
const lead = leadIn();
|
|
185
|
+
for (const [index, row] of rows(container).entries()) {
|
|
186
|
+
if (typeof row.animate !== "function") continue;
|
|
187
|
+
// Prefer the effect created during the swap: it already owns the
|
|
188
|
+
// start frame, so starting it here never shows a finished row.
|
|
189
|
+
const primed = held.get(row);
|
|
190
|
+
if (primed) {
|
|
191
|
+
held.delete(row);
|
|
192
|
+
clearTimeout(primed.timer);
|
|
193
|
+
}
|
|
57
194
|
try {
|
|
58
|
-
const animation =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
clipPath: UNROLL_START,
|
|
62
|
-
opacity: 0.4,
|
|
63
|
-
transform: "translate3d(0, -10px, 0)",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
clipPath: UNROLL_END,
|
|
67
|
-
opacity: 1,
|
|
68
|
-
transform: "translate3d(0, 0, 0)",
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
{
|
|
195
|
+
const animation =
|
|
196
|
+
primed?.animation ??
|
|
197
|
+
row.animate(UNROLL_KEYFRAMES, {
|
|
72
198
|
duration: DURATION_MS,
|
|
73
|
-
delay:
|
|
199
|
+
delay: lead + staggerOf(index),
|
|
74
200
|
easing: EASING,
|
|
75
201
|
// Hidden during the stagger delay instead of popping in.
|
|
76
202
|
fill: "backwards",
|
|
77
|
-
}
|
|
78
|
-
)
|
|
203
|
+
});
|
|
204
|
+
if (primed) {
|
|
205
|
+
// The stagger is counted from now, not from the swap.
|
|
206
|
+
primed.animation.effect?.updateTiming({
|
|
207
|
+
delay: lead + primed.stagger,
|
|
208
|
+
});
|
|
209
|
+
primed.animation.play();
|
|
210
|
+
}
|
|
79
211
|
active.add(animation);
|
|
80
212
|
const release = () => {
|
|
81
213
|
active.delete(animation);
|
|
@@ -84,7 +216,8 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
84
216
|
};
|
|
85
217
|
void animation.finished.then(release, release);
|
|
86
218
|
} catch {
|
|
87
|
-
|
|
219
|
+
// A paused row would stay clipped forever otherwise.
|
|
220
|
+
primed?.animation.cancel();
|
|
88
221
|
}
|
|
89
222
|
}
|
|
90
223
|
};
|
|
@@ -99,10 +232,30 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
99
232
|
{ threshold: 0.05 },
|
|
100
233
|
);
|
|
101
234
|
for (const container of containers) observer.observe(container);
|
|
102
|
-
|
|
235
|
+
const stopMotion = () => {
|
|
236
|
+
cancelActive();
|
|
237
|
+
// The reader asked for less motion: nothing may stay clipped.
|
|
238
|
+
cancelHeld();
|
|
239
|
+
};
|
|
240
|
+
preference.addEventListener("change", stopMotion);
|
|
103
241
|
return () => {
|
|
104
242
|
observer.disconnect();
|
|
105
|
-
preference.removeEventListener("change",
|
|
106
|
-
|
|
243
|
+
preference.removeEventListener("change", stopMotion);
|
|
244
|
+
cancelActive();
|
|
107
245
|
};
|
|
108
246
|
}
|
|
247
|
+
|
|
248
|
+
// The router dispatches this synchronously after replacing the main node and
|
|
249
|
+
// before the browser paints, which is the only moment where the incoming rows
|
|
250
|
+
// can be held without a visible flash. Registered once per document even when
|
|
251
|
+
// both the shared layout and the article layout import this module.
|
|
252
|
+
interface PrimeHost {
|
|
253
|
+
__staluxListRevealPrime?: boolean;
|
|
254
|
+
}
|
|
255
|
+
if (typeof document !== "undefined" && typeof window !== "undefined") {
|
|
256
|
+
const host = document as Document & PrimeHost;
|
|
257
|
+
if (!host.__staluxListRevealPrime) {
|
|
258
|
+
host.__staluxListRevealPrime = true;
|
|
259
|
+
document.addEventListener("astro:after-swap", primeListReveal);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -307,6 +307,33 @@ export function installSwupRouter(): Swup {
|
|
|
307
307
|
html.dataset.staluxRoute = getRouteKind(context.to.pathname);
|
|
308
308
|
visibleHistoryIndex = Number(window.history.state?.index ?? visibleHistoryIndex);
|
|
309
309
|
dispatchNavigation("astro:after-swap", context, next);
|
|
310
|
+
// The entry cue has to own the first painted frame of the new document.
|
|
311
|
+
// content:scroll is an await and a rAF away, and running the incoming
|
|
312
|
+
// scripts can block the main thread for hundreds of milliseconds: the
|
|
313
|
+
// reader would see the finished page for one or more frames and only
|
|
314
|
+
// then watch it jump to the entry offset and slide back, which reads as
|
|
315
|
+
// the page shaking. Put the scroll at its final position and start the
|
|
316
|
+
// cue in this same synchronous block, before the scripts run, so the
|
|
317
|
+
// compositor already owns the cue while the main thread is busy. A visit
|
|
318
|
+
// with a hash or a history traversal keeps the deferred path, because its
|
|
319
|
+
// scroll is not decided yet.
|
|
320
|
+
if (
|
|
321
|
+
// A history traversal restores a position that is only known later,
|
|
322
|
+
// and a hash target is decided by the anchor.
|
|
323
|
+
!visit.history.popstate &&
|
|
324
|
+
context.navigationType !== "traverse" &&
|
|
325
|
+
!context.to.hash &&
|
|
326
|
+
context.preset !== "none" &&
|
|
327
|
+
// ScrollPlugin owns visit.scroll; a bare visit has no reset flag.
|
|
328
|
+
visit.scroll?.reset !== false
|
|
329
|
+
) {
|
|
330
|
+
const position = scroll.getCachedScrollPositions?.(visit.to.url)?.window;
|
|
331
|
+
const top = position?.top ?? 0;
|
|
332
|
+
const left = position?.left ?? 0;
|
|
333
|
+
if (window.scrollY !== top || window.scrollX !== left)
|
|
334
|
+
window.scrollTo({ top, left, behavior: "instant" });
|
|
335
|
+
startMotion(visit);
|
|
336
|
+
}
|
|
310
337
|
context.scriptsReady = context.executeScripts?.(context.controller.signal);
|
|
311
338
|
});
|
|
312
339
|
|