@xingwangzhe/stalux 1.43.1 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- 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/archivesList.astro +8 -2
- 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/PostTaxonomy.astro +2 -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 +2 -2
- package/src/components/stalux/posts/postNavigation.astro +5 -1
- package/src/components/stalux/posts/postRight.astro +5 -1
- package/src/components/stalux/posts/postToc.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/PostLayout.astro +8 -0
- 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/list-reveal.ts +81 -0
- package/src/scripts/module-motion.ts +73 -0
- package/src/scripts/random-posts.ts +85 -47
- package/src/scripts/route-motion.ts +44 -24
- package/src/scripts/route-out.ts +11 -4
- package/src/scripts/smooth-scroll.ts +87 -0
- package/src/scripts/swup-router.ts +23 -15
- 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 -26
- package/src/utils/route-motion-policy.ts +5 -1
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
// 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]") ||
|
|
38
|
+
element.closest("[data-stalux-motion-region]") ||
|
|
39
|
+
entry.boundingClientRect.height > Math.min(420, window.innerHeight * 0.6) ||
|
|
40
|
+
typeof element.animate !== "function"
|
|
41
|
+
)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const animation = element.animate(
|
|
45
|
+
{ opacity: [0.88, 1] },
|
|
46
|
+
{
|
|
47
|
+
duration: 140,
|
|
48
|
+
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
active.add(animation);
|
|
52
|
+
const release = () => {
|
|
53
|
+
active.delete(animation);
|
|
54
|
+
animation.cancel();
|
|
55
|
+
};
|
|
56
|
+
void animation.finished.then(release, release);
|
|
57
|
+
} catch {
|
|
58
|
+
/* Unsupported effects leave the authored visible state. */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ threshold: 0.08 },
|
|
63
|
+
);
|
|
64
|
+
for (const element of elements) observer.observe(element);
|
|
65
|
+
motionPreference.addEventListener("change", cancel);
|
|
66
|
+
return () => {
|
|
67
|
+
observer.disconnect();
|
|
68
|
+
motionPreference.removeEventListener("change", cancel);
|
|
69
|
+
cancel();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
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,98 @@ 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
|
+
// 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;
|
|
56
|
+
try {
|
|
57
|
+
// Real titles can wrap the sidebar beyond the same bounded-region
|
|
58
|
+
// budget used for route effects. Keep that result immediately readable.
|
|
59
|
+
if (list.getBoundingClientRect().height > Math.min(420, window.innerHeight * 0.6))
|
|
60
|
+
return;
|
|
61
|
+
const current = list.animate(
|
|
62
|
+
{ opacity: [0.82, 1] },
|
|
63
|
+
{ duration: 100, easing: "ease-out" },
|
|
64
|
+
);
|
|
65
|
+
animation = current;
|
|
66
|
+
const release = () => {
|
|
67
|
+
// A completed old effect cannot release a newer refresh cue.
|
|
68
|
+
if (animation === current) animation = undefined;
|
|
69
|
+
current.cancel();
|
|
70
|
+
};
|
|
71
|
+
void current.finished.then(release, release);
|
|
72
|
+
} catch {
|
|
73
|
+
// The authored list stays fully readable when animation is unavailable.
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const refresh = async () => {
|
|
77
|
+
// Latest click wins, including fetch implementations whose pending JSON
|
|
78
|
+
// decoding cannot be interrupted by AbortSignal.
|
|
79
|
+
request?.abort();
|
|
80
|
+
const current = new AbortController();
|
|
81
|
+
request = current;
|
|
82
|
+
const token = ++revision;
|
|
83
|
+
const isCurrent = () => !disposed && token === revision && !current.signal.aborted;
|
|
84
|
+
cancelAnimation();
|
|
85
|
+
container.setAttribute("aria-busy", "true");
|
|
86
|
+
try {
|
|
87
|
+
logger.debug("refresh started");
|
|
88
|
+
const posts = shuffle(await fetchPosts(current.signal)).slice(
|
|
89
|
+
0,
|
|
90
|
+
Number(container.dataset.count ?? 5),
|
|
91
|
+
);
|
|
92
|
+
if (!isCurrent()) return;
|
|
93
|
+
logger.debug(`refresh completed; posts=${posts.length}`);
|
|
94
|
+
list.innerHTML = posts.length
|
|
95
|
+
? render(posts)
|
|
96
|
+
: `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
|
|
97
|
+
reveal();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!isCurrent()) return;
|
|
100
|
+
logger.error("refresh failed; showing fallback", error);
|
|
101
|
+
list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
|
|
102
|
+
} finally {
|
|
103
|
+
if (isCurrent()) {
|
|
104
|
+
request = undefined;
|
|
105
|
+
container.removeAttribute("aria-busy");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
81
109
|
container.querySelector("#refresh-random-posts")?.addEventListener(
|
|
82
110
|
"click",
|
|
83
111
|
(event) => {
|
|
84
112
|
event.preventDefault();
|
|
85
|
-
void refresh(
|
|
113
|
+
void refresh();
|
|
86
114
|
},
|
|
87
115
|
{ signal: controller.signal },
|
|
88
116
|
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
reducedMotion.addEventListener("change", cancelAnimation, { signal: controller.signal });
|
|
118
|
+
void refresh();
|
|
119
|
+
return () => {
|
|
120
|
+
disposed = true;
|
|
121
|
+
revision++;
|
|
122
|
+
request?.abort();
|
|
123
|
+
controller.abort();
|
|
124
|
+
cancelAnimation();
|
|
125
|
+
container.removeAttribute("aria-busy");
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
registerPageLifecycle("random-posts", mountRandomPosts);
|
|
@@ -12,6 +12,7 @@ export interface RouteMotionEvent {
|
|
|
12
12
|
|
|
13
13
|
export interface RouteMotionOptions {
|
|
14
14
|
skip?: boolean;
|
|
15
|
+
/** Use a bounded introductory region around the marked heading. */
|
|
15
16
|
content?: boolean;
|
|
16
17
|
preset?: RouteMotionPreset;
|
|
17
18
|
browserMotion?: boolean;
|
|
@@ -62,21 +63,42 @@ function completion() {
|
|
|
62
63
|
return { promise, resolve };
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
/** A
|
|
66
|
-
function
|
|
67
|
-
if (!
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
86
|
}
|
|
79
87
|
|
|
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
|
+
|
|
80
102
|
/** Share Swup's actual content animation with background and analytics consumers. */
|
|
81
103
|
export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
82
104
|
const native = event.viewTransition;
|
|
@@ -130,35 +152,33 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
130
152
|
try {
|
|
131
153
|
const preset = options?.preset ?? "glide";
|
|
132
154
|
const mobile = window.matchMedia?.("(max-width: 768px)").matches;
|
|
155
|
+
const target = getRouteMotionTarget(heading);
|
|
133
156
|
if (
|
|
134
|
-
!
|
|
157
|
+
!target ||
|
|
135
158
|
preset === "none" ||
|
|
136
159
|
(options?.content && preset === "restore") ||
|
|
137
160
|
options?.skip ||
|
|
138
161
|
options?.browserMotion ||
|
|
139
162
|
document.hidden ||
|
|
140
163
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
|
|
141
|
-
typeof
|
|
142
|
-
!(options?.content ? heading.isConnected : isVisibleHeading(heading)) ||
|
|
164
|
+
typeof target.animate !== "function" ||
|
|
143
165
|
(mobile && preset === "restore")
|
|
144
166
|
) {
|
|
145
167
|
animationDone.resolve();
|
|
146
168
|
return;
|
|
147
169
|
}
|
|
148
|
-
const spec = options?.content
|
|
149
|
-
? { opacity: 0, duration: mobile ? 180 : 220, y: mobile ? 8 : 16 }
|
|
150
|
-
: headingMotions[preset];
|
|
170
|
+
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
151
171
|
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
if (
|
|
172
|
+
// Phones use a pure fade. Explicit previous/next alone imply a
|
|
173
|
+
// lateral reading direction; history never replays the entry.
|
|
174
|
+
if (!mobile && (spec.x || spec.y)) {
|
|
155
175
|
keyframes.transform = [
|
|
156
176
|
`translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
|
|
157
177
|
"translate3d(0px, 0px, 0)",
|
|
158
178
|
];
|
|
159
179
|
}
|
|
160
|
-
animation =
|
|
161
|
-
duration: options?.content ?
|
|
180
|
+
animation = target.animate(keyframes, {
|
|
181
|
+
duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
|
|
162
182
|
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
163
183
|
fill: "both",
|
|
164
184
|
});
|
package/src/scripts/route-out.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getRouteMotionTarget } from "./route-motion";
|
|
2
|
+
|
|
1
3
|
/** Fade the old content only after destination assets are ready. */
|
|
2
4
|
export async function animateRouteOut(
|
|
3
5
|
element: HTMLElement | null,
|
|
@@ -10,16 +12,21 @@ export async function animateRouteOut(
|
|
|
10
12
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
11
13
|
)
|
|
12
14
|
return () => {};
|
|
15
|
+
const target = getRouteMotionTarget(element);
|
|
16
|
+
if (!target || typeof target.animate !== "function") return () => {};
|
|
13
17
|
let animation: Animation | undefined;
|
|
14
18
|
const cancel = () => animation?.cancel();
|
|
19
|
+
const mobile = window.matchMedia("(max-width: 768px)").matches;
|
|
15
20
|
try {
|
|
16
|
-
animation =
|
|
17
|
-
|
|
18
|
-
|
|
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" },
|
|
19
26
|
);
|
|
20
27
|
signal.addEventListener("abort", cancel, { once: true });
|
|
21
28
|
await animation.finished;
|
|
22
|
-
// Keep the
|
|
29
|
+
// Keep the bounded outgoing cue until swap; the body stays readable.
|
|
23
30
|
// Swup detaches this node; no persistent shell is animated.
|
|
24
31
|
} catch {
|
|
25
32
|
/* Cancellation and unsupported animation must not block a visit. */
|