@xingwangzhe/stalux 1.43.0 → 1.44.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 +6 -2
- package/package.json +4 -2
- package/public/background/pattern-1.min.webp +0 -0
- package/public/background/pattern-10.min.webp +0 -0
- package/public/background/pattern-11.min.webp +0 -0
- package/public/background/pattern-12.min.webp +0 -0
- package/public/background/pattern-13.min.webp +0 -0
- package/public/background/pattern-14.min.webp +0 -0
- package/public/background/pattern-15.min.webp +0 -0
- package/public/background/pattern-16.min.webp +0 -0
- package/public/background/pattern-17.min.webp +0 -0
- package/public/background/pattern-18.min.webp +0 -0
- package/public/background/pattern-19.min.webp +0 -0
- package/public/background/pattern-2.min.webp +0 -0
- package/public/background/pattern-20.min.webp +0 -0
- package/public/background/pattern-21.min.webp +0 -0
- package/public/background/pattern-22.min.webp +0 -0
- package/public/background/pattern-23.min.webp +0 -0
- package/public/background/pattern-24.min.webp +0 -0
- package/public/background/pattern-25.min.webp +0 -0
- package/public/background/pattern-26.min.webp +0 -0
- package/public/background/pattern-27.min.webp +0 -0
- package/public/background/pattern-28.min.webp +0 -0
- package/public/background/pattern-29.min.webp +0 -0
- package/public/background/pattern-3.min.webp +0 -0
- package/public/background/pattern-30.min.webp +0 -0
- package/public/background/pattern-31.min.webp +0 -0
- package/public/background/pattern-32.min.webp +0 -0
- package/public/background/pattern-33.min.webp +0 -0
- package/public/background/pattern-34.min.webp +0 -0
- package/public/background/pattern-35.min.webp +0 -0
- package/public/background/pattern-36.min.webp +0 -0
- package/public/background/pattern-37.min.webp +0 -0
- package/public/background/pattern-38.min.webp +0 -0
- package/public/background/pattern-39.min.webp +0 -0
- package/public/background/pattern-4.min.webp +0 -0
- package/public/background/pattern-40.min.webp +0 -0
- package/public/background/pattern-41.min.webp +0 -0
- package/public/background/pattern-42.min.webp +0 -0
- package/public/background/pattern-5.min.webp +0 -0
- package/public/background/pattern-6.min.webp +0 -0
- package/public/background/pattern-7.min.webp +0 -0
- package/public/background/pattern-8.min.webp +0 -0
- package/public/background/pattern-9.min.webp +0 -0
- package/src/components/stalux/archives/toArchive.astro +1 -1
- package/src/components/stalux/categories/categoriesCard.astro +1 -1
- package/src/components/stalux/common/author.astro +1 -1
- package/src/components/stalux/footer/FooterBadges.astro +5 -1
- package/src/components/stalux/layout/PageHeading.astro +2 -2
- package/src/components/stalux/layout/footer.astro +5 -1
- package/src/components/stalux/links/linkCard.astro +7 -1
- package/src/components/stalux/links/socialMedia.astro +1 -0
- package/src/components/stalux/posts/ArticleSummary.astro +6 -1
- package/src/components/stalux/posts/WalineComment.astro +1 -0
- package/src/components/stalux/posts/postCC.astro +1 -1
- package/src/components/stalux/posts/postCard.astro +4 -1
- package/src/components/stalux/posts/postContent.astro +1 -1
- package/src/components/stalux/posts/postLeft.astro +1 -1
- package/src/components/stalux/posts/postNavigation.astro +5 -1
- package/src/components/stalux/posts/postRight.astro +2 -2
- package/src/components/stalux/tags/tagHeader.astro +1 -1
- package/src/components/stalux/tags/tagsCloud.astro +3 -1
- package/src/components/stalux/words/wordCard.astro +5 -1
- package/src/internal/public-assets.ts +3 -3
- package/src/internal/style-names.generated.ts +20 -22
- package/src/layouts/Stalux.astro +3 -1
- package/src/pages/404.astro +2 -1
- package/src/pages/index.astro +5 -1
- package/src/scripts/background.ts +54 -113
- package/src/scripts/module-motion.ts +70 -0
- package/src/scripts/random-posts.ts +82 -47
- package/src/scripts/route-motion.ts +48 -24
- package/src/scripts/route-out.ts +40 -0
- package/src/scripts/smooth-scroll.ts +52 -0
- package/src/scripts/swup-router.ts +25 -9
- package/src/scripts/tags-cloud.ts +8 -3
- package/src/styles/base.css +2 -2
- package/src/styles/generated-article.css +1 -1
- package/src/styles/generated-links.css +1 -1
- package/src/styles/generated-waline.css +1 -1
- package/src/styles/generated-words.css +1 -1
- package/src/styles/generated.css +1 -1
- package/src/styles/integrations.css +1 -17
- package/src/styles/module-motion.css +174 -0
- package/src/styles/motion.css +2 -21
- package/src/utils/route-motion-policy.ts +5 -1
- package/src/utils/toc-scrollspy.ts +21 -14
|
@@ -1,133 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 每个完整文档只随机选择一次的会话背景。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 背景行为:
|
|
10
|
-
* - SSR 只渲染灰墙底色(--stalux-bg-color),不渲染任何背景图,构建产物确定。
|
|
11
|
-
* - 首次加载与 VT 软导航每次都会随机选一张背景:astro:page-load 在
|
|
12
|
-
* window load(首访)与 swap 完成后、过渡动画期间(软导航)触发,
|
|
13
|
-
* 先低优先级解码目标背景,等待内容过渡完成后再淡入,不与内容快照争抢绘制。
|
|
4
|
+
* SSR 保持确定的深色底和原有双层 DOM,不内联背景 URL。文档解析完成即
|
|
5
|
+
* 低优先级请求并异步解码一张无损 WebP,无需等待 window.load。首次成功时从暗底
|
|
6
|
+
* 用 200ms opacity 淡显;减少动态效果时直接显示。解码慢或失败仍保持暗底。
|
|
7
|
+
* Swup 只替换 main,背景沿用同一张,不按软导航重选/解码或再次交叉淡入。
|
|
8
|
+
* 自定义 __STALUX_BG_URLS__ 继续生效;saveData 下保留暗底,不请求装饰图片。
|
|
14
9
|
*/
|
|
15
10
|
import { createClientLogger } from "./logger";
|
|
16
|
-
import { prepareRouteMotion, type RouteMotionEvent } from "./route-motion";
|
|
17
11
|
|
|
18
12
|
const logger = createClientLogger("background");
|
|
19
13
|
|
|
20
14
|
declare global {
|
|
21
15
|
interface Window {
|
|
22
16
|
__STALUX_BG_URLS__?: string[];
|
|
17
|
+
__STALUX_BG_LISTENER__?: boolean;
|
|
23
18
|
}
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
/** 背景 SVG 静态 URL 列表(与主题包 public/background/ 的 1-42 号一一对应) */
|
|
27
21
|
const BACKGROUND_URLS: string[] = Array.from(
|
|
28
22
|
{ length: 42 },
|
|
29
|
-
(_, i) => `/background/pattern-${i + 1}.min.
|
|
23
|
+
(_, i) => `/background/pattern-${i + 1}.min.webp`,
|
|
30
24
|
);
|
|
31
25
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return document.querySelector(`[data-stalux-background="${layer}"]`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function setLayerOpacity(layer: "a" | "b", opacity: number): void {
|
|
42
|
-
const el = getLayerEl(layer);
|
|
43
|
-
if (el) el.style.opacity = String(opacity);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function getActiveLayer(): "a" | "b" {
|
|
47
|
-
const aOpacity = Number(getLayerEl("a")?.style.opacity ?? 0);
|
|
48
|
-
const bOpacity = Number(getLayerEl("b")?.style.opacity ?? 0);
|
|
49
|
-
return bOpacity > aOpacity ? "b" : "a";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function pickBackgroundIndex(previous: number): number {
|
|
53
|
-
if (backgroundImages.length < 2) return 0;
|
|
54
|
-
let index = Math.floor(Math.random() * backgroundImages.length);
|
|
55
|
-
while (index === previous) {
|
|
56
|
-
index = Math.floor(Math.random() * backgroundImages.length);
|
|
57
|
-
}
|
|
58
|
-
return index;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function crossfadeBackground(index: number): void {
|
|
62
|
-
const activeLayer = getActiveLayer();
|
|
63
|
-
const nextLayer: "a" | "b" = activeLayer === "a" ? "b" : "a";
|
|
64
|
-
const next = getLayerEl(nextLayer);
|
|
65
|
-
if (!next || !backgroundImages[index]) {
|
|
66
|
-
logger.debug("background layer unavailable; skipped");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
logger.debug(`crossfade layer=${nextLayer}; index=${index}`);
|
|
70
|
-
|
|
71
|
-
next.style.backgroundImage = `url('${backgroundImages[index]}')`;
|
|
72
|
-
setLayerOpacity(nextLayer, 1);
|
|
73
|
-
setLayerOpacity(activeLayer, 0);
|
|
74
|
-
document.body.dataset.staluxBgIndex = String(index);
|
|
75
|
-
document.body.dataset.staluxBgLayer = nextLayer;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let generation = 0;
|
|
79
|
-
let prepared: Promise<number | undefined> | undefined;
|
|
80
|
-
let transitionFinished: Promise<unknown> = Promise.resolve();
|
|
81
|
-
|
|
82
|
-
function prepareBackground(): Promise<number | undefined> {
|
|
83
|
-
const previousIndex = Number(document.body.dataset.staluxBgIndex ?? -1);
|
|
84
|
-
const index = pickBackgroundIndex(previousIndex);
|
|
85
|
-
const image = new Image();
|
|
86
|
-
image.decoding = "async";
|
|
87
|
-
image.fetchPriority = "low";
|
|
88
|
-
image.src = backgroundImages[index] ?? "";
|
|
89
|
-
return image.decode().then(
|
|
90
|
-
() => index,
|
|
91
|
-
() => undefined,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
let firstCommitStarted = false;
|
|
96
|
-
|
|
97
|
-
function initializeBackground(): void {
|
|
98
|
-
const active = getLayerEl(getActiveLayer());
|
|
99
|
-
if (!prepared && active?.style.backgroundImage) return;
|
|
100
|
-
// 首访的早提交已经发起,但解码还没落地:此时 window.load 上的 page-load
|
|
101
|
-
// 不是新页面,不能再解码并淡入第二张。
|
|
102
|
-
if (!prepared && firstCommitStarted) return;
|
|
103
|
-
if (!prepared) firstCommitStarted = true;
|
|
104
|
-
const current = generation;
|
|
105
|
-
const ready = prepared ?? prepareBackground();
|
|
106
|
-
prepared = undefined;
|
|
107
|
-
// Decode off the navigation path, and keep the full-screen background repaint
|
|
108
|
-
// out of the content's View Transition. A superseded navigation never commits.
|
|
109
|
-
void Promise.all([ready, transitionFinished]).then(([index]) => {
|
|
110
|
-
if (current === generation && index !== undefined) crossfadeBackground(index);
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 背景层跨页保留;before-swap 只记录过渡完成信号,不阻塞或修改内容快照。
|
|
115
|
-
// page-load 再提交已解码的背景,非 VT 浏览器也采用同一清理/去重流程。
|
|
116
|
-
if (!(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__) {
|
|
117
|
-
(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__ = true;
|
|
118
|
-
document.addEventListener("astro:before-preparation", () => {
|
|
119
|
-
generation++;
|
|
26
|
+
/** Install once per full document; this module is also harmless during SSR. */
|
|
27
|
+
export function installSessionBackground(): void {
|
|
28
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
29
|
+
if (window.__STALUX_BG_LISTENER__) return;
|
|
30
|
+
window.__STALUX_BG_LISTENER__ = true;
|
|
31
|
+
const start = () => {
|
|
120
32
|
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } })
|
|
121
33
|
.connection;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
34
|
+
if (connection?.saveData) return;
|
|
35
|
+
const a = document.querySelector<HTMLElement>('[data-stalux-background="a"]');
|
|
36
|
+
const b = document.querySelector<HTMLElement>('[data-stalux-background="b"]');
|
|
37
|
+
if (!a || !b || a.style.backgroundImage || b.style.backgroundImage) return;
|
|
38
|
+
const custom = Boolean(window.__STALUX_BG_URLS__?.length);
|
|
39
|
+
const urls = window.__STALUX_BG_URLS__?.length
|
|
40
|
+
? window.__STALUX_BG_URLS__
|
|
41
|
+
: BACKGROUND_URLS;
|
|
42
|
+
const index = Math.floor(Math.random() * urls.length);
|
|
43
|
+
const source = urls[index];
|
|
44
|
+
if (!source) return;
|
|
45
|
+
const image = new Image();
|
|
46
|
+
image.decoding = "async";
|
|
47
|
+
image.fetchPriority = "low";
|
|
48
|
+
image.src = source;
|
|
49
|
+
void image.decode().then(
|
|
50
|
+
() => {
|
|
51
|
+
// These two shell nodes retain their identities during Swup visits.
|
|
52
|
+
// No navigation event can start another image or reset this result.
|
|
53
|
+
if (!a.isConnected || !b.isConnected) return;
|
|
54
|
+
// Original SVGs have only a viewBox: auto sizes them as contain.
|
|
55
|
+
// Raster files have intrinsic dimensions, so retain that sizing
|
|
56
|
+
// explicitly. Custom URLs keep the existing auto semantics.
|
|
57
|
+
if (!custom) b.style.backgroundSize = "contain";
|
|
58
|
+
b.style.backgroundImage = `url('${source}')`;
|
|
59
|
+
b.style.opacity = "1";
|
|
60
|
+
a.style.opacity = "0";
|
|
61
|
+
document.body.dataset.staluxBgIndex = String(index);
|
|
62
|
+
document.body.dataset.staluxBgLayer = "b";
|
|
63
|
+
},
|
|
64
|
+
() => {
|
|
65
|
+
logger.debug("session background failed to decode; keeping dark canvas");
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
if (document.readyState === "loading")
|
|
70
|
+
document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
71
|
+
else start();
|
|
133
72
|
}
|
|
73
|
+
|
|
74
|
+
installSessionBackground();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { registerPageLifecycle } from "./page-runtime";
|
|
2
|
+
|
|
3
|
+
const SELECTOR =
|
|
4
|
+
'[data-stalux-module="summary"], [data-stalux-module="license"], [data-stalux-module="comments"], [data-stalux-module="sidebar"], [data-stalux-module="post-navigation"]';
|
|
5
|
+
const MAX_REGIONS = 12;
|
|
6
|
+
|
|
7
|
+
/** Bounded secondary cues. Text is visible even without observers or animation. */
|
|
8
|
+
export function mountModuleMotion(): (() => void) | undefined {
|
|
9
|
+
if (
|
|
10
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
|
|
11
|
+
typeof IntersectionObserver !== "function"
|
|
12
|
+
)
|
|
13
|
+
return;
|
|
14
|
+
const elements = [...document.querySelectorAll<HTMLElement>(SELECTOR)].slice(0, MAX_REGIONS);
|
|
15
|
+
if (!elements.length) return;
|
|
16
|
+
const active = new Set<Animation>();
|
|
17
|
+
const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
18
|
+
const cancel = () => {
|
|
19
|
+
for (const animation of active) animation.cancel();
|
|
20
|
+
active.clear();
|
|
21
|
+
};
|
|
22
|
+
const observer = new IntersectionObserver(
|
|
23
|
+
(entries) => {
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!entry.isIntersecting) continue;
|
|
26
|
+
const element = entry.target as HTMLElement;
|
|
27
|
+
observer.unobserve(element);
|
|
28
|
+
// The router owns a single bounded lead region. Do not layer an
|
|
29
|
+
// observer entry over that motion or make long comments slide.
|
|
30
|
+
if (
|
|
31
|
+
document.hidden ||
|
|
32
|
+
motionPreference.matches ||
|
|
33
|
+
document.documentElement.classList.contains("is-changing") ||
|
|
34
|
+
document.documentElement.dataset.staluxVtPreset ||
|
|
35
|
+
element.closest("[data-stalux-motion-region]") ||
|
|
36
|
+
entry.boundingClientRect.height > Math.min(420, window.innerHeight * 0.6) ||
|
|
37
|
+
typeof element.animate !== "function"
|
|
38
|
+
)
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const animation = element.animate(
|
|
42
|
+
{ opacity: [0.88, 1] },
|
|
43
|
+
{
|
|
44
|
+
duration: 140,
|
|
45
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
active.add(animation);
|
|
49
|
+
const release = () => {
|
|
50
|
+
active.delete(animation);
|
|
51
|
+
animation.cancel();
|
|
52
|
+
};
|
|
53
|
+
void animation.finished.then(release, release);
|
|
54
|
+
} catch {
|
|
55
|
+
/* Unsupported effects leave the authored visible state. */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{ threshold: 0.08 },
|
|
60
|
+
);
|
|
61
|
+
for (const element of elements) observer.observe(element);
|
|
62
|
+
motionPreference.addEventListener("change", cancel);
|
|
63
|
+
return () => {
|
|
64
|
+
observer.disconnect();
|
|
65
|
+
motionPreference.removeEventListener("change", cancel);
|
|
66
|
+
cancel();
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
registerPageLifecycle("module-motion", mountModuleMotion);
|
|
@@ -3,21 +3,6 @@ import { registerPageLifecycle } from "./page-runtime";
|
|
|
3
3
|
|
|
4
4
|
const logger = createClientLogger("random-posts");
|
|
5
5
|
|
|
6
|
-
const sleep = (ms: number, signal: AbortSignal) =>
|
|
7
|
-
new Promise<void>((resolve) => {
|
|
8
|
-
const finish = () => {
|
|
9
|
-
signal.removeEventListener("abort", aborted);
|
|
10
|
-
resolve();
|
|
11
|
-
};
|
|
12
|
-
const timer = window.setTimeout(finish, ms);
|
|
13
|
-
const aborted = () => {
|
|
14
|
-
window.clearTimeout(timer);
|
|
15
|
-
finish();
|
|
16
|
-
};
|
|
17
|
-
if (signal.aborted) aborted();
|
|
18
|
-
else signal.addEventListener("abort", aborted, { once: true });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
6
|
function shuffle<T extends object>(items: T[]): T[] {
|
|
22
7
|
const result = [...items];
|
|
23
8
|
const bytes = new Uint32Array(1);
|
|
@@ -47,45 +32,95 @@ function render(posts: { abbrlink: string; title: string }[]) {
|
|
|
47
32
|
.join("");
|
|
48
33
|
}
|
|
49
34
|
|
|
50
|
-
|
|
51
|
-
const list = container.querySelector<HTMLElement>("#random-posts-list");
|
|
52
|
-
if (!list) return;
|
|
53
|
-
list.style.opacity = "0.5";
|
|
54
|
-
await sleep(300, signal);
|
|
55
|
-
if (signal.aborted) return;
|
|
56
|
-
try {
|
|
57
|
-
logger.debug("refresh started");
|
|
58
|
-
const posts = shuffle(await fetchPosts(signal)).slice(
|
|
59
|
-
0,
|
|
60
|
-
Number(container.dataset.count ?? 5),
|
|
61
|
-
);
|
|
62
|
-
if (signal.aborted) return;
|
|
63
|
-
logger.debug(`refresh completed; posts=${posts.length}`);
|
|
64
|
-
list.innerHTML = posts.length
|
|
65
|
-
? render(posts)
|
|
66
|
-
: `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
|
|
67
|
-
} catch (error) {
|
|
68
|
-
if (signal.aborted) return;
|
|
69
|
-
logger.error("refresh failed; showing fallback", error);
|
|
70
|
-
list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
|
|
71
|
-
} finally {
|
|
72
|
-
if (!signal.aborted) list.style.opacity = "1";
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
registerPageLifecycle("random-posts", () => {
|
|
35
|
+
export function mountRandomPosts(): (() => void) | undefined {
|
|
77
36
|
const container = document.querySelector<HTMLElement>("[data-stalux-random-posts]");
|
|
78
|
-
|
|
37
|
+
const list = container?.querySelector<HTMLElement>("#random-posts-list");
|
|
38
|
+
if (!container || !list) return;
|
|
79
39
|
const controller = new AbortController();
|
|
40
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
80
41
|
const fallback = container.dataset.loadError ?? "Failed to load posts";
|
|
42
|
+
let request: AbortController | undefined;
|
|
43
|
+
let animation: Animation | undefined;
|
|
44
|
+
let revision = 0;
|
|
45
|
+
let disposed = false;
|
|
46
|
+
const cancelAnimation = () => {
|
|
47
|
+
const previous = animation;
|
|
48
|
+
animation = undefined;
|
|
49
|
+
previous?.cancel();
|
|
50
|
+
};
|
|
51
|
+
const reveal = () => {
|
|
52
|
+
if (reducedMotion.matches || document.hidden || typeof list.animate !== "function") return;
|
|
53
|
+
try {
|
|
54
|
+
// Real titles can wrap the sidebar beyond the same bounded-region
|
|
55
|
+
// budget used for route effects. Keep that result immediately readable.
|
|
56
|
+
if (list.getBoundingClientRect().height > Math.min(420, window.innerHeight * 0.6))
|
|
57
|
+
return;
|
|
58
|
+
const current = list.animate(
|
|
59
|
+
{ opacity: [0.82, 1] },
|
|
60
|
+
{ duration: 100, easing: "ease-out" },
|
|
61
|
+
);
|
|
62
|
+
animation = current;
|
|
63
|
+
const release = () => {
|
|
64
|
+
// A completed old effect cannot release a newer refresh cue.
|
|
65
|
+
if (animation === current) animation = undefined;
|
|
66
|
+
current.cancel();
|
|
67
|
+
};
|
|
68
|
+
void current.finished.then(release, release);
|
|
69
|
+
} catch {
|
|
70
|
+
// The authored list stays fully readable when animation is unavailable.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const refresh = async () => {
|
|
74
|
+
// Latest click wins, including fetch implementations whose pending JSON
|
|
75
|
+
// decoding cannot be interrupted by AbortSignal.
|
|
76
|
+
request?.abort();
|
|
77
|
+
const current = new AbortController();
|
|
78
|
+
request = current;
|
|
79
|
+
const token = ++revision;
|
|
80
|
+
const isCurrent = () => !disposed && token === revision && !current.signal.aborted;
|
|
81
|
+
cancelAnimation();
|
|
82
|
+
container.setAttribute("aria-busy", "true");
|
|
83
|
+
try {
|
|
84
|
+
logger.debug("refresh started");
|
|
85
|
+
const posts = shuffle(await fetchPosts(current.signal)).slice(
|
|
86
|
+
0,
|
|
87
|
+
Number(container.dataset.count ?? 5),
|
|
88
|
+
);
|
|
89
|
+
if (!isCurrent()) return;
|
|
90
|
+
logger.debug(`refresh completed; posts=${posts.length}`);
|
|
91
|
+
list.innerHTML = posts.length
|
|
92
|
+
? render(posts)
|
|
93
|
+
: `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
|
|
94
|
+
reveal();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!isCurrent()) return;
|
|
97
|
+
logger.error("refresh failed; showing fallback", error);
|
|
98
|
+
list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
|
|
99
|
+
} finally {
|
|
100
|
+
if (isCurrent()) {
|
|
101
|
+
request = undefined;
|
|
102
|
+
container.removeAttribute("aria-busy");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
81
106
|
container.querySelector("#refresh-random-posts")?.addEventListener(
|
|
82
107
|
"click",
|
|
83
108
|
(event) => {
|
|
84
109
|
event.preventDefault();
|
|
85
|
-
void refresh(
|
|
110
|
+
void refresh();
|
|
86
111
|
},
|
|
87
112
|
{ signal: controller.signal },
|
|
88
113
|
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
reducedMotion.addEventListener("change", cancelAnimation, { signal: controller.signal });
|
|
115
|
+
void refresh();
|
|
116
|
+
return () => {
|
|
117
|
+
disposed = true;
|
|
118
|
+
revision++;
|
|
119
|
+
request?.abort();
|
|
120
|
+
controller.abort();
|
|
121
|
+
cancelAnimation();
|
|
122
|
+
container.removeAttribute("aria-busy");
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
registerPageLifecycle("random-posts", mountRandomPosts);
|
|
@@ -12,6 +12,8 @@ export interface RouteMotionEvent {
|
|
|
12
12
|
|
|
13
13
|
export interface RouteMotionOptions {
|
|
14
14
|
skip?: boolean;
|
|
15
|
+
/** Use a bounded introductory region around the marked heading. */
|
|
16
|
+
content?: boolean;
|
|
15
17
|
preset?: RouteMotionPreset;
|
|
16
18
|
browserMotion?: boolean;
|
|
17
19
|
}
|
|
@@ -30,8 +32,8 @@ interface HeadingMotion {
|
|
|
30
32
|
y?: number;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
+
// Legacy heading cues remain available to integrations. The router requests
|
|
36
|
+
// one content animation; it never stacks a second animation on the heading.
|
|
35
37
|
const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
36
38
|
glide: { opacity: 0.86, duration: 100 },
|
|
37
39
|
article: { opacity: 0.86, duration: 100 },
|
|
@@ -61,22 +63,43 @@ function completion() {
|
|
|
61
63
|
return { promise, resolve };
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
/** A
|
|
65
|
-
function
|
|
66
|
-
if (!
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
/** A route transition never captures a long article, list, or off-screen heading. */
|
|
67
|
+
export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | null {
|
|
68
|
+
if (!focus?.isConnected || !focus.hasAttribute("data-stalux-motion-focus")) return null;
|
|
69
|
+
const visible = (element: HTMLElement, maxHeight: number) => {
|
|
70
|
+
const rect = element.getBoundingClientRect();
|
|
71
|
+
return (
|
|
72
|
+
rect.width > 0 &&
|
|
73
|
+
rect.height > 0 &&
|
|
74
|
+
rect.height <= maxHeight &&
|
|
75
|
+
rect.top < window.innerHeight &&
|
|
76
|
+
rect.bottom > 0 &&
|
|
77
|
+
rect.left < window.innerWidth &&
|
|
78
|
+
rect.right > 0
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
if (!visible(focus, Math.min(320, window.innerHeight / 2))) return null;
|
|
82
|
+
const region = focus.closest?.<HTMLElement>("[data-stalux-motion-region]");
|
|
83
|
+
return region?.isConnected && visible(region, Math.min(420, window.innerHeight * 0.6))
|
|
84
|
+
? region
|
|
85
|
+
: focus;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
const regionMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
89
|
+
glide: { opacity: 0.72, duration: 160 },
|
|
90
|
+
article: { opacity: 0.72, duration: 180, y: 4 },
|
|
91
|
+
enter: { opacity: 0.72, duration: 180, y: 6 },
|
|
92
|
+
up: { opacity: 0.8, duration: 160, y: -4 },
|
|
93
|
+
taxonomy: { opacity: 0.72, duration: 160, x: 6 },
|
|
94
|
+
previous: { opacity: 0.76, duration: 180, x: -8 },
|
|
95
|
+
next: { opacity: 0.76, duration: 180, x: 8 },
|
|
96
|
+
panel: { opacity: 0.76, duration: 160, y: 4 },
|
|
97
|
+
home: { opacity: 0.76, duration: 180, y: 4 },
|
|
98
|
+
restore: { opacity: 1, duration: 0 },
|
|
99
|
+
error: { opacity: 0.85, duration: 100 },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Share Swup's actual content animation with background and analytics consumers. */
|
|
80
103
|
export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
81
104
|
const native = event.viewTransition;
|
|
82
105
|
const key = native ?? event;
|
|
@@ -129,32 +152,33 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
129
152
|
try {
|
|
130
153
|
const preset = options?.preset ?? "glide";
|
|
131
154
|
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
155
|
+
const target = getRouteMotionTarget(heading);
|
|
132
156
|
if (
|
|
133
|
-
!
|
|
157
|
+
!target ||
|
|
134
158
|
preset === "none" ||
|
|
159
|
+
(options?.content && preset === "restore") ||
|
|
135
160
|
options?.skip ||
|
|
136
161
|
options?.browserMotion ||
|
|
137
162
|
document.hidden ||
|
|
138
163
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
139
|
-
typeof
|
|
140
|
-
!isVisibleHeading(heading) ||
|
|
164
|
+
typeof target.animate !== "function" ||
|
|
141
165
|
(mobile && preset === "restore")
|
|
142
166
|
) {
|
|
143
167
|
animationDone.resolve();
|
|
144
168
|
return;
|
|
145
169
|
}
|
|
146
|
-
const spec = headingMotions[preset];
|
|
170
|
+
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
147
171
|
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
148
|
-
//
|
|
149
|
-
//
|
|
172
|
+
// Phones use a pure fade. Explicit previous/next alone imply a
|
|
173
|
+
// lateral reading direction; history never replays the entry.
|
|
150
174
|
if (!mobile && (spec.x || spec.y)) {
|
|
151
175
|
keyframes.transform = [
|
|
152
176
|
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
153
177
|
"translate3d(0px, 0px, 0)",
|
|
154
178
|
];
|
|
155
179
|
}
|
|
156
|
-
animation =
|
|
157
|
-
duration: mobile ? 80 : spec.duration,
|
|
180
|
+
animation = target.animate(keyframes, {
|
|
181
|
+
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
158
182
|
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
159
183
|
fill: "both",
|
|
160
184
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getRouteMotionTarget } from "./route-motion";
|
|
2
|
+
|
|
3
|
+
/** Fade the old content only after destination assets are ready. */
|
|
4
|
+
export async function animateRouteOut(
|
|
5
|
+
element: HTMLElement | null,
|
|
6
|
+
signal: AbortSignal,
|
|
7
|
+
): Promise<() => void> {
|
|
8
|
+
if (
|
|
9
|
+
!element ||
|
|
10
|
+
signal.aborted ||
|
|
11
|
+
document.hidden ||
|
|
12
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
13
|
+
)
|
|
14
|
+
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
|
+
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
|
20
|
+
try {
|
|
21
|
+
animation = target.animate(
|
|
22
|
+
mobile
|
|
23
|
+
? { opacity: [1, 0.82] }
|
|
24
|
+
: { opacity: [1, 0.82], transform: ["translateY(0)", "translateY(-4px)"] },
|
|
25
|
+
{ duration: 80, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" },
|
|
26
|
+
);
|
|
27
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
28
|
+
await animation.finished;
|
|
29
|
+
// Keep the bounded outgoing cue until swap; the body stays readable.
|
|
30
|
+
// Swup detaches this node; no persistent shell is animated.
|
|
31
|
+
} catch {
|
|
32
|
+
/* Cancellation and unsupported animation must not block a visit. */
|
|
33
|
+
} finally {
|
|
34
|
+
if (signal.aborted) cancel();
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
signal.removeEventListener("abort", cancel);
|
|
38
|
+
cancel();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|