@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.46.1",
3
+ "version": "1.46.2",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -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 (typeof IntersectionObserver !== "function") return;
167
+ if (!canReveal()) return;
36
168
  const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
37
- if (preference.matches || document.hidden) return;
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
- const cancel = () => {
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 items = [...container.querySelectorAll<HTMLElement>(ITEM)].slice(0, MAX_ITEMS);
55
- for (const [index, item] of items.entries()) {
56
- if (typeof item.animate !== "function") continue;
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 = item.animate(
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: leadIn + Math.min(index * STAGGER_MS, MAX_DELAY_MS),
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
- /* An unsupported effect leaves the authored visible list. */
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
- preference.addEventListener("change", cancel);
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", cancel);
106
- cancel();
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