@xingwangzhe/stalux 1.44.0 → 1.45.1
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/route-motion.ts +153 -23
- package/src/scripts/route-out.ts +42 -17
- package/src/scripts/smooth-scroll.ts +87 -0
- package/src/scripts/swup-router.ts +31 -8
- package/src/styles/generated-words.css +1 -1
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.
|
|
@@ -12,10 +12,12 @@ export interface RouteMotionEvent {
|
|
|
12
12
|
|
|
13
13
|
export interface RouteMotionOptions {
|
|
14
14
|
skip?: boolean;
|
|
15
|
-
/**
|
|
15
|
+
/** Animate the route's own content stage instead of a bounded heading area. */
|
|
16
16
|
content?: boolean;
|
|
17
17
|
preset?: RouteMotionPreset;
|
|
18
18
|
browserMotion?: boolean;
|
|
19
|
+
/** Releases the outgoing fill effect inside the entry cue's sync block. */
|
|
20
|
+
releaseOutgoing?: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface RouteMotion {
|
|
@@ -48,13 +50,102 @@ const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion>
|
|
|
48
50
|
error: { opacity: 0.9, duration: 80 },
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
const MAIN = "main[data-stalux-route]";
|
|
54
|
+
const ARTICLE = "article.stalux-article";
|
|
55
|
+
// Authored per page type by the theme, not inferred from layout: the article
|
|
56
|
+
// body, archive list, tag cloud, post list, word list, link list, home block.
|
|
57
|
+
const CONTENT = "[data-stalux-motion-content]";
|
|
58
|
+
// A persist node marks the three-column article shell. Those sidebars keep
|
|
59
|
+
// their identity across visits and must never travel with a route cue.
|
|
60
|
+
const PERSIST = "[data-swup-persist]";
|
|
61
|
+
|
|
62
|
+
interface StageMotion {
|
|
63
|
+
opacity: number;
|
|
64
|
+
/** Enter offset in px. Positive y enters from below, positive x from the right. */
|
|
65
|
+
distance: number;
|
|
66
|
+
axis: "x" | "y";
|
|
67
|
+
duration: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One entry per route kind, because the components are not interchangeable: an
|
|
71
|
+
// article opens from below, the next article slides in from the side that the
|
|
72
|
+
// reader is heading toward, and going back up to an index pulls away upward.
|
|
73
|
+
const stageMotions: Record<Exclude<RouteMotionPreset, "none">, StageMotion> = {
|
|
74
|
+
// Article-to-article keeps a lateral direction: previous/next already says
|
|
75
|
+
// which way the reading order goes, so the cue repeats that, not a fade.
|
|
76
|
+
previous: { opacity: 0, distance: -36, axis: "x", duration: 300 },
|
|
77
|
+
next: { opacity: 0, distance: 36, axis: "x", duration: 300 },
|
|
78
|
+
// Entering a different article without a reading-direction trigger.
|
|
79
|
+
article: { opacity: 0, distance: 40, axis: "y", duration: 300 },
|
|
80
|
+
// Reaching an article from any list or the home block.
|
|
81
|
+
enter: { opacity: 0, distance: 56, axis: "y", duration: 340 },
|
|
82
|
+
home: { opacity: 0, distance: 48, axis: "y", duration: 320 },
|
|
83
|
+
// Leaving an article for the archive or a taxonomy index.
|
|
84
|
+
up: { opacity: 0, distance: -44, axis: "y", duration: 300 },
|
|
85
|
+
// Moving between two taxonomy detail pages.
|
|
86
|
+
taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 280 },
|
|
87
|
+
// About / links / words: single-panel pages.
|
|
88
|
+
panel: { opacity: 0, distance: 44, axis: "y", duration: 300 },
|
|
89
|
+
glide: { opacity: 0, distance: 40, axis: "y", duration: 280 },
|
|
90
|
+
// History traversal restores what the reader already saw.
|
|
91
|
+
restore: { opacity: 0, distance: -36, axis: "y", duration: 280 },
|
|
92
|
+
error: { opacity: 0, distance: 28, axis: "y", duration: 260 },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const laidOut = (element: HTMLElement): boolean => {
|
|
96
|
+
const rect = element.getBoundingClientRect();
|
|
97
|
+
return rect.width > 0 && rect.height > 0;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The single node a route cue may animate for the current document.
|
|
102
|
+
*
|
|
103
|
+
* Picks the largest authored content stage, so an article moves its own column
|
|
104
|
+
* and an archive moves its list, while sidebars (persisted shells), the header
|
|
105
|
+
* and the footer stay untouched. Falls back to `main` only for pages without a
|
|
106
|
+
* persisted shell inside it.
|
|
107
|
+
*/
|
|
108
|
+
export function getRouteStage(): HTMLElement | null {
|
|
109
|
+
const main = document.querySelector<HTMLElement>(MAIN);
|
|
110
|
+
if (!main?.isConnected) return null;
|
|
111
|
+
const candidates: HTMLElement[] = [];
|
|
112
|
+
const article = main.querySelector<HTMLElement>(ARTICLE);
|
|
113
|
+
if (article) candidates.push(article);
|
|
114
|
+
for (const element of main.querySelectorAll<HTMLElement>(CONTENT)) candidates.push(element);
|
|
115
|
+
|
|
116
|
+
let stage: HTMLElement | null = null;
|
|
117
|
+
let area = 0;
|
|
118
|
+
for (const element of candidates) {
|
|
119
|
+
// A stage inside a persisted subtree is the shell itself.
|
|
120
|
+
if (!element.isConnected || element.closest(PERSIST)) continue;
|
|
121
|
+
const rect = element.getBoundingClientRect();
|
|
122
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
123
|
+
const current = rect.width * rect.height;
|
|
124
|
+
if (current > area) {
|
|
125
|
+
area = current;
|
|
126
|
+
stage = element;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (stage) return stage;
|
|
130
|
+
if (main.querySelector(PERSIST)) return null;
|
|
131
|
+
return laidOut(main) ? main : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Offset applied to the outgoing document, continuing the entry direction. */
|
|
135
|
+
export function getStageExit(preset: RouteMotionPreset): StageMotion | null {
|
|
136
|
+
if (preset === "none") return null;
|
|
137
|
+
const motion = stageMotions[preset];
|
|
138
|
+
// Entering from below means leaving upward, so both halves read as one
|
|
139
|
+
// continuous flow instead of an enter/leave bounce.
|
|
140
|
+
return { ...motion, distance: -Math.round(motion.distance * 0.72) };
|
|
141
|
+
}
|
|
142
|
+
|
|
51
143
|
const motions = new WeakMap<object, RouteMotion>();
|
|
52
144
|
const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
|
|
53
145
|
Promise.resolve(promise).then(
|
|
54
146
|
() => undefined,
|
|
55
147
|
() => undefined,
|
|
56
148
|
);
|
|
57
|
-
|
|
58
149
|
function completion() {
|
|
59
150
|
let resolve = () => {};
|
|
60
151
|
const promise = new Promise<void>((done) => {
|
|
@@ -149,41 +240,80 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
149
240
|
start(heading, options) {
|
|
150
241
|
if (started) return;
|
|
151
242
|
started = true;
|
|
243
|
+
// Every exit path must release the outgoing fill, or the swapped
|
|
244
|
+
// document stays transparent behind a cancelled cue.
|
|
245
|
+
const release = options?.releaseOutgoing;
|
|
152
246
|
try {
|
|
153
247
|
const preset = options?.preset ?? "glide";
|
|
154
248
|
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
155
|
-
const target = getRouteMotionTarget(heading);
|
|
156
249
|
if (
|
|
157
|
-
!target ||
|
|
158
|
-
preset === "none" ||
|
|
159
|
-
(options?.content && preset === "restore") ||
|
|
160
250
|
options?.skip ||
|
|
161
251
|
options?.browserMotion ||
|
|
252
|
+
preset === "none" ||
|
|
253
|
+
(options?.content && preset === "restore") ||
|
|
254
|
+
// History never replays an entry on a phone: the reader is
|
|
255
|
+
// returning to a position, not arriving somewhere new.
|
|
256
|
+
(mobile && preset === "restore") ||
|
|
162
257
|
document.hidden ||
|
|
163
|
-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
164
|
-
typeof target.animate !== "function" ||
|
|
165
|
-
(mobile && preset === "restore")
|
|
258
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
166
259
|
) {
|
|
260
|
+
release?.();
|
|
261
|
+
animationDone.resolve();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const stage = options?.content ? getRouteStage() : null;
|
|
265
|
+
const target = stage ?? getRouteMotionTarget(heading);
|
|
266
|
+
if (!target || typeof target.animate !== "function") {
|
|
267
|
+
release?.();
|
|
167
268
|
animationDone.resolve();
|
|
168
269
|
return;
|
|
169
270
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
271
|
+
// Cancelling the outgoing fill and starting the entry cue must
|
|
272
|
+
// share one synchronous block. Split across frames, the reader
|
|
273
|
+
// sees the new document at full opacity for a frame first.
|
|
274
|
+
release?.();
|
|
275
|
+
if (stage) {
|
|
276
|
+
const spec = stageMotions[preset];
|
|
277
|
+
// Phones get a shorter throw; the same offset reads as a much
|
|
278
|
+
// larger move on a narrow viewport.
|
|
279
|
+
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
280
|
+
const from =
|
|
281
|
+
spec.axis === "x"
|
|
282
|
+
? `translate3d(${distance}px, 0, 0)`
|
|
283
|
+
: `translate3d(0, ${distance}px, 0)`;
|
|
284
|
+
animation = stage.animate(
|
|
285
|
+
{
|
|
286
|
+
opacity: [spec.opacity, 1],
|
|
287
|
+
transform: [from, "translate3d(0, 0, 0)"],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
|
|
291
|
+
// Strong ease-out: quick arrival, soft settle. The
|
|
292
|
+
// built-in ease-out is too weak to read at this size.
|
|
293
|
+
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
294
|
+
fill: "both",
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
299
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
300
|
+
// Phones use a pure fade. Explicit previous/next alone imply a
|
|
301
|
+
// lateral reading direction; history never replays the entry.
|
|
302
|
+
if (!mobile && (spec.x || spec.y)) {
|
|
303
|
+
keyframes.transform = [
|
|
304
|
+
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
305
|
+
"translate3d(0px, 0px, 0)",
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
animation = target.animate(keyframes, {
|
|
309
|
+
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
310
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
311
|
+
fill: "both",
|
|
312
|
+
});
|
|
179
313
|
}
|
|
180
|
-
animation = target.animate(keyframes, {
|
|
181
|
-
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
182
|
-
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
183
|
-
fill: "both",
|
|
184
|
-
});
|
|
185
314
|
void animation.finished.then(clearAnimation, clearAnimation);
|
|
186
315
|
} catch {
|
|
316
|
+
release?.();
|
|
187
317
|
clearAnimation();
|
|
188
318
|
} finally {
|
|
189
319
|
installed.resolve();
|
package/src/scripts/route-out.ts
CHANGED
|
@@ -1,37 +1,62 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RouteMotionPreset } from "../utils/route-motion-policy";
|
|
2
|
+
import { getRouteStage, getStageExit } from "./route-motion";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Fade and shift the current content stage out, continuing the entry direction
|
|
6
|
+
* so the two halves read as one movement instead of a flash.
|
|
7
|
+
*
|
|
8
|
+
* The returned release keeps the fill effect until the entry cue takes over.
|
|
9
|
+
* Cancelling here would expose the freshly swapped document at full opacity for
|
|
10
|
+
* a frame before it animates in — the exact flash this replaces.
|
|
11
|
+
*/
|
|
4
12
|
export async function animateRouteOut(
|
|
5
|
-
|
|
13
|
+
preset: RouteMotionPreset,
|
|
6
14
|
signal: AbortSignal,
|
|
7
15
|
): Promise<() => void> {
|
|
16
|
+
const spec = getStageExit(preset);
|
|
17
|
+
const stage = spec ? getRouteStage() : null;
|
|
8
18
|
if (
|
|
9
|
-
!
|
|
19
|
+
!spec ||
|
|
20
|
+
!stage ||
|
|
10
21
|
signal.aborted ||
|
|
11
22
|
document.hidden ||
|
|
23
|
+
typeof stage.animate !== "function" ||
|
|
12
24
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
13
25
|
)
|
|
14
26
|
return () => {};
|
|
15
|
-
const target = getRouteMotionTarget(element);
|
|
16
|
-
if (!target || typeof target.animate !== "function") return () => {};
|
|
17
|
-
let animation: Animation | undefined;
|
|
18
|
-
const cancel = () => animation?.cancel();
|
|
19
27
|
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
|
28
|
+
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
29
|
+
const to =
|
|
30
|
+
spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
|
|
31
|
+
let animation: Animation | undefined;
|
|
32
|
+
const cancel = () => {
|
|
33
|
+
const active = animation;
|
|
34
|
+
animation = undefined;
|
|
35
|
+
try {
|
|
36
|
+
active?.cancel();
|
|
37
|
+
} catch {
|
|
38
|
+
/* An already detached effect cannot be released further. */
|
|
39
|
+
}
|
|
40
|
+
};
|
|
20
41
|
try {
|
|
21
|
-
animation =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
:
|
|
25
|
-
|
|
42
|
+
animation = stage.animate(
|
|
43
|
+
{ opacity: [1, 0], transform: ["translate3d(0, 0, 0)", to] },
|
|
44
|
+
{
|
|
45
|
+
duration: mobile
|
|
46
|
+
? Math.round(spec.duration * 0.62)
|
|
47
|
+
: Math.round(spec.duration * 0.58),
|
|
48
|
+
// Strong ease-in: the outgoing document accelerates away. An exit
|
|
49
|
+
// is the one place ease-in is correct; entrances use ease-out.
|
|
50
|
+
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
51
|
+
fill: "forwards",
|
|
52
|
+
},
|
|
26
53
|
);
|
|
27
54
|
signal.addEventListener("abort", cancel, { once: true });
|
|
28
55
|
await animation.finished;
|
|
29
|
-
//
|
|
30
|
-
//
|
|
56
|
+
// The stage node survives the swap, so this filled effect is what holds
|
|
57
|
+
// the incoming document hidden until its own cue starts.
|
|
31
58
|
} catch {
|
|
32
59
|
/* Cancellation and unsupported animation must not block a visit. */
|
|
33
|
-
} finally {
|
|
34
|
-
if (signal.aborted) cancel();
|
|
35
60
|
}
|
|
36
61
|
return () => {
|
|
37
62
|
signal.removeEventListener("abort", cancel);
|
|
@@ -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
|
|
|
@@ -59,6 +60,16 @@ function clearMotionState(): void {
|
|
|
59
60
|
delete document.documentElement.dataset[key];
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** The exit fill is released by whichever path finishes first; never twice. */
|
|
64
|
+
function once(effect: () => void): () => void {
|
|
65
|
+
let done = false;
|
|
66
|
+
return () => {
|
|
67
|
+
if (done) return;
|
|
68
|
+
done = true;
|
|
69
|
+
effect();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
/** Astro renders pages; Swup alone owns their client navigation and animation. */
|
|
63
74
|
export function installSwupRouter(): Swup {
|
|
64
75
|
if (window.swup) return window.swup;
|
|
@@ -132,6 +143,7 @@ export function installSwupRouter(): Swup {
|
|
|
132
143
|
content: true,
|
|
133
144
|
browserMotion: context.browserMotion,
|
|
134
145
|
skip: context.controller.signal.aborted,
|
|
146
|
+
releaseOutgoing: context.releaseOutgoing,
|
|
135
147
|
},
|
|
136
148
|
);
|
|
137
149
|
await context.motion.finished;
|
|
@@ -251,12 +263,14 @@ export function installSwupRouter(): Swup {
|
|
|
251
263
|
const next = visit.to.document;
|
|
252
264
|
if (!context || !next || !replace) throw new Error("Missing Swup document");
|
|
253
265
|
if (context.controller.signal.aborted) return;
|
|
254
|
-
//
|
|
266
|
+
// Two cues overlap on the outgoing document: the content stage leaving,
|
|
267
|
+
// and the page walking back to the top. Persisted sidebars, the header
|
|
268
|
+
// and the frame are never part of either one.
|
|
255
269
|
if (context.navigationType !== "traverse" && context.preset !== "none") {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
270
|
+
const outgoing = animateRouteOut(context.preset, context.controller.signal);
|
|
271
|
+
if (!context.to.hash) context.glide = glideToTop();
|
|
272
|
+
await context.glide?.promise;
|
|
273
|
+
context.releaseOutgoing = once(await outgoing);
|
|
260
274
|
if (context.controller.signal.aborted || visit !== swup.visit) {
|
|
261
275
|
context.releaseOutgoing();
|
|
262
276
|
return;
|
|
@@ -270,7 +284,10 @@ export function installSwupRouter(): Swup {
|
|
|
270
284
|
context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
|
|
271
285
|
if (context.controller.signal.aborted) return;
|
|
272
286
|
await replace(visit, args);
|
|
273
|
-
|
|
287
|
+
// The stage node survives the swap, so the filled exit effect still holds
|
|
288
|
+
// the incoming document at zero opacity. It is released inside
|
|
289
|
+
// motion.start, in the same synchronous block as the entry cue: releasing
|
|
290
|
+
// it here would paint the new document fully visible for one frame.
|
|
274
291
|
if (context.controller.signal.aborted) return;
|
|
275
292
|
for (const element of context.obsoleteStyles) element.remove();
|
|
276
293
|
const incomingStyles = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
|
|
@@ -333,6 +350,7 @@ export function installSwupRouter(): Swup {
|
|
|
333
350
|
content: true,
|
|
334
351
|
skip: !visit.animation.animate || context.controller.signal.aborted,
|
|
335
352
|
browserMotion: context.browserMotion,
|
|
353
|
+
releaseOutgoing: context.releaseOutgoing,
|
|
336
354
|
});
|
|
337
355
|
}
|
|
338
356
|
|
|
@@ -342,13 +360,17 @@ export function installSwupRouter(): Swup {
|
|
|
342
360
|
await context.scriptsReady;
|
|
343
361
|
if (context.controller.signal.aborted) return;
|
|
344
362
|
dispatchNavigation("astro:page-load", context);
|
|
345
|
-
if (!visit.animation.animate)
|
|
363
|
+
if (!visit.animation.animate)
|
|
364
|
+
context.motion?.start(null, { skip: true, releaseOutgoing: context.releaseOutgoing });
|
|
346
365
|
});
|
|
347
366
|
swup.hooks.replace("link:self", () => undefined);
|
|
348
367
|
|
|
349
368
|
const cancel = (visit: Visit) => {
|
|
350
369
|
const context = contexts.get(visit);
|
|
351
370
|
context?.controller.abort();
|
|
371
|
+
context?.glide?.cancel();
|
|
372
|
+
// An aborted visit keeps no exit fill: without this the stage would stay
|
|
373
|
+
// transparent until the next successful navigation.
|
|
352
374
|
context?.releaseOutgoing?.();
|
|
353
375
|
context?.motion?.cancel();
|
|
354
376
|
if (context === activeContext) {
|
|
@@ -361,6 +383,7 @@ export function installSwupRouter(): Swup {
|
|
|
361
383
|
swup.hooks.on("visit:fail", cancel);
|
|
362
384
|
swup.hooks.on("visit:end", (visit) => {
|
|
363
385
|
const context = contexts.get(visit);
|
|
386
|
+
context?.glide?.cancel();
|
|
364
387
|
context?.releaseOutgoing?.();
|
|
365
388
|
context?.motion?.start(null, { skip: true });
|
|
366
389
|
if (activeContext === context) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
@layer theme, base, components, utilities;
|
|
2
|
-
@layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;
|
|
2
|
+
@layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;width:100%;max-width:900px;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;min-width:0;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border-left:2px solid var(--accent-50p);background:rgb(var(--stalux-accent-rgb) / .045);backdrop-filter:blur(3px);overflow-wrap:anywhere;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
|
|
3
3
|
/*$vite$:1*/
|