@xingwangzhe/stalux 1.44.0 → 1.45.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/package.json +1 -1
- package/src/components/stalux/archives/archivesList.astro +8 -2
- package/src/components/stalux/posts/PostTaxonomy.astro +2 -1
- package/src/components/stalux/posts/postLeft.astro +1 -1
- package/src/components/stalux/posts/postRight.astro +5 -1
- package/src/components/stalux/posts/postToc.astro +2 -2
- package/src/layouts/PostLayout.astro +8 -0
- package/src/scripts/list-reveal.ts +81 -0
- package/src/scripts/module-motion.ts +3 -0
- package/src/scripts/random-posts.ts +3 -0
- package/src/scripts/smooth-scroll.ts +87 -0
- package/src/scripts/swup-router.ts +12 -5
package/package.json
CHANGED
|
@@ -77,9 +77,12 @@ const archiveYears = [...archivesByYearMonth.entries()]
|
|
|
77
77
|
{ length: Math.ceil(monthPosts.length / 12) },
|
|
78
78
|
(_, index) => monthPosts.slice(index * 12, (index + 1) * 12),
|
|
79
79
|
).map((group) => (
|
|
80
|
-
<ul class="stalux-archive-post-list">
|
|
80
|
+
<ul class="stalux-archive-post-list" data-stalux-reveal>
|
|
81
81
|
{group.map((post) => (
|
|
82
|
-
<li
|
|
82
|
+
<li
|
|
83
|
+
class="stalux-archive-post-item"
|
|
84
|
+
data-stalux-reveal-item
|
|
85
|
+
>
|
|
83
86
|
<time
|
|
84
87
|
class="stalux-archive-post-date"
|
|
85
88
|
datetime={toMachineDateTime(
|
|
@@ -127,7 +130,10 @@ const archiveYears = [...archivesByYearMonth.entries()]
|
|
|
127
130
|
|
|
128
131
|
<script>
|
|
129
132
|
import { mountArchiveDescriptions } from "../../../scripts/archive-descriptions";
|
|
133
|
+
import { mountListReveal } from "../../../scripts/list-reveal";
|
|
130
134
|
import { registerPageLifecycle } from "../../../scripts/page-runtime";
|
|
131
135
|
|
|
132
136
|
registerPageLifecycle("archive-descriptions", mountArchiveDescriptions);
|
|
137
|
+
// The archive cascade ships only with the archive markup it animates.
|
|
138
|
+
registerPageLifecycle("list-reveal", mountListReveal);
|
|
133
139
|
</script>
|
|
@@ -46,12 +46,13 @@ const cfg = {
|
|
|
46
46
|
<nav class="stalux-post-taxonomy-container" aria-label={title ?? cfg.heading}>
|
|
47
47
|
<h3 class="stalux-post-taxonomy-title">{title ?? cfg.heading}</h3>
|
|
48
48
|
{items.length > 0 ? (
|
|
49
|
-
<div class={cfg.listClass}>
|
|
49
|
+
<div class={cfg.listClass} data-stalux-reveal>
|
|
50
50
|
{items.map((item) => (
|
|
51
51
|
<a
|
|
52
52
|
href={`${cfg.prefix}${item}/`}
|
|
53
53
|
class={`${cfg.itemClass} a-none`}
|
|
54
54
|
data-astro-prefetch
|
|
55
|
+
data-stalux-reveal-item
|
|
55
56
|
>
|
|
56
57
|
{item}
|
|
57
58
|
</a>
|
|
@@ -5,7 +5,7 @@ import PostAuthor from "@components/stalux/posts/postAuthor.astro";
|
|
|
5
5
|
import RandomPost from "@components/stalux/posts/randomPost.astro";
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
<aside class="stalux-sidebar">
|
|
8
|
+
<aside class="stalux-sidebar" data-swup-persist="post-sidebar-left">
|
|
9
9
|
<div class="stalux-sidebar-container" data-stalux-module="sidebar">
|
|
10
10
|
<PostAuthor />
|
|
11
11
|
</div>
|
|
@@ -15,7 +15,11 @@ const { headings = [], tags = [], categories = [] } = Astro.props;
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
<aside class="stalux-sidebar-right" data-stalux-scroll-pane>
|
|
18
|
-
<div
|
|
18
|
+
<div
|
|
19
|
+
class="stalux-sidebar-container"
|
|
20
|
+
data-stalux-module="sidebar"
|
|
21
|
+
data-swup-persist="post-sidebar-clock"
|
|
22
|
+
>
|
|
19
23
|
<PostDate />
|
|
20
24
|
<Weather />
|
|
21
25
|
</div>
|
|
@@ -32,9 +32,9 @@ const tocItems = headings
|
|
|
32
32
|
<nav data-toc-active-class="stalux-toc-active" aria-label={t("taxonomy.toc")}>
|
|
33
33
|
<h3 class="stalux-toc-title">{title}</h3>
|
|
34
34
|
{tocItems.length > 0 ? (
|
|
35
|
-
<ul class="stalux-toc-list">
|
|
35
|
+
<ul class="stalux-toc-list" data-stalux-reveal>
|
|
36
36
|
{tocItems.map((heading) => (
|
|
37
|
-
<li>
|
|
37
|
+
<li data-stalux-reveal-item>
|
|
38
38
|
<a
|
|
39
39
|
class={`stalux-toc-item ${({ 1: "stalux-toc-depth-1", 2: "stalux-toc-depth-2", 3: "stalux-toc-depth-3", 4: "stalux-toc-depth-4", 5: "stalux-toc-depth-5", 6: "stalux-toc-depth-6" } as Record<number, string>)[heading.depth] || ""} a-none`}
|
|
40
40
|
href={`#${heading.slug}`}
|
|
@@ -92,3 +92,11 @@ const walineConfig = walineSection?.serverURL
|
|
|
92
92
|
<PostRight headings={headings} tags={tags} categories={categories} />
|
|
93
93
|
</div>
|
|
94
94
|
</div>
|
|
95
|
+
|
|
96
|
+
<script>
|
|
97
|
+
import { mountListReveal } from "../scripts/list-reveal";
|
|
98
|
+
import { registerPageLifecycle } from "../scripts/page-runtime";
|
|
99
|
+
|
|
100
|
+
// Article sidebars cascade their lists; the script ships with article pages only.
|
|
101
|
+
registerPageLifecycle("list-reveal", mountListReveal);
|
|
102
|
+
</script>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const CONTAINER = "[data-stalux-reveal]";
|
|
2
|
+
const ITEM = ":scope > [data-stalux-reveal-item]";
|
|
3
|
+
// A cascade is only ever a bounded group cue: never one observer per row, and
|
|
4
|
+
// never a queue long enough to keep the reader waiting for the last item.
|
|
5
|
+
const MAX_CONTAINERS = 48;
|
|
6
|
+
const MAX_ITEMS = 16;
|
|
7
|
+
const STAGGER_MS = 22;
|
|
8
|
+
const MAX_DELAY_MS = 176;
|
|
9
|
+
const DURATION_MS = 240;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lists cascade in once, when their group becomes visible.
|
|
13
|
+
*
|
|
14
|
+
* Text is authored fully visible; the effect only dims a group that is about to
|
|
15
|
+
* be read. Rows outside the group cap, restored history entries, reduced
|
|
16
|
+
* motion, persisted shells and hidden documents are left untouched.
|
|
17
|
+
*/
|
|
18
|
+
export function mountListReveal(): (() => void) | undefined {
|
|
19
|
+
if (typeof IntersectionObserver !== "function") return;
|
|
20
|
+
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
21
|
+
if (preference.matches || document.hidden) return;
|
|
22
|
+
// Returning to a history entry restores what the reader already saw.
|
|
23
|
+
if (document.documentElement.dataset.staluxVtPreset === "restore") return;
|
|
24
|
+
const containers = [...document.querySelectorAll<HTMLElement>(CONTAINER)]
|
|
25
|
+
.filter((element) => !element.closest("[data-swup-persist]"))
|
|
26
|
+
.slice(0, MAX_CONTAINERS);
|
|
27
|
+
if (!containers.length) return;
|
|
28
|
+
|
|
29
|
+
const active = new Set<Animation>();
|
|
30
|
+
const cancel = () => {
|
|
31
|
+
for (const animation of active) animation.cancel();
|
|
32
|
+
active.clear();
|
|
33
|
+
};
|
|
34
|
+
const reveal = (container: HTMLElement) => {
|
|
35
|
+
if (document.hidden || preference.matches) return;
|
|
36
|
+
const items = [...container.querySelectorAll<HTMLElement>(ITEM)].slice(0, MAX_ITEMS);
|
|
37
|
+
for (const [index, item] of items.entries()) {
|
|
38
|
+
if (typeof item.animate !== "function") continue;
|
|
39
|
+
try {
|
|
40
|
+
const animation = item.animate(
|
|
41
|
+
{
|
|
42
|
+
opacity: [0.6, 1],
|
|
43
|
+
transform: ["translateY(8px)", "translateY(0)"],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
duration: DURATION_MS,
|
|
47
|
+
delay: Math.min(index * STAGGER_MS, MAX_DELAY_MS),
|
|
48
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
49
|
+
// Dimmed during the stagger delay instead of popping in.
|
|
50
|
+
fill: "backwards",
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
active.add(animation);
|
|
54
|
+
const release = () => {
|
|
55
|
+
active.delete(animation);
|
|
56
|
+
animation.cancel();
|
|
57
|
+
};
|
|
58
|
+
void animation.finished.then(release, release);
|
|
59
|
+
} catch {
|
|
60
|
+
/* An unsupported effect leaves the authored visible list. */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const observer = new IntersectionObserver(
|
|
65
|
+
(entries) => {
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (!entry.isIntersecting) continue;
|
|
68
|
+
observer.unobserve(entry.target);
|
|
69
|
+
reveal(entry.target as HTMLElement);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{ threshold: 0.05 },
|
|
73
|
+
);
|
|
74
|
+
for (const container of containers) observer.observe(container);
|
|
75
|
+
preference.addEventListener("change", cancel);
|
|
76
|
+
return () => {
|
|
77
|
+
observer.disconnect();
|
|
78
|
+
preference.removeEventListener("change", cancel);
|
|
79
|
+
cancel();
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -32,6 +32,9 @@ export function mountModuleMotion(): (() => void) | undefined {
|
|
|
32
32
|
motionPreference.matches ||
|
|
33
33
|
document.documentElement.classList.contains("is-changing") ||
|
|
34
34
|
document.documentElement.dataset.staluxVtPreset ||
|
|
35
|
+
// A persisted node kept its identity across the visit; it was
|
|
36
|
+
// never rebuilt, so re-entering it would be a second cue.
|
|
37
|
+
element.closest("[data-swup-persist]") ||
|
|
35
38
|
element.closest("[data-stalux-motion-region]") ||
|
|
36
39
|
entry.boundingClientRect.height > Math.min(420, window.innerHeight * 0.6) ||
|
|
37
40
|
typeof element.animate !== "function"
|
|
@@ -50,6 +50,9 @@ export function mountRandomPosts(): (() => void) | undefined {
|
|
|
50
50
|
};
|
|
51
51
|
const reveal = () => {
|
|
52
52
|
if (reducedMotion.matches || document.hidden || typeof list.animate !== "function") return;
|
|
53
|
+
// A persisted sidebar keeps its node across visits; fading it again would
|
|
54
|
+
// read as the fixed shell blinking, which is exactly what it must not do.
|
|
55
|
+
if (container.closest("[data-swup-persist]")) return;
|
|
53
56
|
try {
|
|
54
57
|
// Real titles can wrap the sidebar beyond the same bounded-region
|
|
55
58
|
// budget used for route effects. Keep that result immediately readable.
|
|
@@ -4,6 +4,93 @@ export function cancelPageScroll(): void {
|
|
|
4
4
|
stopActive?.();
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export interface PageGlide {
|
|
8
|
+
/** Settles when the page reaches the top, is interrupted, or is canceled. */
|
|
9
|
+
promise: Promise<void>;
|
|
10
|
+
/** Stop immediately at the current position without further frames. */
|
|
11
|
+
cancel: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Bound the pre-swap return-to-top so a 6000px article does not add a second. */
|
|
15
|
+
const GLIDE_MIN_MS = 160;
|
|
16
|
+
const GLIDE_MAX_MS = 360;
|
|
17
|
+
const GLIDE_SAFETY_MS = 760;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk the current document back to the top before the container is replaced.
|
|
21
|
+
* Swup's own between-page reset is instant; animating it after the swap would
|
|
22
|
+
* make the new article start wherever the old page happened to be scrolled.
|
|
23
|
+
* Doing it on the outgoing document keeps the incoming page at its real top.
|
|
24
|
+
*/
|
|
25
|
+
export function glideToTop(): PageGlide {
|
|
26
|
+
const element = document.scrollingElement ?? document.documentElement;
|
|
27
|
+
const startTop = Math.max(0, element.scrollTop || window.scrollY || 0);
|
|
28
|
+
const startLeft = Math.max(0, element.scrollLeft || window.scrollX || 0);
|
|
29
|
+
let resolve = () => {};
|
|
30
|
+
const promise = new Promise<void>((done) => {
|
|
31
|
+
resolve = done;
|
|
32
|
+
});
|
|
33
|
+
if (
|
|
34
|
+
startTop < 1 ||
|
|
35
|
+
typeof requestAnimationFrame !== "function" ||
|
|
36
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
37
|
+
document.hidden
|
|
38
|
+
) {
|
|
39
|
+
resolve();
|
|
40
|
+
return { promise, cancel: resolve };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Longer documents take proportionally longer, but the ceiling keeps the
|
|
44
|
+
// whole cue inside the same budget as the content fade.
|
|
45
|
+
const duration = Math.min(GLIDE_MAX_MS, GLIDE_MIN_MS + startTop / 10);
|
|
46
|
+
let frame: number | undefined;
|
|
47
|
+
let settled = false;
|
|
48
|
+
const release = () => {
|
|
49
|
+
window.removeEventListener("wheel", finish);
|
|
50
|
+
window.removeEventListener("touchstart", finish);
|
|
51
|
+
window.removeEventListener("keydown", finish);
|
|
52
|
+
clearTimeout(safety);
|
|
53
|
+
if (stopActive === finish) stopActive = undefined;
|
|
54
|
+
};
|
|
55
|
+
const finish = () => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
59
|
+
release();
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
// A wheel/touch/key press means the reader reclaimed the scroll; stop at
|
|
63
|
+
// the frame already rendered instead of fighting the gesture.
|
|
64
|
+
const safety = setTimeout(finish, GLIDE_SAFETY_MS);
|
|
65
|
+
stopActive = finish;
|
|
66
|
+
window.addEventListener("wheel", finish, { passive: true, once: true });
|
|
67
|
+
window.addEventListener("touchstart", finish, { passive: true, once: true });
|
|
68
|
+
window.addEventListener("keydown", finish, { once: true });
|
|
69
|
+
|
|
70
|
+
const begin = performance.now();
|
|
71
|
+
let frames = 0;
|
|
72
|
+
const step = (now: number) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
// Frame ceiling: a host that invokes rAF synchronously must not turn
|
|
75
|
+
// this loop into unbounded recursion. Real browsers reach the safety
|
|
76
|
+
// timeout first; twenty frames cover the 320ms budget at 60Hz.
|
|
77
|
+
if (++frames > 240) {
|
|
78
|
+
finish();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const progress = Math.min(1, Math.max(0, (now - begin) / duration));
|
|
82
|
+
// Quadratic ease-out: reads as a scroll rather than a snap, and still
|
|
83
|
+
// spends most of its time near the top where the article begins.
|
|
84
|
+
const remaining = (1 - progress) ** 2;
|
|
85
|
+
element.scrollTop = Math.round(startTop * remaining);
|
|
86
|
+
element.scrollLeft = Math.round(startLeft * remaining);
|
|
87
|
+
if (progress < 1) frame = requestAnimationFrame(step);
|
|
88
|
+
else finish();
|
|
89
|
+
};
|
|
90
|
+
frame = requestAnimationFrame(step);
|
|
91
|
+
return { promise, cancel: finish };
|
|
92
|
+
}
|
|
93
|
+
|
|
7
94
|
/** Native scrolling with bounded completion and explicit listener cleanup. */
|
|
8
95
|
export function scrollPage(
|
|
9
96
|
element: Element,
|
|
@@ -12,7 +12,7 @@ import "./navigation-client";
|
|
|
12
12
|
import { prepareNavigationScripts } from "./navigation-scripts";
|
|
13
13
|
import { prepareRouteMotion, type RouteMotion } from "./route-motion";
|
|
14
14
|
import { animateRouteOut } from "./route-out";
|
|
15
|
-
import { cancelPageScroll, scrollPage } from "./smooth-scroll";
|
|
15
|
+
import { cancelPageScroll, glideToTop, type PageGlide, scrollPage } from "./smooth-scroll";
|
|
16
16
|
|
|
17
17
|
interface NavigationContext {
|
|
18
18
|
controller: AbortController;
|
|
@@ -27,6 +27,7 @@ interface NavigationContext {
|
|
|
27
27
|
executeScripts?: (signal?: AbortSignal) => Promise<void>;
|
|
28
28
|
scriptsReady?: Promise<void>;
|
|
29
29
|
obsoleteStyles: Element[];
|
|
30
|
+
glide?: PageGlide;
|
|
30
31
|
releaseOutgoing?: () => void;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -251,12 +252,17 @@ export function installSwupRouter(): Swup {
|
|
|
251
252
|
const next = visit.to.document;
|
|
252
253
|
if (!context || !next || !replace) throw new Error("Missing Swup document");
|
|
253
254
|
if (context.controller.signal.aborted) return;
|
|
254
|
-
//
|
|
255
|
+
// Two cues overlap on the outgoing document: a bounded fade on the
|
|
256
|
+
// changing content region, and the whole page walking back to the top.
|
|
257
|
+
// The frame, sidebars and header are never part of either one.
|
|
255
258
|
if (context.navigationType !== "traverse" && context.preset !== "none") {
|
|
256
|
-
|
|
259
|
+
const outgoing = animateRouteOut(
|
|
257
260
|
document.querySelector<HTMLElement>("[data-stalux-motion-focus]"),
|
|
258
261
|
context.controller.signal,
|
|
259
262
|
);
|
|
263
|
+
if (!context.to.hash) context.glide = glideToTop();
|
|
264
|
+
await context.glide?.promise;
|
|
265
|
+
context.releaseOutgoing = await outgoing;
|
|
260
266
|
if (context.controller.signal.aborted || visit !== swup.visit) {
|
|
261
267
|
context.releaseOutgoing();
|
|
262
268
|
return;
|
|
@@ -270,6 +276,7 @@ export function installSwupRouter(): Swup {
|
|
|
270
276
|
context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
|
|
271
277
|
if (context.controller.signal.aborted) return;
|
|
272
278
|
await replace(visit, args);
|
|
279
|
+
// The old region is detached; drop any fill effect left on it.
|
|
273
280
|
context.releaseOutgoing?.();
|
|
274
281
|
if (context.controller.signal.aborted) return;
|
|
275
282
|
for (const element of context.obsoleteStyles) element.remove();
|
|
@@ -349,7 +356,7 @@ export function installSwupRouter(): Swup {
|
|
|
349
356
|
const cancel = (visit: Visit) => {
|
|
350
357
|
const context = contexts.get(visit);
|
|
351
358
|
context?.controller.abort();
|
|
352
|
-
context?.
|
|
359
|
+
context?.glide?.cancel();
|
|
353
360
|
context?.motion?.cancel();
|
|
354
361
|
if (context === activeContext) {
|
|
355
362
|
clearMotionState();
|
|
@@ -361,7 +368,7 @@ export function installSwupRouter(): Swup {
|
|
|
361
368
|
swup.hooks.on("visit:fail", cancel);
|
|
362
369
|
swup.hooks.on("visit:end", (visit) => {
|
|
363
370
|
const context = contexts.get(visit);
|
|
364
|
-
context?.
|
|
371
|
+
context?.glide?.cancel();
|
|
365
372
|
context?.motion?.start(null, { skip: true });
|
|
366
373
|
if (activeContext === context) {
|
|
367
374
|
clearMotionState();
|