@xingwangzhe/stalux 1.42.0 → 1.43.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/README.md +7 -5
- package/package.json +7 -1
- package/src/components/stalux/archives/archivesList.astro +0 -2
- package/src/components/stalux/archives/toArchive.astro +1 -5
- package/src/components/stalux/common/Typewriter.astro +1 -7
- package/src/components/stalux/common/author.astro +1 -6
- package/src/components/stalux/common/date.astro +1 -11
- package/src/components/stalux/layout/PageHeading.astro +2 -11
- package/src/components/stalux/layout/footer.astro +1 -6
- package/src/components/stalux/layout/head.astro +0 -2
- package/src/components/stalux/links/socialMedia.astro +1 -10
- package/src/components/stalux/posts/ArticleSummary.astro +2 -8
- package/src/components/stalux/posts/PostTaxonomy.astro +3 -14
- package/src/components/stalux/posts/postCC.astro +2 -2
- package/src/components/stalux/posts/postCard.astro +1 -5
- package/src/components/stalux/posts/postContent.astro +4 -23
- package/src/components/stalux/posts/postLeft.astro +4 -22
- package/src/components/stalux/posts/postRight.astro +5 -27
- package/src/components/stalux/posts/postToc.astro +3 -10
- package/src/components/stalux/posts/randomPost.astro +0 -1
- package/src/components/stalux/tags/tagHeader.astro +1 -5
- package/src/index.ts +20 -1
- package/src/internal/style-names.generated.ts +59 -69
- package/src/layouts/PostLayout.astro +1 -5
- package/src/layouts/Stalux.astro +3 -19
- package/src/pages/index.astro +1 -5
- package/src/scripts/navigation-client.ts +13 -0
- package/src/scripts/navigation-scripts.ts +191 -0
- package/src/scripts/route-motion.ts +82 -11
- package/src/scripts/route-out.ts +33 -0
- package/src/scripts/smooth-scroll.ts +52 -0
- package/src/scripts/swup-router.ts +381 -0
- package/src/scripts/tags-cloud.ts +1 -1
- package/src/scripts/view-transitions.ts +3 -233
- package/src/scripts/word-list.ts +9 -3
- package/src/styles/generated-words.css +1 -1
- package/src/styles/generated.css +1 -1
- package/src/styles/motion.css +7 -211
- package/src/utils/toc-scrollspy.ts +21 -14
- package/src/scripts/archive-reveal.ts +0 -76
|
@@ -290,80 +290,70 @@ export const styleNames: { classes: Record<string, string>; variables: Record<st
|
|
|
290
290
|
"--stalux-site-header-height": "--s1q",
|
|
291
291
|
"--stalux-site-header-min-height": "--s1r",
|
|
292
292
|
"--stalux-text-shadow": "--s1s",
|
|
293
|
-
"--
|
|
294
|
-
"--
|
|
295
|
-
"--
|
|
296
|
-
"--
|
|
297
|
-
"--
|
|
298
|
-
"--
|
|
299
|
-
"--
|
|
300
|
-
"--
|
|
301
|
-
"--
|
|
302
|
-
"--
|
|
303
|
-
"--
|
|
304
|
-
"--
|
|
305
|
-
"--
|
|
306
|
-
"--
|
|
307
|
-
"--
|
|
308
|
-
"--
|
|
309
|
-
"--
|
|
310
|
-
"--
|
|
311
|
-
"--
|
|
312
|
-
"--
|
|
313
|
-
"--
|
|
314
|
-
"--un-
|
|
315
|
-
"--un-
|
|
316
|
-
"--un-
|
|
317
|
-
"--
|
|
318
|
-
"--
|
|
319
|
-
"--
|
|
320
|
-
"--
|
|
321
|
-
"--
|
|
322
|
-
"--
|
|
323
|
-
"--
|
|
324
|
-
"--
|
|
325
|
-
"--
|
|
326
|
-
"--
|
|
327
|
-
"--
|
|
328
|
-
"--white-
|
|
329
|
-
"--white-
|
|
330
|
-
"--white-
|
|
331
|
-
"--white-
|
|
332
|
-
"--white-
|
|
333
|
-
"--white-
|
|
334
|
-
"--white-60p": "--s2y",
|
|
335
|
-
"--white-70p": "--s2z",
|
|
336
|
-
"--white-75p": "--s30",
|
|
337
|
-
"--white-78p": "--s31",
|
|
338
|
-
"--white-80p": "--s32",
|
|
339
|
-
"--white-85p": "--s33",
|
|
340
|
-
"--white-90p": "--s34",
|
|
341
|
-
"--white-95p": "--s35",
|
|
342
|
-
"--white-full": "--s36"
|
|
293
|
+
"--surface-glass": "--s1t",
|
|
294
|
+
"--surface-glass-shadow": "--s1u",
|
|
295
|
+
"--tag-bg": "--s1v",
|
|
296
|
+
"--tag-bg-hover": "--s1w",
|
|
297
|
+
"--tl-line": "--s1x",
|
|
298
|
+
"--tl-pad": "--s1y",
|
|
299
|
+
"--transition-fast": "--s1z",
|
|
300
|
+
"--transition-medium": "--s20",
|
|
301
|
+
"--transition-normal": "--s21",
|
|
302
|
+
"--transition-slow": "--s22",
|
|
303
|
+
"--transition-slower": "--s23",
|
|
304
|
+
"--transition-slowest": "--s24",
|
|
305
|
+
"--un-rotate": "--s25",
|
|
306
|
+
"--un-rotate-x": "--s26",
|
|
307
|
+
"--un-rotate-y": "--s27",
|
|
308
|
+
"--un-rotate-z": "--s28",
|
|
309
|
+
"--un-scale-x": "--s29",
|
|
310
|
+
"--un-scale-y": "--s2a",
|
|
311
|
+
"--un-scale-z": "--s2b",
|
|
312
|
+
"--un-skew-x": "--s2c",
|
|
313
|
+
"--un-skew-y": "--s2d",
|
|
314
|
+
"--un-translate-x": "--s2e",
|
|
315
|
+
"--un-translate-y": "--s2f",
|
|
316
|
+
"--un-translate-z": "--s2g",
|
|
317
|
+
"--updated-bg": "--s2h",
|
|
318
|
+
"--updated-border": "--s2i",
|
|
319
|
+
"--white-05p": "--s2j",
|
|
320
|
+
"--white-10p": "--s2k",
|
|
321
|
+
"--white-15p": "--s2l",
|
|
322
|
+
"--white-20p": "--s2m",
|
|
323
|
+
"--white-30p": "--s2n",
|
|
324
|
+
"--white-50p": "--s2o",
|
|
325
|
+
"--white-60p": "--s2p",
|
|
326
|
+
"--white-70p": "--s2q",
|
|
327
|
+
"--white-75p": "--s2r",
|
|
328
|
+
"--white-78p": "--s2s",
|
|
329
|
+
"--white-80p": "--s2t",
|
|
330
|
+
"--white-85p": "--s2u",
|
|
331
|
+
"--white-90p": "--s2v",
|
|
332
|
+
"--white-95p": "--s2w",
|
|
333
|
+
"--white-full": "--s2x"
|
|
343
334
|
},
|
|
344
335
|
"animations": {
|
|
345
336
|
"fadeInUpComment": "k0",
|
|
346
337
|
"spin": "k1",
|
|
347
338
|
"stalux-article-enter": "k2",
|
|
348
339
|
"stalux-article-enter-back": "k3",
|
|
349
|
-
"stalux-
|
|
350
|
-
"stalux-
|
|
351
|
-
"stalux-
|
|
352
|
-
"stalux-page-enter": "k7",
|
|
353
|
-
"stalux-page-
|
|
354
|
-
"stalux-
|
|
355
|
-
"stalux-
|
|
356
|
-
"stalux-sidebar-left-back-
|
|
357
|
-
"stalux-sidebar-left-
|
|
358
|
-
"stalux-sidebar-left-
|
|
359
|
-
"stalux-sidebar-
|
|
360
|
-
"stalux-sidebar-right-back-
|
|
361
|
-
"stalux-sidebar-right-
|
|
362
|
-
"stalux-sidebar-right-
|
|
363
|
-
"stalux-
|
|
364
|
-
"stalux-
|
|
365
|
-
"stalux-taxonomy-enter": "kk",
|
|
366
|
-
"stalux-
|
|
367
|
-
"stalux-word-word-glow": "km"
|
|
340
|
+
"stalux-cursor-blink": "k4",
|
|
341
|
+
"stalux-link-avatar-spin": "k5",
|
|
342
|
+
"stalux-page-enter": "k6",
|
|
343
|
+
"stalux-page-enter-back": "k7",
|
|
344
|
+
"stalux-page-exit": "k8",
|
|
345
|
+
"stalux-random-spin": "k9",
|
|
346
|
+
"stalux-sidebar-left-back-in": "ka",
|
|
347
|
+
"stalux-sidebar-left-back-out": "kb",
|
|
348
|
+
"stalux-sidebar-left-in": "kc",
|
|
349
|
+
"stalux-sidebar-left-out": "kd",
|
|
350
|
+
"stalux-sidebar-right-back-in": "ke",
|
|
351
|
+
"stalux-sidebar-right-back-out": "kf",
|
|
352
|
+
"stalux-sidebar-right-in": "kg",
|
|
353
|
+
"stalux-sidebar-right-out": "kh",
|
|
354
|
+
"stalux-social-stalux-social-arrive": "ki",
|
|
355
|
+
"stalux-taxonomy-enter": "kj",
|
|
356
|
+
"stalux-taxonomy-enter-back": "kk",
|
|
357
|
+
"stalux-word-word-glow": "kl"
|
|
368
358
|
}
|
|
369
359
|
};
|
|
@@ -52,11 +52,7 @@ const walineConfig = walineSection?.serverURL
|
|
|
52
52
|
---
|
|
53
53
|
|
|
54
54
|
<div class="stalux-post-page">
|
|
55
|
-
<div
|
|
56
|
-
class="stalux-post-layout"
|
|
57
|
-
transition:name="stalux-article-layout"
|
|
58
|
-
transition:animate="none"
|
|
59
|
-
>
|
|
55
|
+
<div class="stalux-post-layout">
|
|
60
56
|
<!-- Left Sidebar -->
|
|
61
57
|
<PostLeft author={author} authorAvatar={props.authorAvatar} />
|
|
62
58
|
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -91,20 +91,10 @@ const routeKind = getRouteKind(Astro.url.pathname);
|
|
|
91
91
|
data-stalux-umami-id={headConfig?.umami?.id}
|
|
92
92
|
data-stalux-umami-url={headConfig?.umami?.url}
|
|
93
93
|
>
|
|
94
|
-
<header
|
|
95
|
-
class="stalux-site-header"
|
|
96
|
-
data-stalux-font-group="header"
|
|
97
|
-
transition:name="stalux-site-header"
|
|
98
|
-
transition:animate="none"
|
|
99
|
-
transition:persist="stalux-site-header"
|
|
100
|
-
>
|
|
94
|
+
<header class="stalux-site-header" data-stalux-font-group="header">
|
|
101
95
|
<Navs />
|
|
102
96
|
</header>
|
|
103
|
-
<div
|
|
104
|
-
data-stalux-scroll-sentinel
|
|
105
|
-
transition:persist="stalux-scroll-sentinel"
|
|
106
|
-
aria-hidden="true"
|
|
107
|
-
></div>
|
|
97
|
+
<div data-stalux-scroll-sentinel aria-hidden="true"></div>
|
|
108
98
|
{llmPromote && (
|
|
109
99
|
<section class="stalux-ai-prompt" data-stalux-ai-prompt aria-label="AI site context">
|
|
110
100
|
<p>{llmPromote}</p>
|
|
@@ -114,15 +104,9 @@ const routeKind = getRouteKind(Astro.url.pathname);
|
|
|
114
104
|
class="bg-layer bg-a"
|
|
115
105
|
data-stalux-background="a"
|
|
116
106
|
style="opacity: 1;"
|
|
117
|
-
transition:persist="stalux-bg-a"
|
|
118
|
-
aria-hidden="true"
|
|
119
|
-
></div>
|
|
120
|
-
<div
|
|
121
|
-
class="bg-layer bg-b"
|
|
122
|
-
data-stalux-background="b"
|
|
123
|
-
transition:persist="stalux-bg-b"
|
|
124
107
|
aria-hidden="true"
|
|
125
108
|
></div>
|
|
109
|
+
<div class="bg-layer bg-b" data-stalux-background="b" aria-hidden="true"></div>
|
|
126
110
|
<main class="stalux-main" data-stalux-route={routeKind}>
|
|
127
111
|
<slot />
|
|
128
112
|
</main>
|
package/src/pages/index.astro
CHANGED
|
@@ -20,11 +20,7 @@ import { getSiteData } from "@utils/config-utils";
|
|
|
20
20
|
|
|
21
21
|
<Stalux title={siteTitle} description={siteDescription}>
|
|
22
22
|
<div class="stalux-home-body stalux-u-flex stalux-u-items-center stalux-u-flex-col">
|
|
23
|
-
<h1
|
|
24
|
-
class="stalux-home-title stalux-u-text-center"
|
|
25
|
-
data-stalux-motion-focus
|
|
26
|
-
transition:animate="none"
|
|
27
|
-
>
|
|
23
|
+
<h1 class="stalux-home-title stalux-u-text-center" data-stalux-motion-focus>
|
|
28
24
|
{siteTitle}
|
|
29
25
|
</h1>
|
|
30
26
|
<h2
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type Swup from "swup";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
swup?: Swup;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Programmatic theme navigation uses the same controller as ordinary links. */
|
|
10
|
+
export function navigate(href: string): void {
|
|
11
|
+
if (window.swup) window.swup.navigate(href);
|
|
12
|
+
else window.location.assign(href);
|
|
13
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createClientLogger } from "./logger";
|
|
2
|
+
|
|
3
|
+
const logger = createClientLogger("navigation-scripts");
|
|
4
|
+
const executions = new Map<string, Promise<void>>();
|
|
5
|
+
const executedNodes = new WeakSet<HTMLScriptElement>();
|
|
6
|
+
let seeded = false;
|
|
7
|
+
|
|
8
|
+
interface NavigationScript {
|
|
9
|
+
attributes: { name: string; value: string }[];
|
|
10
|
+
content: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
type: string;
|
|
13
|
+
key: string;
|
|
14
|
+
rerun: boolean;
|
|
15
|
+
head: boolean;
|
|
16
|
+
original: HTMLScriptElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const JAVASCRIPT_TYPES = new Set([
|
|
20
|
+
"",
|
|
21
|
+
"module",
|
|
22
|
+
"text/javascript",
|
|
23
|
+
"application/javascript",
|
|
24
|
+
"text/ecmascript",
|
|
25
|
+
"application/ecmascript",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function describeScript(script: HTMLScriptElement, base: string): NavigationScript | undefined {
|
|
29
|
+
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
30
|
+
if (!JAVASCRIPT_TYPES.has(type) || script.hasAttribute("data-swup-ignore-script")) return;
|
|
31
|
+
if (script.hasAttribute("nomodule") && "noModule" in document.createElement("script")) return;
|
|
32
|
+
const rawSource = script.getAttribute("src");
|
|
33
|
+
let source: string | undefined;
|
|
34
|
+
if (rawSource !== null) {
|
|
35
|
+
try {
|
|
36
|
+
source = new URL(rawSource, base).href;
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const content = script.textContent ?? "";
|
|
42
|
+
return {
|
|
43
|
+
attributes: [...script.attributes].map(({ name, value }) => ({ name, value })),
|
|
44
|
+
content,
|
|
45
|
+
source,
|
|
46
|
+
type,
|
|
47
|
+
key: source ? `src:${source}` : `inline:${type}:${content}`,
|
|
48
|
+
rerun: script.hasAttribute("data-astro-rerun"),
|
|
49
|
+
head: script.parentElement?.tagName === "HEAD",
|
|
50
|
+
original: script,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function seedCurrentScripts(): void {
|
|
55
|
+
if (seeded) return;
|
|
56
|
+
seeded = true;
|
|
57
|
+
for (const script of document.querySelectorAll<HTMLScriptElement>("script")) {
|
|
58
|
+
const descriptor = describeScript(script, document.baseURI);
|
|
59
|
+
if (descriptor) {
|
|
60
|
+
executions.set(descriptor.key, Promise.resolve());
|
|
61
|
+
executedNodes.add(script);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function waitForScript(script: HTMLScriptElement, timeoutMs: number): Promise<boolean> {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
69
|
+
const finish = (loaded: boolean) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
script.removeEventListener("load", loadedHandler);
|
|
72
|
+
script.removeEventListener("error", failedHandler);
|
|
73
|
+
resolve(loaded);
|
|
74
|
+
};
|
|
75
|
+
const loadedHandler = () => finish(true);
|
|
76
|
+
const failedHandler = () => finish(false);
|
|
77
|
+
script.addEventListener("load", loadedHandler, { once: true });
|
|
78
|
+
script.addEventListener("error", failedHandler, { once: true });
|
|
79
|
+
timer = setTimeout(failedHandler, timeoutMs);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function abortable(promise: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
84
|
+
if (!signal) return promise;
|
|
85
|
+
if (signal.aborted) return Promise.resolve();
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const finish = () => {
|
|
88
|
+
signal.removeEventListener("abort", finish);
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
92
|
+
void promise.then(finish, finish);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function executeScript(descriptor: NavigationScript, timeoutMs: number): Promise<void> {
|
|
97
|
+
// HeadPlugin clones incoming head nodes before container replacement. Those
|
|
98
|
+
// scripts are inert, but are different nodes from the parsed document we
|
|
99
|
+
// captured. Replace that clone rather than appending a second head script.
|
|
100
|
+
const inertCopies = [...document.querySelectorAll<HTMLScriptElement>("script")].filter(
|
|
101
|
+
(candidate) =>
|
|
102
|
+
!executedNodes.has(candidate) &&
|
|
103
|
+
describeScript(candidate, document.baseURI)?.key === descriptor.key,
|
|
104
|
+
);
|
|
105
|
+
const existing = executions.get(descriptor.key);
|
|
106
|
+
if (existing && !descriptor.rerun) {
|
|
107
|
+
for (const copy of inertCopies) copy.remove();
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const script = document.createElement("script");
|
|
112
|
+
// Dynamic scripts are async by default. Preserve document order, including
|
|
113
|
+
// the module barrier used below, before copying the page's attributes.
|
|
114
|
+
script.async = false;
|
|
115
|
+
for (const { name, value } of descriptor.attributes) {
|
|
116
|
+
if (name !== "src" && name !== "data-astro-exec") script.setAttribute(name, value);
|
|
117
|
+
}
|
|
118
|
+
if (descriptor.source) script.setAttribute("src", descriptor.source);
|
|
119
|
+
script.textContent = descriptor.content;
|
|
120
|
+
script.setAttribute("data-astro-exec", "");
|
|
121
|
+
executedNodes.add(script);
|
|
122
|
+
|
|
123
|
+
let observed = script;
|
|
124
|
+
if (!descriptor.source && descriptor.type === "module") {
|
|
125
|
+
// Inline module load events are not portable. Astro uses the same
|
|
126
|
+
// ordered external module barrier to wait for deferred inline modules.
|
|
127
|
+
observed = document.createElement("script");
|
|
128
|
+
observed.type = "module";
|
|
129
|
+
observed.async = false;
|
|
130
|
+
observed.src = "data:application/javascript,";
|
|
131
|
+
observed.setAttribute("data-swup-ignore-script", "");
|
|
132
|
+
if (script.nonce) observed.nonce = script.nonce;
|
|
133
|
+
}
|
|
134
|
+
const loaded =
|
|
135
|
+
descriptor.source || descriptor.type === "module"
|
|
136
|
+
? waitForScript(observed, timeoutMs)
|
|
137
|
+
: Promise.resolve(true);
|
|
138
|
+
const execution = loaded.then((success) => {
|
|
139
|
+
if (observed !== script) observed.remove();
|
|
140
|
+
if (!success) {
|
|
141
|
+
// Failed fetches may succeed on a subsequent visit. Do not turn a
|
|
142
|
+
// transient offline response into a session-long disabled feature.
|
|
143
|
+
if (executions.get(descriptor.key) === execution) executions.delete(descriptor.key);
|
|
144
|
+
script.remove();
|
|
145
|
+
logger.warn(`page script failed to load: ${descriptor.source ?? "inline module"}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
executions.set(descriptor.key, execution);
|
|
149
|
+
|
|
150
|
+
const parent = descriptor.head ? document.head : document.body;
|
|
151
|
+
const original =
|
|
152
|
+
descriptor.original.isConnected && descriptor.original.ownerDocument === document
|
|
153
|
+
? descriptor.original
|
|
154
|
+
: inertCopies[0];
|
|
155
|
+
if (original) {
|
|
156
|
+
original.replaceWith(script);
|
|
157
|
+
} else {
|
|
158
|
+
parent.append(script);
|
|
159
|
+
}
|
|
160
|
+
for (const copy of inertCopies) if (copy !== original) copy.remove();
|
|
161
|
+
if (observed !== script) parent.append(observed);
|
|
162
|
+
return execution;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Capture both head and body before Swup replaces the document's containers.
|
|
167
|
+
* Call the returned function after replacement, before astro:page-load. Bundled
|
|
168
|
+
* modules and ordinary inline scripts retain Astro's once-per-session behavior;
|
|
169
|
+
* data-astro-rerun opts a script into every visit. SDK scripts already present in
|
|
170
|
+
* the first document are never reinserted merely because head metadata changed.
|
|
171
|
+
*/
|
|
172
|
+
export function prepareNavigationScripts(
|
|
173
|
+
nextDocument: Document,
|
|
174
|
+
options: { timeoutMs?: number; baseUrl?: string } = {},
|
|
175
|
+
): (signal?: AbortSignal) => Promise<void> {
|
|
176
|
+
seedCurrentScripts();
|
|
177
|
+
const base = options.baseUrl ?? nextDocument.baseURI ?? document.baseURI;
|
|
178
|
+
const scripts = [...nextDocument.querySelectorAll<HTMLScriptElement>("script")]
|
|
179
|
+
.map((script) => describeScript(script, base))
|
|
180
|
+
.filter((script): script is NavigationScript => script !== undefined);
|
|
181
|
+
let executed: Promise<void> | undefined;
|
|
182
|
+
return (signal) => {
|
|
183
|
+
executed ??= (async () => {
|
|
184
|
+
for (const script of scripts) {
|
|
185
|
+
if (signal?.aborted) return;
|
|
186
|
+
await abortable(executeScript(script, options.timeoutMs ?? 15_000), signal);
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
return executed;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { RouteMotionPreset } from "../utils/route-motion-policy";
|
|
2
|
+
|
|
1
3
|
export interface RouteMotionEvent {
|
|
4
|
+
// Retained for integrations using the shared gates with a native transition.
|
|
5
|
+
// Stalux's Swup router leaves this unset and owns the animation lifecycle.
|
|
2
6
|
viewTransition?: {
|
|
3
7
|
ready?: PromiseLike<unknown>;
|
|
4
8
|
finished?: PromiseLike<unknown>;
|
|
@@ -6,13 +10,43 @@ export interface RouteMotionEvent {
|
|
|
6
10
|
signal?: AbortSignal;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
export interface RouteMotionOptions {
|
|
14
|
+
skip?: boolean;
|
|
15
|
+
content?: boolean;
|
|
16
|
+
preset?: RouteMotionPreset;
|
|
17
|
+
browserMotion?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export interface RouteMotion {
|
|
10
21
|
ready: Promise<void>;
|
|
11
22
|
finished: Promise<void>;
|
|
12
|
-
start: (
|
|
23
|
+
start: (heading: HTMLElement | null, options?: RouteMotionOptions) => void;
|
|
13
24
|
cancel: () => void;
|
|
14
25
|
}
|
|
15
26
|
|
|
27
|
+
interface HeadingMotion {
|
|
28
|
+
opacity: number;
|
|
29
|
+
duration: number;
|
|
30
|
+
x?: number;
|
|
31
|
+
y?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Legacy heading cues remain available to integrations. The router requests
|
|
35
|
+
// one content animation; it never stacks a second animation on the heading.
|
|
36
|
+
const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
37
|
+
glide: { opacity: 0.86, duration: 100 },
|
|
38
|
+
article: { opacity: 0.86, duration: 100 },
|
|
39
|
+
enter: { opacity: 0.86, duration: 140, y: 6 },
|
|
40
|
+
up: { opacity: 0.9, duration: 120, y: -4 },
|
|
41
|
+
taxonomy: { opacity: 0.85, duration: 120 },
|
|
42
|
+
previous: { opacity: 0.9, duration: 140, x: -8 },
|
|
43
|
+
next: { opacity: 0.9, duration: 140, x: 8 },
|
|
44
|
+
panel: { opacity: 0.9, duration: 120, y: 4 },
|
|
45
|
+
home: { opacity: 0.9, duration: 120, y: 4 },
|
|
46
|
+
restore: { opacity: 0.96, duration: 80 },
|
|
47
|
+
error: { opacity: 0.9, duration: 80 },
|
|
48
|
+
};
|
|
49
|
+
|
|
16
50
|
const motions = new WeakMap<object, RouteMotion>();
|
|
17
51
|
const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
|
|
18
52
|
Promise.resolve(promise).then(
|
|
@@ -28,15 +62,30 @@ function completion() {
|
|
|
28
62
|
return { promise, resolve };
|
|
29
63
|
}
|
|
30
64
|
|
|
31
|
-
/**
|
|
65
|
+
/** A deep history restore must not scroll to or animate an off-screen title. */
|
|
66
|
+
function isVisibleHeading(heading: HTMLElement): boolean {
|
|
67
|
+
if (!heading.isConnected || !heading.hasAttribute("data-stalux-motion-focus")) return false;
|
|
68
|
+
const rect = heading.getBoundingClientRect();
|
|
69
|
+
return (
|
|
70
|
+
rect.width > 0 &&
|
|
71
|
+
rect.height > 0 &&
|
|
72
|
+
rect.height <= Math.min(320, window.innerHeight / 2) &&
|
|
73
|
+
rect.top < window.innerHeight &&
|
|
74
|
+
rect.bottom > 0 &&
|
|
75
|
+
rect.left < window.innerWidth &&
|
|
76
|
+
rect.right > 0
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Share Swup's actual content animation with background and analytics consumers. */
|
|
32
81
|
export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
33
82
|
const native = event.viewTransition;
|
|
34
83
|
const key = native ?? event;
|
|
35
84
|
const existing = motions.get(key);
|
|
36
85
|
if (existing) return existing;
|
|
37
86
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
87
|
+
// Installation is a separate gate: before-swap consumers must not mistake
|
|
88
|
+
// the gap before Swup's incoming animation hook for a completed transition.
|
|
40
89
|
const installed = completion();
|
|
41
90
|
const animationDone = completion();
|
|
42
91
|
const canceled = completion();
|
|
@@ -47,6 +96,8 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
47
96
|
const active = animation;
|
|
48
97
|
animation = undefined;
|
|
49
98
|
try {
|
|
99
|
+
// Remove fill effects and restore the authored final styles. No
|
|
100
|
+
// inline transform/opacity survives completion, failure or abort.
|
|
50
101
|
active?.cancel();
|
|
51
102
|
} catch {
|
|
52
103
|
// Releasing an already detached effect must not hold up navigation.
|
|
@@ -73,24 +124,44 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
73
124
|
const motion: RouteMotion = {
|
|
74
125
|
ready,
|
|
75
126
|
finished,
|
|
76
|
-
start(
|
|
127
|
+
start(heading, options) {
|
|
77
128
|
if (started) return;
|
|
78
129
|
started = true;
|
|
79
130
|
try {
|
|
131
|
+
const preset = options?.preset ?? "glide";
|
|
132
|
+
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
80
133
|
if (
|
|
81
|
-
!
|
|
134
|
+
!heading ||
|
|
135
|
+
preset === "none" ||
|
|
136
|
+
(options?.content && preset === "restore") ||
|
|
82
137
|
options?.skip ||
|
|
138
|
+
options?.browserMotion ||
|
|
83
139
|
document.hidden ||
|
|
84
140
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
85
|
-
typeof
|
|
141
|
+
typeof heading.animate !== "function" ||
|
|
142
|
+
!(options?.content ? heading.isConnected : isVisibleHeading(heading)) ||
|
|
143
|
+
(mobile && preset === "restore")
|
|
86
144
|
) {
|
|
87
145
|
animationDone.resolve();
|
|
88
146
|
return;
|
|
89
147
|
}
|
|
90
|
-
|
|
91
|
-
{ opacity:
|
|
92
|
-
|
|
93
|
-
|
|
148
|
+
const spec = options?.content
|
|
149
|
+
? { opacity: 0, duration: mobile ? 180 : 220, y: mobile ? 8 : 16 }
|
|
150
|
+
: headingMotions[preset];
|
|
151
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
152
|
+
// Content uses a short vertical entry on both breakpoints. Legacy
|
|
153
|
+
// heading cues retain their motionless phone fallback.
|
|
154
|
+
if ((options?.content || !mobile) && (spec.x || spec.y)) {
|
|
155
|
+
keyframes.transform = [
|
|
156
|
+
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
157
|
+
"translate3d(0px, 0px, 0)",
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
animation = heading.animate(keyframes, {
|
|
161
|
+
duration: options?.content ? spec.duration : mobile ? 80 : spec.duration,
|
|
162
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
163
|
+
fill: "both",
|
|
164
|
+
});
|
|
94
165
|
void animation.finished.then(clearAnimation, clearAnimation);
|
|
95
166
|
} catch {
|
|
96
167
|
clearAnimation();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Fade the old content only after destination assets are ready. */
|
|
2
|
+
export async function animateRouteOut(
|
|
3
|
+
element: HTMLElement | null,
|
|
4
|
+
signal: AbortSignal,
|
|
5
|
+
): Promise<() => void> {
|
|
6
|
+
if (
|
|
7
|
+
!element ||
|
|
8
|
+
signal.aborted ||
|
|
9
|
+
document.hidden ||
|
|
10
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
11
|
+
)
|
|
12
|
+
return () => {};
|
|
13
|
+
let animation: Animation | undefined;
|
|
14
|
+
const cancel = () => animation?.cancel();
|
|
15
|
+
try {
|
|
16
|
+
animation = element.animate(
|
|
17
|
+
{ opacity: [1, 0], transform: ["translateY(0)", "translateY(-8px)"] },
|
|
18
|
+
{ duration: 140, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" },
|
|
19
|
+
);
|
|
20
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
21
|
+
await animation.finished;
|
|
22
|
+
// Keep the old page hidden until swap, but release effects on abort.
|
|
23
|
+
// Swup detaches this node; no persistent shell is animated.
|
|
24
|
+
} catch {
|
|
25
|
+
/* Cancellation and unsupported animation must not block a visit. */
|
|
26
|
+
} finally {
|
|
27
|
+
if (signal.aborted) cancel();
|
|
28
|
+
}
|
|
29
|
+
return () => {
|
|
30
|
+
signal.removeEventListener("abort", cancel);
|
|
31
|
+
cancel();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
let stopActive: (() => void) | undefined;
|
|
2
|
+
|
|
3
|
+
export function cancelPageScroll(): void {
|
|
4
|
+
stopActive?.();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Native scrolling with bounded completion and explicit listener cleanup. */
|
|
8
|
+
export function scrollPage(
|
|
9
|
+
element: Element,
|
|
10
|
+
top: number,
|
|
11
|
+
left: number,
|
|
12
|
+
animate: boolean,
|
|
13
|
+
start: () => void,
|
|
14
|
+
end: () => void,
|
|
15
|
+
): void {
|
|
16
|
+
stopActive?.();
|
|
17
|
+
start();
|
|
18
|
+
const smooth = animate && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
19
|
+
if (
|
|
20
|
+
!smooth ||
|
|
21
|
+
(Math.abs(element.scrollTop - top) < 1 && Math.abs(element.scrollLeft - left) < 1)
|
|
22
|
+
) {
|
|
23
|
+
element.scrollTo({ top, left, behavior: "instant" });
|
|
24
|
+
end();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const target =
|
|
28
|
+
element === document.documentElement || element === document.body ? window : element;
|
|
29
|
+
let finished = false;
|
|
30
|
+
const done = () => {
|
|
31
|
+
if (finished) return;
|
|
32
|
+
finished = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
target.removeEventListener("scrollend", done);
|
|
35
|
+
window.removeEventListener("wheel", interrupt);
|
|
36
|
+
window.removeEventListener("touchstart", interrupt);
|
|
37
|
+
window.removeEventListener("keydown", interrupt);
|
|
38
|
+
if (stopActive === interrupt) stopActive = undefined;
|
|
39
|
+
end();
|
|
40
|
+
};
|
|
41
|
+
const interrupt = () => {
|
|
42
|
+
element.scrollTo({ top: element.scrollTop, left: element.scrollLeft, behavior: "instant" });
|
|
43
|
+
done();
|
|
44
|
+
};
|
|
45
|
+
const timer = setTimeout(done, 1500);
|
|
46
|
+
stopActive = interrupt;
|
|
47
|
+
target.addEventListener("scrollend", done, { once: true });
|
|
48
|
+
window.addEventListener("wheel", interrupt, { passive: true, once: true });
|
|
49
|
+
window.addEventListener("touchstart", interrupt, { passive: true, once: true });
|
|
50
|
+
window.addEventListener("keydown", interrupt, { once: true });
|
|
51
|
+
element.scrollTo({ top, left, behavior: "smooth" });
|
|
52
|
+
}
|