修复:增加回到顶部功能,优化博客性能

This commit is contained in:
2026-09-21 09:37:31 +08:00
parent b0e7d40faf
commit 219cb712a6
27 changed files with 845 additions and 373 deletions
+30 -1
View File
@@ -26,7 +26,7 @@
| 代码块 | Expressive Code(行号、折叠、语言徽章、自定义复制按钮) | | 代码块 | Expressive Code(行号、折叠、语言徽章、自定义复制按钮) |
| 数学公式 | remark-math + KaTeX | | 数学公式 | remark-math + KaTeX |
| 站内搜索 | Pagefind(构建时索引) | | 站内搜索 | Pagefind(构建时索引) |
| 页面切换 | Swup | | 页面切换 | Astro View Transitions(`<ClientRouter />`) |
| 部署 | `./deploy.sh`(rsync 到树莓派 + 云服务器) | | 部署 | `./deploy.sh`(rsync 到树莓派 + 云服务器) |
| 评论系统 | 自托管的 [twikoo](https://twikoo.js.org)(见下) | | 评论系统 | 自托管的 [twikoo](https://twikoo.js.org)(见下) |
@@ -60,6 +60,7 @@ src/
├── content/ Markdown 内容集合(见下) ├── content/ Markdown 内容集合(见下)
├── data/ 站点配置:站点信息、个人信息、相册、评论、友链 ├── data/ 站点配置:站点信息、个人信息、相册、评论、友链
├── styles/ 全局 CSS ├── styles/ 全局 CSS
├── scripts/ 浏览器侧交互(lifecycle 契约,见下)
├── plugins/ 自定义 remark / rehype / Expressive Code 插件 ├── plugins/ 自定义 remark / rehype / Expressive Code 插件
├── lib/ hooks/ 支撑逻辑 ├── lib/ hooks/ 支撑逻辑
public/ 原样拷贝到产物根目录的静态资源 public/ 原样拷贝到产物根目录的静态资源
@@ -68,6 +69,34 @@ public/ 原样拷贝到产物根目录的静态资源
scripts/ 内容工具脚本 scripts/ 内容工具脚本
``` ```
## 交互层
除了 React islands,站点还有一批「绑全局监听 / 操作 DOM」的原生脚本(主题切换、
代码复制、github 卡片、灯箱、移动端菜单、评论、回到顶部)。它们都注册在
`src/scripts/interactions.ts`,由 `src/layouts/BaseLayout.astro` 引一次。
因为页面切换走 `<ClientRouter />`(Astro View Transitions),DOM 每次导航都会被换掉,
而**模块脚本只会求值一次**。所以新增交互不要自己监听 `astro:page-load`、也不要用
`window.__xxxBound` 防重复绑定,改用 `src/scripts/lifecycle.ts`:
```ts
import { onPage } from "@/scripts/lifecycle"
onPage("my-widget", () => {
const el = document.querySelector("[data-my-widget]")
if (!el) return // 本页没有这个组件:不挂载,也不报错
const onClick = () => { /* ... */ }
el.addEventListener("click", onClick)
return () => el.removeEventListener("click", onClick) // astro:before-swap 时调用
})
```
`lifecycle` 在 `astro:before-swap` 卸载全部、在 `astro:page-load` 挂载全部,
并保证首屏、切页、模块重复求值这三种情况下都不会重复绑定或漏绑。
页面级的小交互如果**不需要 JS** 就别写 JS:阅读进度条和回到顶部按钮的显隐都由
CSS 滚动驱动动画(`animation-timeline: scroll()`)负责,脚本只管点击与老浏览器降级。
## 写作 ## 写作
内容集合定义在 `src/content/config.ts`,共三个: 内容集合定义在 `src/content/config.ts`,共三个:
+1 -1
View File
@@ -20,7 +20,7 @@ const primaryTag = tags[0]
const primaryTagHref = primaryTag ? `/?tag=${encodeURIComponent(primaryTag)}#home-main` : null const primaryTagHref = primaryTag ? `/?tag=${encodeURIComponent(primaryTag)}#home-main` : null
--- ---
<main class="pt-24 pb-4" data-pagefind-body> <main id="main" tabindex="-1" class="pt-24 pb-4" data-pagefind-body>
<div class="px-6"> <div class="px-6">
<div class="max-w-[44rem] mx-auto"> <div class="max-w-[44rem] mx-auto">
<a <a
-92
View File
@@ -37,98 +37,6 @@ const isEnabled = Boolean(envId)
data-lang={commentConfig.lang} data-lang={commentConfig.lang}
data-strip-trailing-slash={commentConfig.stripTrailingSlash ? "true" : "false"} data-strip-trailing-slash={commentConfig.stripTrailingSlash ? "true" : "false"}
/> />
<script is:inline>
;(() => {
const SCRIPT_SRC = "/assets/js/twikoo.min.js"
/**
* 注意:容器是 <div id="twikoo">,而浏览器会把带 id 的元素暴露成
* window 上的同名全局变量 —— 所以脚本加载前 window.twikoo 就已经
* 是那个 DOM 元素了。这里必须检查真正的 API 是否存在,
* 不能只判断 `if (window.twikoo)`,否则会误以为已加载而跳过。
*/
const isLoaded = () =>
typeof window.twikoo === "object" && typeof window.twikoo?.init === "function"
const loadScript = () => {
if (isLoaded()) return Promise.resolve()
const existing = document.querySelector("script[data-twikoo]")
if (existing) {
if (existing.dataset.loaded === "true") return Promise.resolve()
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener(
"error",
() => reject(new Error("twikoo 脚本加载失败")),
{ once: true },
)
})
}
return new Promise((resolve, reject) => {
const script = document.createElement("script")
script.src = SCRIPT_SRC
script.async = true
script.dataset.twikoo = "true"
script.onload = () => {
script.dataset.loaded = "true"
resolve()
}
script.onerror = () => reject(new Error("twikoo 脚本加载失败: " + SCRIPT_SRC))
document.head.appendChild(script)
})
}
/**
* 评论归属路径。本站 trailingSlash 为 "always",但评论 key 统一去掉
* 结尾斜杠(/posts/xxx),与历史数据保持一致。
*/
const getCommentPath = (strip) => {
const pathname = window.location.pathname
if (strip && pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1)
}
return pathname
}
const mount = async () => {
const container = document.getElementById("twikoo")
if (!container) return
// astro:page-load 在首屏也会触发,用标记避免重复初始化;
// 切页时容器是新节点,没有这个标记,所以会正常重新挂载。
if (container.dataset.mounted === "true") return
const envId = container.dataset.envId
if (!envId) return
container.dataset.mounted = "true"
try {
await loadScript()
await window.twikoo.init({
envId,
el: "#twikoo",
lang: container.dataset.lang || "zh-CN",
path: getCommentPath(container.dataset.stripTrailingSlash === "true"),
})
} catch (error) {
// 失败时解除标记,允许下次切页重试
container.dataset.mounted = "false"
console.error("[twikoo] 初始化失败:", error)
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount)
} else {
mount()
}
document.addEventListener("astro:page-load", mount)
})()
</script>
</div> </div>
</div> </div>
</section> </section>
+20 -13
View File
@@ -8,6 +8,9 @@
* *
* 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效; * 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效;
* 遮罩只在第一次点击时才创建,没点过就不占 DOM。 * 遮罩只在第一次点击时才创建,没点过就不占 DOM。
*
* 监听器的挂载 / 摘除交给 src/scripts/lifecycle.ts(astro:before-swap 时卸载),
* 这里不再需要 `window.__vvLightboxBound` 那套防重复绑定的标记。
*/ */
--- ---
@@ -19,6 +22,7 @@
</style> </style>
<script> <script>
import { onPage } from "@/scripts/lifecycle"
import { openModalFocus } from "@/lib/modal-focus" import { openModalFocus } from "@/lib/modal-focus"
const LB_ID = "vv-lightbox" const LB_ID = "vv-lightbox"
@@ -107,10 +111,8 @@
if (overlay) render(overlay) if (overlay) render(overlay)
} }
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) { onPage("lightbox", () => {
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true const handleClick = (event: MouseEvent) => {
document.addEventListener("click", (event) => {
const target = event.target as Element | null const target = event.target as Element | null
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]") const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
@@ -130,24 +132,29 @@
// 点背景关闭(点图片本身不关) // 点背景关闭(点图片本身不关)
const overlay = document.getElementById(LB_ID) const overlay = document.getElementById(LB_ID)
if (overlay && target === overlay) close() if (overlay && target === overlay) close()
}) }
document.addEventListener("keydown", (event) => { const handleKeydown = (event: KeyboardEvent) => {
const overlay = document.getElementById(LB_ID) const overlay = document.getElementById(LB_ID)
if (!overlay || overlay.classList.contains("hidden")) return if (!overlay || overlay.classList.contains("hidden")) return
if (event.key === "Escape") close() if (event.key === "Escape") close()
if (event.key === "ArrowLeft") step(-1) if (event.key === "ArrowLeft") step(-1)
if (event.key === "ArrowRight") step(1) if (event.key === "ArrowRight") step(1)
}) }
// 切页时收拾干净,避免残留遮罩锁住滚动 document.addEventListener("click", handleClick)
document.addEventListener("astro:page-load", () => { document.addEventListener("keydown", handleKeydown)
// 先还原 inert / 焦点,再移除遮罩,别把上一页的 inert 状态留在新页面上
// 切页前收拾干净:还原背景 inert / 焦点、移除遮罩、解开滚动锁。
// 这一步现在发生在 astro:before-swap(旧 DOM 还在的时候),比原来挂在
// astro:page-load 里补救更早 —— 遮罩不会在新页面上闪一下,也不会残留 inert。
return () => {
document.removeEventListener("click", handleClick)
document.removeEventListener("keydown", handleKeydown)
releaseFocus?.() releaseFocus?.()
releaseFocus = null releaseFocus = null
const overlay = document.getElementById(LB_ID) document.getElementById(LB_ID)?.remove()
if (overlay) overlay.remove()
document.body.style.overflow = "" document.body.style.overflow = ""
})
} }
})
</script> </script>
+1 -1
View File
@@ -106,7 +106,7 @@ const recommendBadge = (status?: string) => {
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-24 pb-24"> <main id="main" tabindex="-1" class="pt-24 pb-24">
<div class="max-w-7xl mx-auto px-4 md:px-8"> <div class="max-w-7xl mx-auto px-4 md:px-8">
<div class="flex flex-col md:flex-row md:gap-10"> <div class="flex flex-col md:flex-row md:gap-10">
+1 -1
View File
@@ -15,7 +15,7 @@ const journey = [
export default function AboutPage() { export default function AboutPage() {
return ( return (
<div className="min-h-screen bg-background dark:bg-surface-subtle"> <div className="min-h-screen bg-background dark:bg-surface-subtle">
<main className="pt-32 pb-8"> <main id="main" tabIndex={-1} className="pt-32 pb-8">
<div className="max-w-4xl mx-auto px-6"> <div className="max-w-4xl mx-auto px-6">
{/* Hero Section */} {/* Hero Section */}
<section className="mb-10"> <section className="mb-10">
+1 -1
View File
@@ -52,7 +52,7 @@ const siteInfoText = [
<div class="min-h-screen bg-background dark:bg-surface-subtle overflow-x-hidden"> <div class="min-h-screen bg-background dark:bg-surface-subtle overflow-x-hidden">
<SiteHeader /> <SiteHeader />
<main class="pt-24 sm:pt-32 pb-8"> <main id="main" tabindex="-1" class="pt-24 sm:pt-32 pb-8">
<div class="max-w-6xl mx-auto px-6"> <div class="max-w-6xl mx-auto px-6">
<section class="mb-3"> <section class="mb-3">
<h1 class="text-3xl sm:text-4xl font-bold text-foreground mb-3 flex items-center gap-3"> <h1 class="text-3xl sm:text-4xl font-bold text-foreground mb-3 flex items-center gap-3">
+31 -41
View File
@@ -268,19 +268,23 @@ const isCurrent = (href: string) =>
</style> </style>
<script> <script>
import { onPage } from "@/scripts/lifecycle"
import { openModalFocus } from "@/lib/modal-focus" import { openModalFocus } from "@/lib/modal-focus"
// Runs on initial page load AND after every Astro View Transition navigation. /**
// DOM nodes are replaced across navigations, so we must rebind listeners each time. * 移动端菜单。
const initMobileMenu = () => { *
* DOM 每次导航都会被换掉,所以「重新绑定」交给 lifecycle:
* astro:before-swap 时先按正常流程关一次菜单(还原背景 inert、body 滚动锁、焦点),
* 再摘掉监听器;新页面挂载时重新绑一遍。
* 原来那套 window.__menuEscBound 标记 + 独立的 astro:before-swap / astro:page-load
* 监听器都不再需要。
*/
onPage("mobile-menu", () => {
const toggle = document.getElementById("mobile-menu-toggle") const toggle = document.getElementById("mobile-menu-toggle")
const menu = document.getElementById("mobile-menu") const menu = document.getElementById("mobile-menu")
if (!toggle || !menu) return if (!toggle || !menu) return
// Prevent double-binding in case this element is preserved across transitions
if (toggle.dataset.menuBound === "1") return
toggle.dataset.menuBound = "1"
let prevOverflow = "" let prevOverflow = ""
/** 关闭时用来还原背景 inert 与焦点 */ /** 关闭时用来还原背景 inert 与焦点 */
let releaseFocus: (() => void) | null = null let releaseFocus: (() => void) | null = null
@@ -316,47 +320,33 @@ const isCurrent = (href: string) =>
} }
} }
toggle.addEventListener("click", () => { const onToggle = () => setOpen(menu.dataset.open !== "true")
setOpen(menu.dataset.open !== "true")
})
menu.addEventListener("click", (event) => { const onMenuClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null const target = event.target as HTMLElement | null
if (!target) return if (!target) return
if (target.closest("[data-dismiss]") || target.closest("a")) { if (target.closest("[data-dismiss]") || target.closest("a")) setOpen(false)
}
// Esc 只在本页菜单打开时响应;原来是一个「只绑一次」的全局监听器,
// 现在随菜单一起挂载 / 卸载,语义更窄也更干净。
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
if (menu.dataset.open !== "true") return
event.preventDefault()
setOpen(false) setOpen(false)
} }
})
// Expose close handler for the module-level Escape listener toggle.addEventListener("click", onToggle)
;(menu as HTMLElement & { __close?: () => void }).__close = () => setOpen(false) menu.addEventListener("click", onMenuClick)
} document.addEventListener("keydown", onKeydown)
// ESC listener is global; bind only once per full page load. return () => {
if (!(window as unknown as { __menuEscBound?: boolean }).__menuEscBound) { // 菜单开着就切页:先走正常关闭流程还原 inert / 滚动锁 / 焦点,再摘监听器
;(window as unknown as { __menuEscBound?: boolean }).__menuEscBound = true setOpen(false)
document.addEventListener("keydown", (event) => { toggle.removeEventListener("click", onToggle)
if (event.key !== "Escape") return menu.removeEventListener("click", onMenuClick)
const menu = document.getElementById("mobile-menu") as document.removeEventListener("keydown", onKeydown)
| (HTMLElement & { __close?: () => void })
| null
if (menu?.dataset.open === "true") {
event.preventDefault()
menu.__close?.()
} }
}) })
}
// Reset body scroll lock on navigation away while menu is open
document.addEventListener("astro:before-swap", () => {
const menu = document.getElementById("mobile-menu") as
| (HTMLElement & { __close?: () => void })
| null
if (menu?.dataset.open === "true") {
// 走 __close 而不是只清 overflow:inert 与焦点也要一起还原
menu.__close?.()
}
})
document.addEventListener("astro:page-load", initMobileMenu)
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"updatedAt": "2026-09-21T00:13:50.916Z", "updatedAt": "2026-09-21T01:32:16.851Z",
"source": "weread", "source": "weread",
"books": [ "books": [
{ {
+43 -182
View File
@@ -176,28 +176,6 @@ const resolvedTwitterDescription = twitterDescription || description
window.__applyTheme = applyTheme; window.__applyTheme = applyTheme;
})(); })();
</script> </script>
<script is:inline>
// 主题切换按钮(无水合也可用)
(function() {
if (window.__themeToggleBound) return;
window.__themeToggleBound = true;
const toggleTheme = () => {
const isDark = document.documentElement.classList.contains('dark');
const next = isDark ? 'light' : 'dark';
localStorage.setItem('theme', next);
document.documentElement.classList.toggle('dark', next === 'dark');
};
window.__toggleTheme = toggleTheme;
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest('[data-theme-toggle]');
if (!button) return;
event.preventDefault();
toggleTheme();
});
})();
</script>
<script is:inline> <script is:inline>
// 预过滤标记:避免带 tag/category 查询的首页渲染闪动 // 预过滤标记:避免带 tag/category 查询的首页渲染闪动
(function() { (function() {
@@ -214,169 +192,52 @@ const resolvedTwitterDescription = twitterDescription || description
<ClientRouter /> <ClientRouter />
</head> </head>
<body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}> <body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}>
{/*
P0-2 跳到正文:键盘/读屏用户的第一个停靠点。样式见 global.css 的 .skip-link,
默认用 transform 移出视口(保留在无障碍树里),聚焦时才落回左上角。
目标锚点 #main 由各页面/内容组件的 <main> 提供;漏加 id 的页面,
浏览器的行为是「跳到页面顶部」,不会报错。
*/}
<a href="#main" class="skip-link">跳到正文</a>
{/*
P0-3 阅读进度:完全由 CSS 滚动驱动动画驱动(见 global.css 的 .reading-progress)。
不支持 animation-timeline 的浏览器不显示这条线,而不是退化成一条永远满格的横杠。
*/}
<div class="reading-progress" aria-hidden="true">
<span class="reading-progress-bar"></span>
</div>
<slot /> <slot />
{/*
P0-3 回到顶部:胶囊形磨砂玻璃。显隐同样默认交给 CSS 滚动驱动动画,
这里的 JS 只管点击滚动 + 老浏览器降级(见 src/scripts/back-to-top.ts)。
*/}
<button type="button" class="back-to-top" data-back-to-top>
<svg
class="back-to-top-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5M5 12l7-7 7 7"></path>
</svg>
<span class="back-to-top-text">回到顶部</span>
</button>
<ImageLightbox /> <ImageLightbox />
<script is:inline>
// View Transitions 页面切换后重应用主题
(function() {
const applyTheme = window.__applyTheme
if (typeof applyTheme !== "function") return
document.addEventListener("astro:page-load", applyTheme)
document.addEventListener("astro:after-swap", applyTheme)
window.addEventListener("pageshow", applyTheme)
})()
</script>
<script is:inline>
// Copy button functionality for Expressive Code
function initCopyButtons() {
document.querySelectorAll('.expressive-code .copy-btn').forEach(function(btn) {
if (btn.dataset.initialized) return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', async function() {
const pre = btn.closest('pre');
if (!pre) return;
const code = pre.querySelector('code');
if (!code) return;
// 只提取 .code 元素的内容,排除行号 (.gutter)
const lines = code.querySelectorAll('.ec-line .code');
const text = Array.from(lines).map(function(line) {
return line.textContent || '';
}).join('\n');
try {
await navigator.clipboard.writeText(text);
btn.classList.add('copied');
setTimeout(function() {
btn.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
});
});
}
document.addEventListener('DOMContentLoaded', initCopyButtons);
document.addEventListener('astro:page-load', initCopyButtons);
</script>
<script is:inline data-astro-rerun>
// GitHub card loader fallback (view transitions safe)
(function() {
const debugEnabled = () => localStorage.getItem("debugGithubCard") === "1"
const debug = (...args) => {
if (debugEnabled()) console.log("[GITHUB-CARD]", ...args)
}
const __gcLangColorsMap = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Java: "#b07219",
"C++": "#f34b7d",
C: "#555555",
"C#": "#178600",
Go: "#00ADD8",
Rust: "#dea584",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Dart: "#00B4AB",
Scala: "#c22d40",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
SCSS: "#c6538c",
Vue: "#41b883",
Svelte: "#ff3e00",
Astro: "#ff5a03",
MDX: "#fcb32c",
Markdown: "#083fa1",
JSON: "#292929",
YAML: "#cb171e",
Dockerfile: "#384d54",
Makefile: "#427819",
Lua: "#000080",
Perl: "#0298c3",
R: "#198CE7",
Julia: "#a270ba",
Haskell: "#5e5086",
Elixir: "#6e4a7e",
Clojure: "#db5855",
Objective_C: "#438eff",
"Objective-C": "#438eff",
Jupyter_Notebook: "#DA5B0B",
"Jupyter Notebook": "#DA5B0B",
Vim_Script: "#199f4b",
TeX: "#3D6117",
PowerShell: "#012456",
}
const formatNumber = (value) => { <script>
return Intl.NumberFormat("en-us", { notation: "compact", maximumFractionDigits: 1 }) // 交互层统一从 lifecycle 挂载 / 卸载(见 src/scripts/lifecycle.ts)。
.format(value) // 原来是四段各自 is:inline、各自用 window.__xxxBound 防重复绑定的脚本;
.replaceAll("\u202f", "") // 现在聚合到一个 module 入口,切页时由 astro:before-swap 统一清理。
} import "@/scripts/interactions"
const initGithubCards = (source = "unknown") => {
const cards = Array.from(document.querySelectorAll("[data-github-card][data-github-repo]"))
debug("init", source, "cards:", cards.length)
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) return
if (card.dataset.githubLoaded === "true") return
const repo = card.dataset.githubRepo
if (!repo) return
card.dataset.githubLoaded = "loading"
debug("fetch", repo)
fetch(`https://api.github.com/repos/${repo}`, { referrerPolicy: "no-referrer" })
.then((response) => response.json())
.then((data) => {
const descEl = card.querySelector(".gc-description")
if (descEl) {
descEl.textContent =
data.description?.replace(/:[a-zA-Z0-9_]+:/g, "") || "No description provided"
}
const langEl = card.querySelector(".gc-language")
const lang = data.language || ""
if (langEl) {
langEl.textContent = lang || "—"
if (lang && __gcLangColorsMap[lang]) {
langEl.style.setProperty("--gc-lang-color", __gcLangColorsMap[lang])
}
}
const forksEl = card.querySelector(".gc-forks")
if (forksEl) forksEl.textContent = formatNumber(data.forks || 0)
const starsEl = card.querySelector(".gc-stars")
if (starsEl) starsEl.textContent = formatNumber(data.stargazers_count || 0)
const licenseEl = card.querySelector(".gc-license")
if (licenseEl) licenseEl.textContent = data.license?.spdx_id || "—"
const avatarEl = card.querySelector(".gc-avatar")
if (avatarEl && data.owner?.avatar_url) {
avatarEl.style.backgroundImage = `url(${data.owner.avatar_url})`
avatarEl.style.backgroundColor = "transparent"
}
card.classList.remove("fetch-waiting")
card.dataset.githubLoaded = "true"
debug("loaded", repo)
})
.catch(() => {
card.classList.add("fetch-error")
card.dataset.githubLoaded = "error"
debug("error", repo)
})
})
}
document.addEventListener("DOMContentLoaded", () => initGithubCards("dom"))
document.addEventListener("astro:page-load", () => initGithubCards("page-load"))
document.addEventListener("astro:after-swap", () => initGithubCards("after-swap"))
window.addEventListener("pageshow", () => initGithubCards("pageshow"))
window.__initGithubCards = initGithubCards
})()
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -35,7 +35,7 @@ const entries = [
<BaseLayout {title} {description} robots="noindex" fontProfile="interface"> <BaseLayout {title} {description} robots="noindex" fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main id="main" class="mx-auto flex min-h-[70vh] max-w-2xl flex-col justify-center px-6 pt-24 pb-20"> <main id="main" tabindex="-1" class="mx-auto flex min-h-[70vh] max-w-2xl flex-col justify-center px-6 pt-24 pb-20">
<p class="mb-4 font-mono text-sm tracking-[0.25em] text-primary">404</p> <p class="mb-4 font-mono text-sm tracking-[0.25em] text-primary">404</p>
<h1 class="mb-4 flex items-center gap-3 text-2xl font-semibold text-foreground sm:text-3xl"> <h1 class="mb-4 flex items-center gap-3 text-2xl font-semibold text-foreground sm:text-3xl">
+1 -1
View File
@@ -31,7 +31,7 @@ const title =
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-surface-subtle"> <div class="min-h-screen bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-24"> <main id="main" tabindex="-1" class="pt-24">
{/* 同 index.astro:手机端落点贴页眉下沿(列表开头是吸顶胶囊条),lg 以上保留呼吸位 */} {/* 同 index.astro:手机端落点贴页眉下沿(列表开头是吸顶胶囊条),lg 以上保留呼吸位 */}
<section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24"> <section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24">
<ArticleList <ArticleList
+1 -1
View File
@@ -31,7 +31,7 @@ const description = album.description ?? "相册"
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-20 pb-16"> <main id="main" tabindex="-1" class="pt-20 pb-16">
{/* 封面横幅 */} {/* 封面横幅 */}
<section class="relative w-full overflow-hidden"> <section class="relative w-full overflow-hidden">
{cover ? ( {cover ? (
+1 -1
View File
@@ -15,7 +15,7 @@ const allTags = getAllAlbumTags(albums)
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-24 sm:pt-32 pb-16"> <main id="main" tabindex="-1" class="pt-24 sm:pt-32 pb-16">
<div class="max-w-6xl mx-auto px-6"> <div class="max-w-6xl mx-auto px-6">
{/* 页头 */} {/* 页头 */}
<section class="mb-8"> <section class="mb-8">
+1 -1
View File
@@ -21,7 +21,7 @@ const thoughtPreviews = thoughts.map((thought) => toThoughtPreview(thought)).sli
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main> <main id="main" tabindex="-1">
<Hero client:idle /> <Hero client:idle />
{/* {/*
手机端锚点落点要贴页眉下沿:这一屏列表的第一个元素是吸顶胶囊条 手机端锚点落点要贴页眉下沿:这一屏列表的第一个元素是吸顶胶囊条
+1 -1
View File
@@ -20,7 +20,7 @@ const description = study.epigraph ?? ""
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore> <div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore>
<SiteHeader /> <SiteHeader />
<main class="pt-20"> <main id="main" tabindex="-1" class="pt-20">
<StudyDetailView client:idle study={study} /> <StudyDetailView client:idle study={study} />
</main> </main>
<Footer /> <Footer />
+1 -1
View File
@@ -15,7 +15,7 @@ const studies = await getAllStudies()
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore> <div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore>
<SiteHeader /> <SiteHeader />
<main class="pt-20"> <main id="main" tabindex="-1" class="pt-20">
<StudiesList client:idle studies={studies} /> <StudiesList client:idle studies={studies} />
</main> </main>
<Footer /> <Footer />
+1 -1
View File
@@ -16,7 +16,7 @@ const thoughtsWithContent = allThoughts.map((thought) => toThoughtMeta(thought))
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-20"> <main id="main" tabindex="-1" class="pt-20">
<section id="thoughts-main" class="scroll-mt-24"> <section id="thoughts-main" class="scroll-mt-24">
<ThoughtsView client:idle thoughts={thoughtsWithContent} /> <ThoughtsView client:idle thoughts={thoughtsWithContent} />
</section> </section>
+1 -1
View File
@@ -21,7 +21,7 @@ const timelineItems = posts.map((post) => ({
<BaseLayout {title} {description} fontProfile="interface"> <BaseLayout {title} {description} fontProfile="interface">
<div class="min-h-screen bg-background dark:bg-surface-subtle"> <div class="min-h-screen bg-background dark:bg-surface-subtle">
<SiteHeader /> <SiteHeader />
<main class="pt-24"> <main id="main" tabindex="-1" class="pt-24">
<Timeline items={timelineItems} /> <Timeline items={timelineItems} />
</main> </main>
<Footer /> <Footer />
+59
View File
@@ -0,0 +1,59 @@
import { onPage } from "./lifecycle"
/**
* 回到顶部按钮。
*
* 显隐**默认交给 CSS**:`animation-timeline: scroll(root block)` 让按钮随滚动位置
* 淡入,不需要任何 scroll 监听(见 global.css 的 .back-to-top)。
* 这里只负责两件事:
* 1. 点击后平滑回到顶部;
* 2. 给不支持滚动驱动动画的浏览器(Firefox < 144 / Safari < 26)做降级。
*/
/** 与 global.css 里 animation-range 的起点保持一致 */
const REVEAL_AFTER_PX = 480
const supportsScrollTimeline = () =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: scroll()")
onPage("back-to-top", () => {
const button = document.querySelector<HTMLElement>("[data-back-to-top]")
if (!button) return
const onClick = () => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" })
}
button.addEventListener("click", onClick)
if (supportsScrollTimeline()) {
return () => button.removeEventListener("click", onClick)
}
// ——— 降级路径 ———
// rAF 节流,且只在跨越阈值时才写 class:滚动本身不产生布局读取(只用 scrollY)
const root = document.documentElement
root.classList.add("no-scroll-timeline")
let frame = 0
const update = () => {
frame = 0
button.classList.toggle("is-visible", window.scrollY > REVEAL_AFTER_PX)
}
const onScroll = () => {
if (frame) return
frame = window.requestAnimationFrame(update)
}
window.addEventListener("scroll", onScroll, { passive: true })
update()
return () => {
window.removeEventListener("scroll", onScroll)
if (frame) window.cancelAnimationFrame(frame)
button.removeEventListener("click", onClick)
root.classList.remove("no-scroll-timeline")
}
})
+95
View File
@@ -0,0 +1,95 @@
import { onPage } from "./lifecycle"
/**
* twikoo 评论区的挂载。
*
* 原来这段逻辑内联在 Comments.astro 里,靠 `DOMContentLoaded` + `astro:page-load`
* 双重触发、再用容器上的 `dataset.mounted` 去重。现在只声明「怎么挂载」,
* 节流与清理交给 lifecycle。
*
* 两个容易踩的坑(来自原来的注释,保留在这里免得以后重蹈):
*
* 1. 容器是 `<div id="twikoo">`,浏览器会把带 id 的元素暴露成 `window.twikoo`
* —— 所以脚本加载前 `window.twikoo` **就已经是那个 DOM 元素了**。判断是否
* 加载完成必须检查真正的 API(`init` 是不是函数),不能只判断 `if (window.twikoo)`。
* 2. twikoo 的基础样式必须由静态 `<link>` 引入(见 Comments.astro):脚本自己只会
* 往 head 里注入一次样式,而 ClientRouter 切页会清掉它不认识的 `<style>`,
* 表现为「从一篇带评论的文章点进另一篇后评论区排版全崩」。
*/
const SCRIPT_SRC = "/assets/js/twikoo.min.js"
type TwikooApi = { init: (options: Record<string, unknown>) => Promise<unknown> }
const twikooApi = (): TwikooApi | null => {
const candidate = (window as unknown as { twikoo?: unknown }).twikoo
if (candidate && typeof candidate === "object" && typeof (candidate as TwikooApi).init === "function") {
return candidate as TwikooApi
}
return null
}
const loadScript = () => {
if (twikooApi()) return Promise.resolve()
const existing = document.querySelector<HTMLScriptElement>("script[data-twikoo]")
if (existing) {
if (existing.dataset.loaded === "true") return Promise.resolve()
return new Promise<void>((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener("error", () => reject(new Error("twikoo 脚本加载失败")), { once: true })
})
}
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.src = SCRIPT_SRC
script.async = true
script.dataset.twikoo = "true"
script.onload = () => {
script.dataset.loaded = "true"
resolve()
}
script.onerror = () => reject(new Error("twikoo 脚本加载失败: " + SCRIPT_SRC))
document.head.appendChild(script)
})
}
/**
* 评论归属路径。本站 trailingSlash 为 "always",但评论 key 统一去掉结尾斜杠
* (/posts/xxx),与历史数据保持一致。
*/
const getCommentPath = (strip: boolean) => {
const { pathname } = window.location
if (strip && pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1)
return pathname
}
onPage("comments", () => {
const container = document.getElementById("twikoo")
if (!container) return
const envId = container.dataset.envId
if (!envId) return
// 首屏与 astro:page-load 都可能触发挂载,用容器标记兜住重复初始化
if (container.dataset.mounted === "true") return
container.dataset.mounted = "true"
loadScript()
.then(() => {
// 脚本加载期间用户可能已经切页,容器被摘出文档就别再初始化了
if (!container.isConnected) return
return twikooApi()?.init({
envId,
el: "#twikoo",
lang: container.dataset.lang || "zh-CN",
path: getCommentPath(container.dataset.stripTrailingSlash === "true"),
})
})
.catch((error) => {
// 失败时解除标记,下次挂载可以重试
container.dataset.mounted = "false"
console.error("[twikoo] 初始化失败:", error)
})
})
+38 -27
View File
@@ -1,33 +1,44 @@
// Copy button functionality for Expressive Code import { onPage } from "./lifecycle"
export function initCopyButtons() {
document.querySelectorAll('.expressive-code .copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const pre = btn.closest('pre');
if (!pre) return;
const code = pre.querySelector('code'); /**
if (!code) return; * Expressive Code 代码块的复制按钮。
*
* 用事件委托而不是逐个按钮绑定:切页后代码块是全新节点,委托挂在 `document` 上
* 由 lifecycle 负责摘挂,既不需要 `dataset.initialized` 标记,也不会遗漏
* 懒加载进来的代码块。
*/
const text = code.textContent || ''; /** 与 global.css 里 `.copy-btn.copied` 的反馈时长保持一致 */
const COPIED_FEEDBACK_MS = 2000
onPage("copy-buttons", () => {
const onClick = async (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Element)) return
const button = target.closest<HTMLElement>(".expressive-code .copy-btn")
if (!button) return
const code = button.closest("pre")?.querySelector("code")
if (!code) return
// 只取 .code 的内容:带上 .gutter 的话行号会被一起复制进去
const lines = code.querySelectorAll(".ec-line .code")
const text = lines.length
? Array.from(lines)
.map((line) => line.textContent ?? "")
.join("\n")
: (code.textContent ?? "")
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
btn.classList.add('copied'); button.classList.add("copied")
setTimeout(() => { window.setTimeout(() => button.classList.remove("copied"), COPIED_FEEDBACK_MS)
btn.classList.remove('copied'); } catch (error) {
}, 2000); console.error("Failed to copy:", error)
} catch (err) { }
console.error('Failed to copy:', err);
} }
});
});
}
// Auto-initialize on DOM ready document.addEventListener("click", onClick)
if (typeof document !== 'undefined') { return () => document.removeEventListener("click", onClick)
if (document.readyState === 'loading') { })
document.addEventListener('DOMContentLoaded', initCopyButtons);
} else {
initCopyButtons();
}
}
+129
View File
@@ -0,0 +1,129 @@
import { onPage } from "./lifecycle"
/**
* GitHub 仓库卡片:把 `[data-github-card][data-github-repo]` 的占位内容换成实时数据。
*
* 卡片是构建时渲染的静态骨架(见 Markdown 里的 ::github 语法),这里只做「补数据」。
* 每张卡片用 `dataset.githubLoaded` 标记进度,所以切页重挂时不会重复请求同一个仓库
* —— 这个标记是必要的:lifecycle 每页都会重新调用挂载函数。
*/
const LANG_COLORS: Record<string, string> = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Java: "#b07219",
"C++": "#f34b7d",
C: "#555555",
"C#": "#178600",
Go: "#00ADD8",
Rust: "#dea584",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Dart: "#00B4AB",
Scala: "#c22d40",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
SCSS: "#c6538c",
Vue: "#41b883",
Svelte: "#ff3e00",
Astro: "#ff5a03",
MDX: "#fcb32c",
Markdown: "#083fa1",
JSON: "#292929",
YAML: "#cb171e",
Dockerfile: "#384d54",
Makefile: "#427819",
Lua: "#000080",
Perl: "#0298c3",
R: "#198CE7",
Julia: "#a270ba",
Haskell: "#5e5086",
Elixir: "#6e4a7e",
Clojure: "#db5855",
Objective_C: "#438eff",
"Objective-C": "#438eff",
Jupyter_Notebook: "#DA5B0B",
"Jupyter Notebook": "#DA5B0B",
Vim_Script: "#199f4b",
TeX: "#3D6117",
PowerShell: "#012456",
}
type GithubRepo = {
description?: string
language?: string
forks?: number
stargazers_count?: number
license?: { spdx_id?: string }
owner?: { avatar_url?: string }
}
const formatNumber = (value: number) =>
Intl.NumberFormat("en-us", { notation: "compact", maximumFractionDigits: 1 })
.format(value)
.replaceAll("\u202f", "")
const debugEnabled = () => localStorage.getItem("debugGithubCard") === "1"
const debug = (...args: unknown[]) => {
if (debugEnabled()) console.log("[GITHUB-CARD]", ...args)
}
const initGithubCards = () => {
const cards = Array.from(document.querySelectorAll<HTMLElement>("[data-github-card][data-github-repo]"))
debug("init cards:", cards.length)
cards.forEach((card) => {
// 已经加载成功 / 正在加载 / 已失败过的卡片都跳过,避免切页重挂时重复请求
if (card.dataset.githubLoaded) return
const repo = card.dataset.githubRepo
if (!repo) return
card.dataset.githubLoaded = "loading"
fetch(`https://api.github.com/repos/${repo}`, { referrerPolicy: "no-referrer" })
.then((response) => response.json() as Promise<GithubRepo>)
.then((data) => {
const setText = (selector: string, value: string) => {
const el = card.querySelector(selector)
if (el) el.textContent = value
}
setText(".gc-description", data.description?.replace(/:[a-zA-Z0-9_]+:/g, "") || "No description provided")
setText(".gc-forks", formatNumber(data.forks || 0))
setText(".gc-stars", formatNumber(data.stargazers_count || 0))
setText(".gc-license", data.license?.spdx_id || "—")
const language = data.language || ""
const langEl = card.querySelector<HTMLElement>(".gc-language")
if (langEl) {
langEl.textContent = language || "—"
if (language && LANG_COLORS[language]) {
langEl.style.setProperty("--gc-lang-color", LANG_COLORS[language])
}
}
const avatarEl = card.querySelector<HTMLElement>(".gc-avatar")
if (avatarEl && data.owner?.avatar_url) {
avatarEl.style.backgroundImage = `url(${data.owner.avatar_url})`
avatarEl.style.backgroundColor = "transparent"
}
card.classList.remove("fetch-waiting")
card.dataset.githubLoaded = "true"
debug("loaded", repo)
})
.catch(() => {
card.classList.add("fetch-error")
card.dataset.githubLoaded = "error"
debug("error", repo)
})
})
}
onPage("github-cards", () => {
initGithubCards()
})
+16
View File
@@ -0,0 +1,16 @@
/**
* 交互层入口。
*
* 全站的「绑定全局监听 / 操作 DOM」类脚本都在这里汇总,由 BaseLayout 引一次。
* 每个模块自己负责 `onPage(key, mount)` 注册,并返回卸载函数 —— 见 lifecycle.ts。
*
* 注意这里**不要**写 `import "..."` 之外的逻辑:入口只做聚合,任何一段交互出问题
* 都应该能单独从这个列表里摘掉,而不影响其他交互。
*/
import "./theme"
import "./copy-button"
import "./github-cards"
import "./back-to-top"
// 评论区:只有页面上真的存在 #twikoo 时才会做事,twikoo 库本身仍是按需拉取
import "./comments"
+126
View File
@@ -0,0 +1,126 @@
/**
* 页面生命周期注册表 —— 所有需要「绑定全局监听 / 操作 DOM」的脚本都从这里挂载。
*
* ## 为什么要有这个文件
*
* 本站用 Astro 的 ClientRouter 做切页,DOM 每次导航都会被换掉,但**模块脚本只会
* 求值一次**。于是每加一个交互,就得自己回答三个问题:什么时候挂载、切页时怎么
* 清理、怎么防止重复绑定。原来的答案是「各自监听 astro:page-load + 一个
* `window.__xxxBound` 全局布尔量」,结果是:
*
* - 绑在 `document` 上的监听器**永远不会被摘掉**,只在 `astro:page-load` 里
* 再加一层。旧闭包捕获的元素早就从 DOM 里消失了,却还在每次点击/按键时跑一遍;
* - 没有任何地方负责卸载。弹层开着就切页时,背景的 `inert`、`body` 的
* `overflow: hidden`、遮罩节点都可能残留到新页面上。
*
* 现在每个交互只声明「怎么挂载」,顺带返回「怎么卸载」:
*
* ```ts
* import { onPage } from "@/scripts/lifecycle"
*
* onPage("my-widget", () => {
* const el = document.querySelector("[data-my-widget]")
* if (!el) return // 本页没有这个组件:不挂载,也不报错
* const onClick = () => { ... }
* el.addEventListener("click", onClick)
* return () => el.removeEventListener("click", onClick) // 切页前调用
* })
* ```
*
* ## 时序
*
* - `astro:before-swap`:先卸载**全部**已挂载项(此时旧 DOM 还在,弹层能正常还原
* 焦点与 inert),然后清空挂载状态;
* - 新页面的脚本在 swap 过程中求值并注册;
* - `astro:page-load`:挂载全部。
*
* 注册表本身**不清空**,因为不能保证模块脚本每次切页都会重新求值:某个组件只在
* 第一页出现过、之后靠注册表里的旧条目继续在后续页面挂载,是刻意留的行为
* (挂载函数自己负责在元素不存在时直接返回)。同名 key 重复注册会覆盖旧条目,
* 因此脚本万一真的重新求值也不会累积。
*/
type Teardown = () => void
type Mount = () => void | Teardown
const registry = new Map<string, Mount>()
const teardowns = new Map<string, Teardown>()
const mounted = new Set<string>()
let autoKey = 0
/** 当前这一页是否已经跑过挂载阶段,用来接住「注册得比 astro:page-load 还晚」的脚本 */
let pageMounted = false
const runTeardown = (key: string) => {
const off = teardowns.get(key)
teardowns.delete(key)
if (!off) return
try {
off()
} catch (error) {
// 卸载出错不能连累其他脚本,否则残留的 inert / overflow 就再也清不掉了
console.error(`[lifecycle] "${key}" 卸载失败:`, error)
}
}
const mountOne = (key: string) => {
if (mounted.has(key)) return
const mount = registry.get(key)
if (!mount) return
mounted.add(key)
try {
const off = mount()
if (typeof off === "function") teardowns.set(key, off)
} catch (error) {
// 同理:一个脚本挂载失败不该拖垮同页面的其他交互
console.error(`[lifecycle] "${key}" 挂载失败:`, error)
}
}
const mountAll = () => {
pageMounted = true
for (const key of registry.keys()) mountOne(key)
}
const unmountAll = () => {
pageMounted = false
for (const key of Array.from(teardowns.keys())) runTeardown(key)
mounted.clear()
}
/**
* 注册一个交互。
*
* @param key 稳定且唯一的名字。重复注册会覆盖上一条(而不是叠加)。
* @param mount 挂载函数;返回的函数会在切页前被调用,用来卸载。
*/
export function onPage(key: string, mount: Mount): void
export function onPage(mount: Mount): void
export function onPage(keyOrMount: string | Mount, maybeMount?: Mount): void {
const key = typeof keyOrMount === "string" ? keyOrMount : `__auto:${autoKey++}`
const mount = typeof keyOrMount === "string" ? maybeMount : keyOrMount
if (typeof mount !== "function") {
throw new TypeError(`[lifecycle] "${key}" 缺少挂载函数`)
}
// 覆盖注册:先卸旧的,避免同一页面里新旧两份监听器并存
if (mounted.has(key)) {
runTeardown(key)
mounted.delete(key)
}
registry.set(key, mount)
if (pageMounted) mountOne(key)
}
document.addEventListener("astro:before-swap", unmountAll)
document.addEventListener("astro:page-load", mountAll)
// 首屏:处理过的 <script> 是 module,求值时 DOM 已经解析完,直接挂载即可;
// 另外 astro:page-load 在首屏也会触发,靠 mounted 集合保证不会挂第二遍。
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountAll, { once: true })
} else {
mountAll()
}
+38
View File
@@ -0,0 +1,38 @@
import { onPage } from "./lifecycle"
/**
* 主题切换。
*
* 「防止刷新闪白」的那段内联脚本留在 BaseLayout 的 `<head>` 里(必须在首次绘制前
* 同步执行,不能变成 module),这里只负责点击委托,以及切页后重新应用主题。
*/
const head = () => window as unknown as { __applyTheme?: () => void }
/** 切页后 `<html>` 不一定保留着上一页的 class,重新按 localStorage / 系统偏好落一次 */
const applyTheme = () => head().__applyTheme?.()
onPage("theme-toggle", () => {
const onClick = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Element)) return
if (!target.closest("[data-theme-toggle]")) return
event.preventDefault()
const next = document.documentElement.classList.contains("dark") ? "light" : "dark"
try {
localStorage.setItem("theme", next)
} catch {
// 隐私模式下 localStorage 可能直接抛异常;主题仍然应该切过去
}
document.documentElement.classList.toggle("dark", next === "dark")
}
document.addEventListener("click", onClick)
applyTheme()
return () => document.removeEventListener("click", onClick)
})
// bfcache 恢复不走 ClientRouter,不会触发 astro:page-load,得单独兜一次
window.addEventListener("pageshow", applyTheme)
+203
View File
@@ -1069,3 +1069,206 @@ a.card-github:hover .github-logo {
.thoughts-timeline-body li + li { margin-top: 4px; } .thoughts-timeline-body li + li { margin-top: 4px; }
.thoughts-timeline-body li > ul, .thoughts-timeline-body li > ul,
.thoughts-timeline-body li > ol { margin: 4px 0; } .thoughts-timeline-body li > ol { margin: 4px 0; }
/* ============================================================================
* 交互层:跳到正文 / 阅读进度 / 回到顶部
*
* 这一节的三样东西都属于「P0 基础交互」,共同点是**尽量不依赖 JS**:
* 前两个完全由 CSS 负责,回到顶部只在老浏览器上才走降级脚本。
* ========================================================================== */
/* ——— 跳到正文 ———
* 默认用 transform 移出视口而不是 display:none / visibility:hidden:
* 后者会同时把它从无障碍树和 Tab 顺序里摘掉,那这个链接就永远按不到了。
* 用 translate 只是把它画到屏幕外,键盘仍然能聚焦(WCAG 2.4.1 跳过重复区块)。
*/
.skip-link {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 200;
padding: 0.5rem 0.875rem;
border: 1px solid var(--border);
border-radius: 9999px;
background-color: var(--card);
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
box-shadow: 0 10px 30px -12px oklch(0 0 0 / 0.35);
transform: translateY(calc(-100% - 1rem));
transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.skip-link:focus-visible,
.skip-link:focus {
transform: none;
outline: 2px solid color-mix(in oklch, var(--primary) 45%, transparent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.skip-link { transition-duration: 1ms; }
}
/* ——— 阅读进度条 ———
* 位置:视口最顶端 2px。z-index 55 夹在页眉(50)和移动端菜单(60)之间,
* 所以它会压在页眉上边缘,但打开菜单 / 灯箱时会被正确遮住。
*
* 用滚动驱动动画(animation-timeline: scroll())而不是 scroll 事件:
* 不占主线程、不产生强制同步布局,而且**文档不可滚动时时间线是 inactive**,
* 进度条自然停在 scaleX(0) —— 短页面不会出现一条莫名其妙的满格横杠。
* 不支持的浏览器(Firefox < 144)就整条不显示,不做 JS 兜底:
* 它只是锦上添花的信息,不值得为此常驻一个 scroll 监听。
*/
.reading-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 55;
height: 2px;
pointer-events: none;
}
.reading-progress-bar {
display: block;
height: 100%;
background-color: var(--primary);
transform: scaleX(0);
transform-origin: 0 50%;
}
@keyframes reading-progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@supports (animation-timeline: scroll()) {
.reading-progress-bar {
animation: reading-progress-grow linear both;
animation-timeline: scroll(root block);
}
}
/* ——— 回到顶部 ———
* 胶囊形磨砂玻璃。
*
* 关于 backdrop-filter 的成本:页眉在 2026-09-20 主动去掉了模糊(iPad Air 4 上
* 全幅宽 24px 模糊每帧重算,滚动掉帧)。这里仍然用模糊,理由是**面积**:
* 这个按钮约 40×110 CSS px,不到页眉的 1/50,模糊半径也从 24px 降到 10px。
* 如果哪天真在低端设备上观察到掉帧,第一个该降级的就是它(见下面的
* prefers-reduced-transparency 分支,改成实色即可)。
*
* 显隐默认由滚动驱动动画负责(不需要 scroll 监听);只有不支持
* animation-timeline 的浏览器才会由 back-to-top.ts 加 .is-visible 走降级路径。
*/
.back-to-top {
position: fixed;
right: max(1.25rem, env(safe-area-inset-right, 0px));
bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
z-index: 55;
display: inline-flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding-inline: 0.875rem;
border: 1px solid color-mix(in oklch, var(--border) 80%, transparent);
border-radius: 9999px;
/* 兜底:不支持 backdrop-filter 时用实色,保证文字对比度 */
background-color: var(--card);
color: var(--content-secondary);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.01em;
box-shadow: 0 10px 28px -14px oklch(0 0 0 / 0.4);
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px) scale(0.96);
transition:
opacity 200ms ease,
transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
/* 隐藏时把 visibility 推迟到淡出结束,避免按钮在半透明状态就失去交互 */
visibility 0s linear 200ms,
color 160ms ease,
background-color 160ms ease,
border-color 160ms ease;
}
.back-to-top:hover {
color: var(--foreground);
border-color: color-mix(in oklch, var(--primary) 35%, var(--border));
}
.back-to-top:active {
/* 位移动画在支持滚动时间线的浏览器里会被动画覆盖,所以按压反馈主要靠底色 */
transform: translateY(1px) scale(0.985);
background-color: color-mix(in oklch, var(--card) 82%, transparent);
}
.back-to-top:focus-visible {
outline: 2px solid color-mix(in oklch, var(--primary) 45%, transparent);
outline-offset: 2px;
}
.back-to-top-icon {
width: 1rem;
height: 1rem;
flex: none;
}
.back-to-top-text {
white-space: nowrap;
}
@supports ((backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px))) {
.back-to-top {
background-color: color-mix(in oklch, var(--card) 58%, transparent);
-webkit-backdrop-filter: blur(10px) saturate(160%);
backdrop-filter: blur(10px) saturate(160%);
}
}
@supports (animation-timeline: scroll()) {
.back-to-top {
/* 显隐交给滚动时间线,这里只留下颜色类过渡 */
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
animation: back-to-top-reveal linear both;
animation-timeline: scroll(root block);
/* 滚过 320px 开始淡入,620px 完全就位(与 back-to-top.ts 的 480px 阈值呼应) */
animation-range: 320px 620px;
}
}
@keyframes back-to-top-reveal {
from {
opacity: 0;
visibility: hidden;
transform: translateY(10px) scale(0.96);
}
to {
opacity: 1;
visibility: visible;
transform: none;
}
}
/* 降级路径:只有 back-to-top.ts 判定不支持滚动时间线时才会加上这个 class */
.no-scroll-timeline .back-to-top.is-visible {
opacity: 1;
visibility: visible;
transform: none;
}
/* 系统级「降低透明度」:改用实色,别在磨砂上再叠一层看不清的文字 */
@media (prefers-reduced-transparency: reduce) {
.back-to-top {
background-color: var(--card);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
@media (prefers-reduced-motion: reduce) {
.back-to-top {
transition-duration: 1ms;
}
/* 滚动驱动动画不产生自主运动,进度条保留;按钮的位移动画去掉 */
.back-to-top:active {
transform: none;
}
}