@xingwangzhe/stalux 1.41.5 → 1.43.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/README.md +19 -5
- package/package.json +12 -3
- 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 +9 -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/env.d.ts +5 -0
- package/src/index.ts +41 -2
- package/src/internal/social-card-data.ts +34 -0
- package/src/internal/social-card-renderer.ts +307 -0
- package/src/internal/style-names.generated.ts +59 -69
- package/src/layouts/PostLayout.astro +1 -5
- package/src/layouts/Stalux.astro +6 -19
- package/src/pages/index.astro +1 -5
- package/src/pages/og/[image].[format].ts +47 -0
- package/src/pages/posts/[post].astro +8 -0
- package/src/pages/posts/[post].md.ts +1 -1
- package/src/schemas/collections.ts +9 -0
- package/src/scripts/navigation-client.ts +13 -0
- package/src/scripts/navigation-scripts.ts +191 -0
- package/src/scripts/route-motion.ts +78 -11
- package/src/scripts/swup-router.ts +366 -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.css +1 -1
- package/src/styles/motion.css +2 -211
- package/src/utils/post-markdown.ts +2 -0
- package/src/utils/route-catalog.ts +1 -0
- package/src/utils/seo-document.ts +21 -4
- package/src/utils/social-image.ts +60 -0
- package/src/scripts/archive-reveal.ts +0 -76
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import paths from "virtual:stalux-social-card-paths";
|
|
3
|
+
import type { APIRoute } from "astro";
|
|
4
|
+
import { createSocialCardData } from "../../internal/social-card-data";
|
|
5
|
+
import { renderSocialCard } from "../../internal/social-card-renderer";
|
|
6
|
+
import { getAuthorData, getSiteData } from "../../utils/config-utils";
|
|
7
|
+
import { getPostContentIndex } from "../../utils/content-index";
|
|
8
|
+
import { isSocialImageUrl, type SocialCardData, socialCardPath } from "../../utils/social-image";
|
|
9
|
+
|
|
10
|
+
export async function getStaticPaths() {
|
|
11
|
+
const entries = await getCollection("config");
|
|
12
|
+
const site = getSiteData(entries);
|
|
13
|
+
const author = getAuthorData(entries);
|
|
14
|
+
const { posts } = await getPostContentIndex();
|
|
15
|
+
return posts
|
|
16
|
+
.filter(
|
|
17
|
+
(post) =>
|
|
18
|
+
!post.data["og:image"]?.trim() || !isSocialImageUrl(post.data["og:image"].trim()),
|
|
19
|
+
)
|
|
20
|
+
.flatMap((post) => {
|
|
21
|
+
const card = createSocialCardData(post.data, site, author);
|
|
22
|
+
return (["webp", "png"] as const).map((format) => ({
|
|
23
|
+
params: {
|
|
24
|
+
image: socialCardPath(card)
|
|
25
|
+
.split("/")
|
|
26
|
+
.at(-1)
|
|
27
|
+
?.replace(/\.webp$/u, ""),
|
|
28
|
+
format,
|
|
29
|
+
},
|
|
30
|
+
props: { card, format },
|
|
31
|
+
// Covers may change independently of Markdown. Always recheck them; the renderer
|
|
32
|
+
// has its own content cache and writes outputs even after Astro cleans dist.
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const GET: APIRoute = async ({ props, logger }) => {
|
|
38
|
+
const bytes = await renderSocialCard(
|
|
39
|
+
props.card as SocialCardData,
|
|
40
|
+
paths,
|
|
41
|
+
(message) => logger.warn(message),
|
|
42
|
+
props.format as "webp" | "png",
|
|
43
|
+
);
|
|
44
|
+
return new Response(new Uint8Array(bytes), {
|
|
45
|
+
headers: { "Content-Type": `image/${props.format}` },
|
|
46
|
+
});
|
|
47
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { type CollectionEntry, getCollection, render } from "astro:content";
|
|
3
3
|
import { getRuntimeCacheKey } from "@internal/runtime-cache-key";
|
|
4
|
+
import { createSocialCardData } from "@internal/social-card-data";
|
|
4
5
|
import PostLayout from "@layouts/PostLayout.astro";
|
|
5
6
|
import Stalux from "@layouts/Stalux.astro";
|
|
6
7
|
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
7
8
|
import { getAuthorData, getSiteData } from "@utils/config-utils";
|
|
8
9
|
import { getPostContentIndex } from "@utils/content-index";
|
|
10
|
+
import { resolvePostSocialImage } from "@utils/social-image";
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
post: CollectionEntry<"posts">;
|
|
@@ -33,6 +35,7 @@ export async function getStaticPaths() {
|
|
|
33
35
|
post.data.updated ?? post.data.date,
|
|
34
36
|
prevPost ? (prevPost.data.updated ?? prevPost.data.date) : "",
|
|
35
37
|
nextPost ? (nextPost.data.updated ?? nextPost.data.date) : "",
|
|
38
|
+
JSON.stringify(post.data),
|
|
36
39
|
getRuntimeCacheKey(),
|
|
37
40
|
].join("|");
|
|
38
41
|
return {
|
|
@@ -56,6 +59,11 @@ export async function getStaticPaths() {
|
|
|
56
59
|
title={`${props.post.data.title} | ${siteConfig.title}`}
|
|
57
60
|
description={autoDesc}
|
|
58
61
|
cover={props.post.data.cover}
|
|
62
|
+
socialImage={resolvePostSocialImage(
|
|
63
|
+
props.post.data["og:image"],
|
|
64
|
+
createSocialCardData(props.post.data, siteConfig, authorConfig),
|
|
65
|
+
import.meta.env.BASE_URL,
|
|
66
|
+
)}
|
|
59
67
|
isArticle={true}
|
|
60
68
|
date={props.post.data.date}
|
|
61
69
|
updated={props.post.data.updated}
|
|
@@ -39,7 +39,7 @@ export const getStaticPaths = (async () => {
|
|
|
39
39
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
40
40
|
return posts.map((post) => ({
|
|
41
41
|
params: { post: String(post.data.abbrlink) },
|
|
42
|
-
cacheKey: `${post.data.updated ?? post.data.date}|${getRuntimeCacheKey()}`,
|
|
42
|
+
cacheKey: `${post.data.updated ?? post.data.date}|${JSON.stringify(post.data)}|${getRuntimeCacheKey()}`,
|
|
43
43
|
props: { post },
|
|
44
44
|
}));
|
|
45
45
|
}) satisfies GetStaticPaths;
|
|
@@ -19,6 +19,7 @@ import { defineCollection } from "astro:content";
|
|
|
19
19
|
import { glob, type Loader } from "astro/loaders";
|
|
20
20
|
import { z } from "astro/zod";
|
|
21
21
|
|
|
22
|
+
import { isSocialImageUrl } from "../utils/social-image";
|
|
22
23
|
import { configSchema } from "./config";
|
|
23
24
|
|
|
24
25
|
/** Preserve glob's name, watch behavior and incremental store; only trace the load boundary. */
|
|
@@ -98,6 +99,14 @@ const postSchema = z.object({
|
|
|
98
99
|
desc: z.string({ error: "desc must be a string" }).min(1, "desc is required"),
|
|
99
100
|
cc: z.string().optional().default("CC-BY-NC-SA-4.0"),
|
|
100
101
|
cover: z.string().optional(),
|
|
102
|
+
"og:image": z
|
|
103
|
+
.string()
|
|
104
|
+
.trim()
|
|
105
|
+
.refine(
|
|
106
|
+
(value) => value === "" || isSocialImageUrl(value),
|
|
107
|
+
"og:image must be an HTTP(S) URL or site-root image path",
|
|
108
|
+
)
|
|
109
|
+
.optional(),
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
// ---------------------------------------------------------------------------
|
|
@@ -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,42 @@ export interface RouteMotionEvent {
|
|
|
6
10
|
signal?: AbortSignal;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
export interface RouteMotionOptions {
|
|
14
|
+
skip?: boolean;
|
|
15
|
+
preset?: RouteMotionPreset;
|
|
16
|
+
browserMotion?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export interface RouteMotion {
|
|
10
20
|
ready: Promise<void>;
|
|
11
21
|
finished: Promise<void>;
|
|
12
|
-
start: (
|
|
22
|
+
start: (heading: HTMLElement | null, options?: RouteMotionOptions) => void;
|
|
13
23
|
cancel: () => void;
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
interface HeadingMotion {
|
|
27
|
+
opacity: number;
|
|
28
|
+
duration: number;
|
|
29
|
+
x?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// F / E / U / T / S / L / R / X from the route design. Only the incoming
|
|
34
|
+
// heading moves; article prose, lists, sidebars and the persistent shell do not.
|
|
35
|
+
const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
36
|
+
glide: { opacity: 0.86, duration: 100 },
|
|
37
|
+
article: { opacity: 0.86, duration: 100 },
|
|
38
|
+
enter: { opacity: 0.86, duration: 140, y: 6 },
|
|
39
|
+
up: { opacity: 0.9, duration: 120, y: -4 },
|
|
40
|
+
taxonomy: { opacity: 0.85, duration: 120 },
|
|
41
|
+
previous: { opacity: 0.9, duration: 140, x: -8 },
|
|
42
|
+
next: { opacity: 0.9, duration: 140, x: 8 },
|
|
43
|
+
panel: { opacity: 0.9, duration: 120, y: 4 },
|
|
44
|
+
home: { opacity: 0.9, duration: 120, y: 4 },
|
|
45
|
+
restore: { opacity: 0.96, duration: 80 },
|
|
46
|
+
error: { opacity: 0.9, duration: 80 },
|
|
47
|
+
};
|
|
48
|
+
|
|
16
49
|
const motions = new WeakMap<object, RouteMotion>();
|
|
17
50
|
const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
|
|
18
51
|
Promise.resolve(promise).then(
|
|
@@ -28,15 +61,30 @@ function completion() {
|
|
|
28
61
|
return { promise, resolve };
|
|
29
62
|
}
|
|
30
63
|
|
|
31
|
-
/**
|
|
64
|
+
/** A deep history restore must not scroll to or animate an off-screen title. */
|
|
65
|
+
function isVisibleHeading(heading: HTMLElement): boolean {
|
|
66
|
+
if (!heading.isConnected || !heading.hasAttribute("data-stalux-motion-focus")) return false;
|
|
67
|
+
const rect = heading.getBoundingClientRect();
|
|
68
|
+
return (
|
|
69
|
+
rect.width > 0 &&
|
|
70
|
+
rect.height > 0 &&
|
|
71
|
+
rect.height <= Math.min(320, window.innerHeight / 2) &&
|
|
72
|
+
rect.top < window.innerHeight &&
|
|
73
|
+
rect.bottom > 0 &&
|
|
74
|
+
rect.left < window.innerWidth &&
|
|
75
|
+
rect.right > 0
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Share Swup's actual heading animation with background and analytics consumers. */
|
|
32
80
|
export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
33
81
|
const native = event.viewTransition;
|
|
34
82
|
const key = native ?? event;
|
|
35
83
|
const existing = motions.get(key);
|
|
36
84
|
if (existing) return existing;
|
|
37
85
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
86
|
+
// Installation is a separate gate: before-swap consumers must not mistake
|
|
87
|
+
// the gap before Swup's incoming animation hook for a completed transition.
|
|
40
88
|
const installed = completion();
|
|
41
89
|
const animationDone = completion();
|
|
42
90
|
const canceled = completion();
|
|
@@ -47,6 +95,8 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
47
95
|
const active = animation;
|
|
48
96
|
animation = undefined;
|
|
49
97
|
try {
|
|
98
|
+
// Remove fill effects and restore the authored final styles. No
|
|
99
|
+
// inline transform/opacity survives completion, failure or abort.
|
|
50
100
|
active?.cancel();
|
|
51
101
|
} catch {
|
|
52
102
|
// Releasing an already detached effect must not hold up navigation.
|
|
@@ -73,24 +123,41 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
73
123
|
const motion: RouteMotion = {
|
|
74
124
|
ready,
|
|
75
125
|
finished,
|
|
76
|
-
start(
|
|
126
|
+
start(heading, options) {
|
|
77
127
|
if (started) return;
|
|
78
128
|
started = true;
|
|
79
129
|
try {
|
|
130
|
+
const preset = options?.preset ?? "glide";
|
|
131
|
+
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
80
132
|
if (
|
|
81
|
-
!
|
|
133
|
+
!heading ||
|
|
134
|
+
preset === "none" ||
|
|
82
135
|
options?.skip ||
|
|
136
|
+
options?.browserMotion ||
|
|
83
137
|
document.hidden ||
|
|
84
138
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
85
|
-
typeof
|
|
139
|
+
typeof heading.animate !== "function" ||
|
|
140
|
+
!isVisibleHeading(heading) ||
|
|
141
|
+
(mobile && preset === "restore")
|
|
86
142
|
) {
|
|
87
143
|
animationDone.resolve();
|
|
88
144
|
return;
|
|
89
145
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
const spec = headingMotions[preset];
|
|
147
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
148
|
+
// Phone routes use the specified pure fade. Unrelated article
|
|
149
|
+
// links also fade: lateral motion requires explicit prev/next.
|
|
150
|
+
if (!mobile && (spec.x || spec.y)) {
|
|
151
|
+
keyframes.transform = [
|
|
152
|
+
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
153
|
+
"translate3d(0px, 0px, 0)",
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
animation = heading.animate(keyframes, {
|
|
157
|
+
duration: mobile ? 80 : spec.duration,
|
|
158
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
159
|
+
fill: "both",
|
|
160
|
+
});
|
|
94
161
|
void animation.finished.then(clearAnimation, clearAnimation);
|
|
95
162
|
} catch {
|
|
96
163
|
clearAnimation();
|