Compare commits
3
Commits
081d5fa65f
...
219cb712a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
219cb712a6 | ||
|
|
b0e7d40faf | ||
|
|
df81abb303 |
@@ -26,7 +26,7 @@
|
||||
| 代码块 | Expressive Code(行号、折叠、语言徽章、自定义复制按钮) |
|
||||
| 数学公式 | remark-math + KaTeX |
|
||||
| 站内搜索 | Pagefind(构建时索引) |
|
||||
| 页面切换 | Swup |
|
||||
| 页面切换 | Astro View Transitions(`<ClientRouter />`) |
|
||||
| 部署 | `./deploy.sh`(rsync 到树莓派 + 云服务器) |
|
||||
| 评论系统 | 自托管的 [twikoo](https://twikoo.js.org)(见下) |
|
||||
|
||||
@@ -60,6 +60,7 @@ src/
|
||||
├── content/ Markdown 内容集合(见下)
|
||||
├── data/ 站点配置:站点信息、个人信息、相册、评论、友链
|
||||
├── styles/ 全局 CSS
|
||||
├── scripts/ 浏览器侧交互(lifecycle 契约,见下)
|
||||
├── plugins/ 自定义 remark / rehype / Expressive Code 插件
|
||||
├── lib/ hooks/ 支撑逻辑
|
||||
public/ 原样拷贝到产物根目录的静态资源
|
||||
@@ -68,6 +69,34 @@ public/ 原样拷贝到产物根目录的静态资源
|
||||
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`,共三个:
|
||||
|
||||
@@ -20,7 +20,7 @@ const primaryTag = tags[0]
|
||||
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="max-w-[44rem] mx-auto">
|
||||
<a
|
||||
|
||||
@@ -37,98 +37,6 @@ const isEnabled = Boolean(envId)
|
||||
data-lang={commentConfig.lang}
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
*
|
||||
* 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效;
|
||||
* 遮罩只在第一次点击时才创建,没点过就不占 DOM。
|
||||
*
|
||||
* 监听器的挂载 / 摘除交给 src/scripts/lifecycle.ts(astro:before-swap 时卸载),
|
||||
* 这里不再需要 `window.__vvLightboxBound` 那套防重复绑定的标记。
|
||||
*/
|
||||
---
|
||||
|
||||
@@ -19,6 +22,9 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { onPage } from "@/scripts/lifecycle"
|
||||
import { openModalFocus } from "@/lib/modal-focus"
|
||||
|
||||
const LB_ID = "vv-lightbox"
|
||||
|
||||
const ensureOverlay = (): HTMLElement => {
|
||||
@@ -29,8 +35,10 @@
|
||||
overlay.id = LB_ID
|
||||
overlay.setAttribute("role", "dialog")
|
||||
overlay.setAttribute("aria-modal", "true")
|
||||
overlay.setAttribute("aria-label", "图片预览")
|
||||
overlay.tabIndex = -1
|
||||
overlay.className =
|
||||
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90"
|
||||
overlay.innerHTML = `
|
||||
<button type="button" data-lb-close aria-label="关闭"
|
||||
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
|
||||
@@ -49,6 +57,8 @@
|
||||
let sources: string[] = []
|
||||
let alts: string[] = []
|
||||
let current = 0
|
||||
/** 关闭时用来还原背景 inert 与焦点 */
|
||||
let releaseFocus: (() => void) | null = null
|
||||
|
||||
const render = (overlay: HTMLElement) => {
|
||||
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
|
||||
@@ -65,13 +75,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
const openAt = (index: number) => {
|
||||
const openAt = (index: number, trigger?: HTMLElement | null) => {
|
||||
const overlay = ensureOverlay()
|
||||
current = index
|
||||
render(overlay)
|
||||
overlay.classList.remove("hidden")
|
||||
overlay.classList.add("flex")
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
// 遮罩声明了 role="dialog" / aria-modal="true",就得真做到两件事:
|
||||
// 把焦点移进来、并让背景不可交互。否则键盘用户 Tab 到的是被盖住的正文,
|
||||
// 那三个按钮(关闭/上一张/下一张)根本够不着。
|
||||
releaseFocus?.()
|
||||
releaseFocus = openModalFocus(overlay, {
|
||||
initialFocus: overlay.querySelector<HTMLElement>("[data-lb-close]"),
|
||||
restoreFocus: trigger ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
@@ -80,6 +99,9 @@
|
||||
overlay.classList.add("hidden")
|
||||
overlay.classList.remove("flex")
|
||||
document.body.style.overflow = ""
|
||||
// 还原背景 inert,并把焦点还给打开灯箱的那个元素
|
||||
releaseFocus?.()
|
||||
releaseFocus = null
|
||||
}
|
||||
|
||||
const step = (delta: number) => {
|
||||
@@ -89,10 +111,8 @@
|
||||
if (overlay) render(overlay)
|
||||
}
|
||||
|
||||
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
|
||||
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
onPage("lightbox", () => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as Element | null
|
||||
|
||||
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
|
||||
@@ -101,7 +121,7 @@
|
||||
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
|
||||
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
|
||||
alts = all.map((el) => (el instanceof HTMLImageElement ? el.alt : ""))
|
||||
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
|
||||
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)), trigger)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,21 +132,29 @@
|
||||
// 点背景关闭(点图片本身不关)
|
||||
const overlay = document.getElementById(LB_ID)
|
||||
if (overlay && target === overlay) close()
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const overlay = document.getElementById(LB_ID)
|
||||
if (!overlay || overlay.classList.contains("hidden")) return
|
||||
if (event.key === "Escape") close()
|
||||
if (event.key === "ArrowLeft") step(-1)
|
||||
if (event.key === "ArrowRight") step(1)
|
||||
})
|
||||
}
|
||||
|
||||
// 切页时收拾干净,避免残留遮罩锁住滚动
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
const overlay = document.getElementById(LB_ID)
|
||||
if (overlay) overlay.remove()
|
||||
document.addEventListener("click", handleClick)
|
||||
document.addEventListener("keydown", handleKeydown)
|
||||
|
||||
// 切页前收拾干净:还原背景 inert / 焦点、移除遮罩、解开滚动锁。
|
||||
// 这一步现在发生在 astro:before-swap(旧 DOM 还在的时候),比原来挂在
|
||||
// astro:page-load 里补救更早 —— 遮罩不会在新页面上闪一下,也不会残留 inert。
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick)
|
||||
document.removeEventListener("keydown", handleKeydown)
|
||||
releaseFocus?.()
|
||||
releaseFocus = null
|
||||
document.getElementById(LB_ID)?.remove()
|
||||
document.body.style.overflow = ""
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -53,7 +53,6 @@ const encryptedDataString = encryptedData ? JSON.stringify(encryptedData) : "";
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.password-container {
|
||||
|
||||
@@ -353,7 +353,7 @@ export function ArticleList({
|
||||
const filterPillOff = "border-border text-muted-foreground hover:border-primary hover:text-primary"
|
||||
|
||||
const filterBar = (
|
||||
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle/80 backdrop-blur-md">
|
||||
<div className="lg:hidden sticky top-[var(--header-h)] z-30 -mx-6 mb-8 border-b border-border/60 bg-surface-subtle">
|
||||
<div
|
||||
ref={filterBarRef}
|
||||
className="no-scrollbar flex items-center gap-2 overflow-x-auto px-6 py-2.5"
|
||||
@@ -425,7 +425,13 @@ export function ArticleList({
|
||||
)
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="article-list-root px-6 py-16 bg-surface-subtle">
|
||||
// 手机端(<lg)上方不留内边距:列表的第一个元素是吸顶胶囊条,
|
||||
// 让它紧贴 section 顶端,配合 #home-main 的 scroll-margin 正好落在页眉下沿;
|
||||
// 桌面端没有吸顶条,保留 py-16 的呼吸位。
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="article-list-root px-6 pt-0 pb-16 lg:py-16 bg-surface-subtle"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{filterBar}
|
||||
{/* Section header */}
|
||||
|
||||
@@ -106,7 +106,7 @@ const recommendBadge = (status?: string) => {
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<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="flex flex-col md:flex-row md:gap-10">
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ export function HeaderSearch() {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle")
|
||||
const [loadRequested, setLoadRequested] = useState(false)
|
||||
/** 查询进行中(pagefind 已可用,正在等结果) */
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
/** 这次查询抛了异常。与 status 分开:status 描述「pagefind 加载」,它描述「本次查询」 */
|
||||
const [failed, setFailed] = useState(false)
|
||||
const isProd = import.meta.env.PROD
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const desktopInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -90,13 +94,22 @@ export function HeaderSearch() {
|
||||
|
||||
const handleLoaded = () => {
|
||||
if (!cancelled) setStatus("ready")
|
||||
window.clearTimeout(watchdog)
|
||||
}
|
||||
|
||||
// 兜底:pagefind.js 是运行时 import 的,模块加载失败**不会**触发 script.onerror,
|
||||
// 只靠上面那个 onerror 会出现「永远搜索中…」。超过 8 秒还没就绪就按失败处理。
|
||||
const watchdog = window.setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setStatus((current) => (current === "loading" ? "error" : current))
|
||||
}, 8000)
|
||||
|
||||
window.addEventListener("pagefind:loaded", handleLoaded)
|
||||
loadPagefind()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(watchdog)
|
||||
window.removeEventListener("pagefind:loaded", handleLoaded)
|
||||
}
|
||||
}, [isProd, loadRequested])
|
||||
@@ -118,12 +131,20 @@ export function HeaderSearch() {
|
||||
|
||||
try {
|
||||
const { results: searchResults } = await window.pagefind!.search(query)
|
||||
setResults(searchResults.slice(0, 8)) // Limit results
|
||||
setResults(searchResults.slice(0, 8)) // 只展示前 8 条
|
||||
setFailed(false)
|
||||
} catch (error) {
|
||||
console.error("Pagefind search failed:", error)
|
||||
setResults([])
|
||||
setFailed(true)
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 一有输入就进「搜索中」:把 200ms 防抖窗口也算进去,
|
||||
// 否则这 200ms 里会先闪一下「未找到相关结果」。
|
||||
setIsSearching(Boolean(query.trim()))
|
||||
const debounce = setTimeout(runSearch, 200)
|
||||
return () => clearTimeout(debounce)
|
||||
}, [query, isProd, status, requestPagefindLoad])
|
||||
@@ -147,6 +168,11 @@ export function HeaderSearch() {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false)
|
||||
setIsFocused(false)
|
||||
// 让搜索框自己失焦:否则面板藏起来后继续打字不会再触发 onFocus,看着像搜索坏了
|
||||
const active = document.activeElement
|
||||
if (active === desktopInputRef.current || active === mobileInputRef.current) {
|
||||
;(active as HTMLElement).blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +206,22 @@ export function HeaderSearch() {
|
||||
)
|
||||
: null
|
||||
|
||||
/**
|
||||
* 面板里该显示什么。
|
||||
*
|
||||
* 原来只判断 `results.length === 0`,于是「pagefind 还在加载」和「查询失败」都会
|
||||
* 显示成「未找到相关结果」—— 把一条确定性错误信息递给用户,直接劝退。
|
||||
*/
|
||||
const searchState: "idle" | "loading" | "empty" | "error" | "results" = !query.trim()
|
||||
? "idle"
|
||||
: failed || status === "error"
|
||||
? "error"
|
||||
: status === "loading" || isSearching
|
||||
? "loading"
|
||||
: results.length === 0
|
||||
? "empty"
|
||||
: "results"
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{overlay}
|
||||
@@ -227,12 +269,18 @@ export function HeaderSearch() {
|
||||
</button>
|
||||
|
||||
{/* Search panel */}
|
||||
<div className={cn(
|
||||
"fixed left-4 right-4 top-16 md:absolute md:top-full md:left-auto md:right-0 md:w-96 md:mt-2 z-50",
|
||||
"bg-background/95 backdrop-blur-xl border border-border rounded-xl shadow-lg",
|
||||
"transition-all duration-200 overflow-hidden",
|
||||
showPanel ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"
|
||||
)}>
|
||||
<div
|
||||
// 关闭时必须 inert:opacity-0 / pointer-events-none 都挡不住键盘,
|
||||
// 否则那几条结果链接会变成「看不见但 Tab 得到」的焦点陷阱。
|
||||
inert={!showPanel}
|
||||
aria-hidden={!showPanel}
|
||||
className={cn(
|
||||
"fixed left-4 right-4 top-16 md:absolute md:top-full md:left-auto md:right-0 md:w-96 md:mt-2 z-50",
|
||||
"bg-background border border-border rounded-xl shadow-lg",
|
||||
"transition-all duration-200 overflow-hidden",
|
||||
showPanel ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{/* Mobile search input inside panel */}
|
||||
<div className="md:hidden p-3 border-b border-border">
|
||||
<div className="flex items-center h-10 rounded-lg bg-muted/50">
|
||||
@@ -261,8 +309,20 @@ export function HeaderSearch() {
|
||||
|
||||
{/* Search results */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isProd && query.trim() && results.length === 0 && (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">
|
||||
{isProd && searchState === "loading" && (
|
||||
<div role="status" className="p-4 text-center text-sm text-muted-foreground">
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProd && searchState === "error" && (
|
||||
<div role="status" className="p-4 text-center text-sm text-destructive">
|
||||
搜索暂时不可用,请刷新页面重试
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProd && searchState === "empty" && (
|
||||
<div role="status" className="p-4 text-center text-sm text-muted-foreground">
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ const journey = [
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<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">
|
||||
{/* Hero Section */}
|
||||
<section className="mb-10">
|
||||
|
||||
@@ -52,7 +52,7 @@ const siteInfoText = [
|
||||
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle overflow-x-hidden">
|
||||
<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">
|
||||
<section class="mb-3">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-foreground mb-3 flex items-center gap-3">
|
||||
|
||||
@@ -23,7 +23,7 @@ const isCurrent = (href: string) =>
|
||||
href === "/#home-main" ? currentPath === "/" : currentPath.startsWith(href)
|
||||
---
|
||||
|
||||
<header class="site-header fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border/50 py-4">
|
||||
<header class="site-header fixed top-0 left-0 right-0 z-50 bg-background border-b border-border/50 py-4">
|
||||
<div class="max-w-6xl mx-auto px-6 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||
<a href="/#home-main" data-astro-prefetch class="group flex items-center gap-3 justify-self-start">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border border-primary/20 bg-secondary transition-transform duration-300 group-hover:scale-105">
|
||||
@@ -84,6 +84,7 @@ const isCurrent = (href: string) =>
|
||||
id="mobile-menu"
|
||||
class="mobile-menu md:hidden fixed inset-0 z-[60] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="mobile-menu-bg absolute inset-0" data-dismiss aria-hidden="true"></div>
|
||||
|
||||
@@ -167,16 +168,15 @@ const isCurrent = (href: string) =>
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 悬浮面板:磨砂卡片,从顶部轻轻落下 */
|
||||
/* 悬浮面板:从顶部轻轻落下。
|
||||
注意这是**不透明**卡片,不是磨砂卡片 —— 常驻 backdrop-filter 的成本见文件末尾说明。 */
|
||||
.mobile-menu-panel {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.985);
|
||||
transform-origin: top right;
|
||||
background-color: var(--glass-bg);
|
||||
border-color: var(--glass-border);
|
||||
background-color: var(--card);
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 18px 48px -14px oklch(0 0 0 / 0.24);
|
||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -237,22 +237,6 @@ const isCurrent = (href: string) =>
|
||||
transform: translateY(-50%) scaleY(1);
|
||||
}
|
||||
|
||||
/* 不支持 backdrop-filter 的浏览器:退回不透明卡片,别让文字糊在内容上 */
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.mobile-menu-panel {
|
||||
background-color: var(--card);
|
||||
}
|
||||
}
|
||||
|
||||
/* 系统开启“减少透明度”时直接关掉磨砂 */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.mobile-menu-panel {
|
||||
background-color: var(--card);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-menu,
|
||||
.mobile-menu-panel,
|
||||
@@ -272,30 +256,42 @@ const isCurrent = (href: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏上页眉不再常驻 backdrop-filter:固定元素持续模糊会让移动端合成开销变大,
|
||||
顶部时用实色反而更省更快。菜单面板是点开才出现的、面积也小,保留磨砂。 */
|
||||
@media (max-width: 767px) {
|
||||
.site-header {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
background-color: var(--background) !important;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 页眉与菜单面板都不再用 backdrop-filter(2026-09-20)。
|
||||
*
|
||||
* 原来只在 max-width: 767px 关掉页眉的模糊,iPad 宽度 ≥768px 正好落在外面,
|
||||
* 于是固定页眉整幅宽度、每帧重算 24px 模糊 —— iPad Air 4 上滚动最重的文章页时
|
||||
* 掉帧并出现未绘制的空白区域。现在全局改实色,顺带也不再需要
|
||||
* 「不支持 backdrop-filter 的降级」和「prefers-reduced-transparency」两段覆盖。
|
||||
* 菜单面板原来是 --glass-bg(72% 透明),去掉模糊后文字会糊在内容上,一并改不透明。
|
||||
*/
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 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 = () => {
|
||||
import { onPage } from "@/scripts/lifecycle"
|
||||
import { openModalFocus } from "@/lib/modal-focus"
|
||||
|
||||
/**
|
||||
* 移动端菜单。
|
||||
*
|
||||
* 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 menu = document.getElementById("mobile-menu")
|
||||
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 = ""
|
||||
/** 关闭时用来还原背景 inert 与焦点 */
|
||||
let releaseFocus: (() => void) | null = null
|
||||
|
||||
// 关闭态必须是 inert,而不是只把 opacity 调成 0:
|
||||
// opacity / pointer-events 都不影响键盘可聚焦性,透明面板会白吃掉 10 个 Tab 停靠点。
|
||||
if (menu.dataset.open !== "true") menu.inert = true
|
||||
|
||||
const setOpen = (open: boolean) => {
|
||||
const isOpen = menu.dataset.open === "true"
|
||||
@@ -308,54 +304,49 @@ const isCurrent = (href: string) =>
|
||||
if (open) {
|
||||
prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
const firstLink = menu.querySelector<HTMLAnchorElement>("nav a")
|
||||
if (firstLink) {
|
||||
window.setTimeout(() => firstLink.focus({ preventScroll: true }), 100)
|
||||
}
|
||||
menu.inert = false
|
||||
// 背景 inert 之后 Tab 只能在菜单内部循环,不用再手写焦点陷阱
|
||||
releaseFocus = openModalFocus(menu, {
|
||||
initialFocus: menu.querySelector<HTMLAnchorElement>("nav a"),
|
||||
restoreFocus: toggle,
|
||||
})
|
||||
} else {
|
||||
document.body.style.overflow = prevOverflow
|
||||
toggle.focus({ preventScroll: true })
|
||||
// 顺序要紧:先还原背景的 inert(展开按钮在背景里)并把焦点还回去,
|
||||
// 再把菜单本身设回 inert
|
||||
releaseFocus?.()
|
||||
releaseFocus = null
|
||||
menu.inert = true
|
||||
}
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
setOpen(menu.dataset.open !== "true")
|
||||
})
|
||||
const onToggle = () => setOpen(menu.dataset.open !== "true")
|
||||
|
||||
menu.addEventListener("click", (event) => {
|
||||
const onMenuClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (target.closest("[data-dismiss]") || target.closest("a")) {
|
||||
setOpen(false)
|
||||
}
|
||||
})
|
||||
if (target.closest("[data-dismiss]") || target.closest("a")) setOpen(false)
|
||||
}
|
||||
|
||||
// Expose close handler for the module-level Escape listener
|
||||
;(menu as HTMLElement & { __close?: () => void }).__close = () => setOpen(false)
|
||||
}
|
||||
|
||||
// ESC listener is global; bind only once per full page load.
|
||||
if (!(window as unknown as { __menuEscBound?: boolean }).__menuEscBound) {
|
||||
;(window as unknown as { __menuEscBound?: boolean }).__menuEscBound = true
|
||||
document.addEventListener("keydown", (event) => {
|
||||
// Esc 只在本页菜单打开时响应;原来是一个「只绑一次」的全局监听器,
|
||||
// 现在随菜单一起挂载 / 卸载,语义更窄也更干净。
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return
|
||||
const menu = document.getElementById("mobile-menu") as
|
||||
| (HTMLElement & { __close?: () => void })
|
||||
| null
|
||||
if (menu?.dataset.open === "true") {
|
||||
event.preventDefault()
|
||||
menu.__close?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (menu.dataset.open !== "true") return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Reset body scroll lock on navigation away while menu is open
|
||||
document.addEventListener("astro:before-swap", () => {
|
||||
const menu = document.getElementById("mobile-menu")
|
||||
if (menu?.dataset.open === "true") {
|
||||
document.body.style.overflow = ""
|
||||
toggle.addEventListener("click", onToggle)
|
||||
menu.addEventListener("click", onMenuClick)
|
||||
document.addEventListener("keydown", onKeydown)
|
||||
|
||||
return () => {
|
||||
// 菜单开着就切页:先走正常关闭流程还原 inert / 滚动锁 / 焦点,再摘监听器
|
||||
setOpen(false)
|
||||
toggle.removeEventListener("click", onToggle)
|
||||
menu.removeEventListener("click", onMenuClick)
|
||||
document.removeEventListener("keydown", onKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener("astro:page-load", initMobileMenu)
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,6 @@ description: "我们这个时代为什么越来越繁忙,人工智能带来的
|
||||
slug: Improving-work-efficiency
|
||||
tags: [工作,人的异化]
|
||||
category: "思考"
|
||||
draft: true
|
||||
---
|
||||
|
||||
在这里写正文。
|
||||
我也在思考一个问题
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-09-17T12:40:24.399Z",
|
||||
"updatedAt": "2026-09-21T01:32:16.851Z",
|
||||
"source": "weread",
|
||||
"books": [
|
||||
{
|
||||
@@ -8,12 +8,12 @@
|
||||
"author": "布尔加科夫",
|
||||
"category": "文学-外国文学",
|
||||
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
|
||||
"progress": 35,
|
||||
"progress": 62,
|
||||
"finished": false,
|
||||
"lastReadAt": "2026-09-17T11:58:14.000Z",
|
||||
"lastReadAt": "2026-09-20T16:46:27.000Z",
|
||||
"startedAt": "2026-09-04T16:07:07.000Z",
|
||||
"finishedAt": null,
|
||||
"readingSeconds": 4908,
|
||||
"readingSeconds": 8729,
|
||||
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
|
||||
},
|
||||
{
|
||||
|
||||
+6
-3
@@ -72,12 +72,15 @@ export const favicon = {
|
||||
|
||||
/**
|
||||
* Web App Manifest:只在「添加到主屏幕 / 装成 App」时被浏览器取用。
|
||||
* 文件不在 public/ 里,而是由 src/pages/favicon/[manifest].ts 按本配置生成,
|
||||
* 文件不在 public/ 里,而是由 src/pages/favicon/site.webmanifest.ts 按本配置生成,
|
||||
* 所以站名、图标路径不会和这里对不上。
|
||||
*/
|
||||
manifest: {
|
||||
/** 生成的文件名。改这里,<link rel="manifest"> 和产物文件名一起变 */
|
||||
file: "site.webmanifest",
|
||||
/**
|
||||
* manifest 的地址。由路由文件名决定(Astro 的规矩,和 robots.txt 一样),
|
||||
* 改名字要连路由文件一起改 —— 对不上时构建会直接报错,不会默默产出到别的地址。
|
||||
*/
|
||||
url: "/favicon/site.webmanifest",
|
||||
/** Android「添加到主屏幕」用的两档图标 */
|
||||
android192: "android-chrome-192x192.png",
|
||||
android512: "android-chrome-512x512.png",
|
||||
|
||||
+44
-183
@@ -95,7 +95,7 @@ const resolvedTwitterDescription = twitterDescription || description
|
||||
<link rel="icon" href={faviconUrl(favicon.png32)} type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href={faviconUrl(favicon.png16)} type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href={faviconUrl(favicon.appleTouchIcon)} />
|
||||
<link rel="manifest" href={faviconUrl(favicon.manifest.file)} />
|
||||
<link rel="manifest" href={favicon.manifest.url} />
|
||||
<script is:inline define:vars={{ analytics }}>
|
||||
;(() => {
|
||||
// ClientRouter 切页时 head 里的内联脚本可能再次执行,用全局标记兜底
|
||||
@@ -176,28 +176,6 @@ const resolvedTwitterDescription = twitterDescription || description
|
||||
window.__applyTheme = applyTheme;
|
||||
})();
|
||||
</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>
|
||||
// 预过滤标记:避免带 tag/category 查询的首页渲染闪动
|
||||
(function() {
|
||||
@@ -214,169 +192,52 @@ const resolvedTwitterDescription = twitterDescription || description
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<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 />
|
||||
|
||||
{/*
|
||||
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 />
|
||||
<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) => {
|
||||
return Intl.NumberFormat("en-us", { notation: "compact", maximumFractionDigits: 1 })
|
||||
.format(value)
|
||||
.replaceAll("\u202f", "")
|
||||
}
|
||||
|
||||
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>
|
||||
// 交互层统一从 lifecycle 挂载 / 卸载(见 src/scripts/lifecycle.ts)。
|
||||
// 原来是四段各自 is:inline、各自用 window.__xxxBound 防重复绑定的脚本;
|
||||
// 现在聚合到一个 module 入口,切页时由 astro:before-swap 统一清理。
|
||||
import "@/scripts/interactions"
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 弹层的焦点管理(移动端菜单、图片灯箱共用)。
|
||||
*
|
||||
* 起因:`opacity: 0` 和 `pointer-events: none` **都不改变键盘可聚焦性**,所以"视觉上
|
||||
* 隐藏"的弹层照样会吃掉 Tab 停靠点 —— 焦点环落在看不见的控件上(WCAG 2.4.7)。
|
||||
* `aria-hidden` 也救不了:它只影响辅助技术,不影响 Tab 顺序。
|
||||
*
|
||||
* 这里统一用 `inert`:它对键盘和辅助技术同时生效;而且背景一旦 inert,
|
||||
* Tab 自然只能在弹层内部循环,于是不用手写 focus trap。
|
||||
*
|
||||
* ⚠️ 容器**不一定**是 `<body>` 的直接子节点(移动端菜单就被包在页面的
|
||||
* `<div class="min-h-screen">` 里),所以不能只遍历 `body.children` ——
|
||||
* 那样会把容器的祖先一起 inert 掉,连弹层自己都点不动。
|
||||
* 正确做法是沿容器的祖先链逐层处理:每一层里,把"不在通往容器的路径上"的
|
||||
* 兄弟节点设为 inert。
|
||||
*/
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ")
|
||||
|
||||
export interface ModalFocusOptions {
|
||||
/** 打开后聚焦到哪个元素。缺省用容器内第一个可聚焦元素 */
|
||||
initialFocus?: HTMLElement | null
|
||||
/** 关闭后把焦点还给谁。缺省还给打开前持有焦点的元素 */
|
||||
restoreFocus?: HTMLElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「容器之外的一切」设为 inert:从容器往上走到 `<body>`,每一层只处理旁支兄弟。
|
||||
*
|
||||
* 返回真正被改动的元素列表 —— 只收「原本没被 inert」的,这样还原时不会误清
|
||||
* 别处(例如另一个弹层)设置的状态。
|
||||
*/
|
||||
const inertOutside = (container: HTMLElement): HTMLElement[] => {
|
||||
const inerted: HTMLElement[] = []
|
||||
|
||||
let node: HTMLElement | null = container
|
||||
while (node && node !== document.body) {
|
||||
const parent: HTMLElement | null = node.parentElement
|
||||
if (!parent) break
|
||||
|
||||
for (const sibling of Array.from(parent.children)) {
|
||||
// 跳过"通往容器的那一支",以及非元素节点
|
||||
if (sibling === node || !(sibling instanceof HTMLElement)) continue
|
||||
if (sibling.inert) continue
|
||||
sibling.inert = true
|
||||
inerted.push(sibling)
|
||||
}
|
||||
|
||||
node = parent
|
||||
}
|
||||
|
||||
return inerted
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开弹层:把容器之外的内容设为 inert,并把焦点移进弹层。
|
||||
*
|
||||
* 返回「关闭时清理」的函数 —— **必须调用**,否则背景会一直不可交互。
|
||||
*/
|
||||
export const openModalFocus = (container: HTMLElement, options: ModalFocusOptions = {}) => {
|
||||
const inerted = inertOutside(container)
|
||||
|
||||
const restoreTarget =
|
||||
options.restoreFocus ??
|
||||
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
|
||||
const initial =
|
||||
options.initialFocus ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? container
|
||||
initial.focus({ preventScroll: true })
|
||||
|
||||
return () => {
|
||||
for (const el of inerted) el.inert = false
|
||||
restoreTarget?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { siteTitleSuffix } from "@/data/site"
|
||||
|
||||
/**
|
||||
* 404 页面。
|
||||
*
|
||||
* 注意:光有这个文件不够 —— 静态产物是 dist/404.html,要让 Caddy 真正把它递给用户,
|
||||
* 服务器端还需要(两个 Caddyfile 都要):
|
||||
*
|
||||
* handle_errors 404 {
|
||||
* rewrite /404.html
|
||||
* file_server
|
||||
* }
|
||||
*
|
||||
* 否则 Caddy 的 file_server 只会返回一个 0 字节的空白 404(这正是加这个页面的原因)。
|
||||
*/
|
||||
|
||||
const title = `页面不存在 | ${siteTitleSuffix}`
|
||||
const description = "这个地址没有对应的页面。"
|
||||
|
||||
const entries = [
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/timeline/", label: "时间线" },
|
||||
{ href: "/thoughts/", label: "偶得" },
|
||||
{ href: "/gallery/", label: "相册" },
|
||||
{ href: "/bookshelf/", label: "书架" },
|
||||
{ href: "/friends/", label: "友链" },
|
||||
{ href: "/about/", label: "关于" },
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description} robots="noindex" fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<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>
|
||||
|
||||
<h1 class="mb-4 flex items-center gap-3 text-2xl font-semibold text-foreground sm:text-3xl">
|
||||
<span class="h-6 w-1 shrink-0 rounded-full bg-primary" />
|
||||
这里没有页面
|
||||
</h1>
|
||||
|
||||
<p class="mb-2 leading-relaxed text-muted-foreground">
|
||||
地址可能拼错了,或者这个页面已经被挪走。
|
||||
</p>
|
||||
<p class="mb-9 text-sm text-muted-foreground">
|
||||
可以按 <kbd
|
||||
class="inline-flex h-5 items-center rounded border border-border bg-muted/60 px-1.5 font-mono text-[11px]"
|
||||
>⌘K</kbd> 搜一下,或者从下面挑一个入口。
|
||||
</p>
|
||||
|
||||
<nav aria-label="站点入口" class="flex flex-wrap gap-2">
|
||||
{entries.map((entry) => (
|
||||
<a
|
||||
href={entry.href}
|
||||
class="rounded-lg border border-border px-3.5 py-2 text-sm text-muted-foreground transition-colors duration-200 hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{entry.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -31,8 +31,9 @@ const title =
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<main class="pt-24">
|
||||
<section id="home-main" class="scroll-mt-24">
|
||||
<main id="main" tabindex="-1" class="pt-24">
|
||||
{/* 同 index.astro:手机端落点贴页眉下沿(列表开头是吸顶胶囊条),lg 以上保留呼吸位 */}
|
||||
<section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24">
|
||||
<ArticleList
|
||||
client:visible
|
||||
articles={allArticles}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { APIRoute } from "astro"
|
||||
import { favicon, faviconUrl, site } from "@/data/site"
|
||||
|
||||
/**
|
||||
* Web App Manifest 由路由生成而非放在 public/,这样站名、图标路径都跟随
|
||||
* src/data/site.ts —— 换 favicon 只需要改那一个文件,不会出现"manifest 没跟上"。
|
||||
* 同样的思路见 src/pages/robots.txt.ts。
|
||||
*
|
||||
* 产物文件名取自 favicon.manifest.file,所以改名也在 site.ts 里做。
|
||||
*/
|
||||
|
||||
/** 本路由所在目录,必须和 favicon.dir 一致 —— 改了一个就得改名另一个 */
|
||||
const ROUTE_DIR = "/favicon"
|
||||
|
||||
export function getStaticPaths() {
|
||||
if (favicon.dir !== ROUTE_DIR) {
|
||||
throw new Error(
|
||||
`favicon.dir 是 "${favicon.dir}",但 manifest 路由在 src/pages${ROUTE_DIR}/ 下。` +
|
||||
`请把该目录一并改名为 src/pages${favicon.dir}/,否则 manifest 会被生成到错误的路径上。`,
|
||||
)
|
||||
}
|
||||
|
||||
// public/ 里的同名文件会在构建时胜出(Astro 只打一行 WARN 就跳过本路由),
|
||||
// manifest 会静默停在旧内容。宁可构建失败,也不要默默把旧 manifest 推上线。
|
||||
const shadow = path.join(process.cwd(), "public", favicon.dir, favicon.manifest.file)
|
||||
if (fs.existsSync(shadow)) {
|
||||
throw new Error(
|
||||
`public${favicon.dir}/${favicon.manifest.file} 与本路由重名,构建时 public 里的那个会胜出,` +
|
||||
`manifest 将不再跟随 site.ts。请删掉它 —— manifest 是自动生成的,不需要手写。`,
|
||||
)
|
||||
}
|
||||
|
||||
return [{ params: { manifest: favicon.manifest.file } }]
|
||||
}
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const { manifest } = favicon
|
||||
|
||||
const body = {
|
||||
name: site.name,
|
||||
description: site.description,
|
||||
lang: site.lang,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: manifest.backgroundColor,
|
||||
theme_color: manifest.themeColor,
|
||||
icons: [
|
||||
{ src: faviconUrl(manifest.android192), sizes: "192x192", type: "image/png" },
|
||||
{ src: faviconUrl(manifest.android512), sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(body, null, 2), {
|
||||
headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { APIRoute } from "astro"
|
||||
import { favicon, faviconUrl, site } from "@/data/site"
|
||||
|
||||
/**
|
||||
* Web App Manifest 由路由生成而非放在 public/,这样站名、图标路径都跟随
|
||||
* src/data/site.ts —— 换 favicon 只需要改那一个文件,不会出现「manifest 没跟上」。
|
||||
* 同样的思路见 src/pages/robots.txt.ts。
|
||||
*
|
||||
* ⚠️ 这里刻意用**固定文件名**,不用 [manifest].ts 这种动态路由:
|
||||
* 本项目 trailingSlash 是 "always",而动态路由在 dev server 里只认带斜杠的
|
||||
* /favicon/site.webmanifest/ —— 可 HTML 里写的是不带斜杠的地址,于是 dev 下必然 404,
|
||||
* 和生产行为对不上(生产是 200)。固定文件名的端点和 robots.txt 一样,两边一致。
|
||||
*
|
||||
* 代价是地址由文件名决定,所以下面用断言把 site.ts 和文件名对上:
|
||||
* 对不上就构建失败,而不是默默产出到别的地址。
|
||||
*/
|
||||
|
||||
/** 本路由所在目录与文件名,必须和 favicon.dir / favicon.manifest.url 一致 */
|
||||
const ROUTE_DIR = "/favicon"
|
||||
const ROUTE_FILE = "site.webmanifest"
|
||||
const ROUTE_URL = `${ROUTE_DIR}/${ROUTE_FILE}`
|
||||
|
||||
if (favicon.dir !== ROUTE_DIR) {
|
||||
throw new Error(
|
||||
`favicon.dir 是 "${favicon.dir}",但 manifest 路由在 src/pages${ROUTE_DIR}/ 下。` +
|
||||
`请把该目录一并改名为 src/pages${favicon.dir}/,否则 manifest 会被生成到错误的地址。`,
|
||||
)
|
||||
}
|
||||
|
||||
if (favicon.manifest.url !== ROUTE_URL) {
|
||||
throw new Error(
|
||||
`favicon.manifest.url 是 "${favicon.manifest.url}",但本路由的地址是 "${ROUTE_URL}"` +
|
||||
`(Astro 端点的地址由文件名决定)。请把 src/pages${ROUTE_DIR}/${ROUTE_FILE}.ts ` +
|
||||
`改名为 src/pages${favicon.manifest.url}.ts,或者把 site.ts 里的 url 改回来。`,
|
||||
)
|
||||
}
|
||||
|
||||
// public/ 里的同名文件会在构建时胜出(Astro 只打一行 WARN 就跳过本路由),
|
||||
// manifest 会静默停在旧内容。宁可构建失败,也不要默默把旧 manifest 推上线。
|
||||
const shadow = path.join(process.cwd(), "public", ROUTE_DIR, ROUTE_FILE)
|
||||
if (fs.existsSync(shadow)) {
|
||||
throw new Error(
|
||||
`public${ROUTE_URL} 与本路由重名,构建时 public 里的那个会胜出,` +
|
||||
`manifest 将不再跟随 site.ts。请删掉它 —— manifest 是自动生成的,不需要手写。`,
|
||||
)
|
||||
}
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
const { manifest } = favicon
|
||||
|
||||
const body = {
|
||||
name: site.name,
|
||||
description: site.description,
|
||||
lang: site.lang,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: manifest.backgroundColor,
|
||||
theme_color: manifest.themeColor,
|
||||
icons: [
|
||||
{ src: faviconUrl(manifest.android192), sizes: "192x192", type: "image/png" },
|
||||
{ src: faviconUrl(manifest.android512), sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(body, null, 2), {
|
||||
headers: { "Content-Type": "application/manifest+json; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,10 @@ const albumId = Astro.params.album as string
|
||||
const album = galleryConfig.albums.find((a) => a.id === albumId)
|
||||
|
||||
if (!album) {
|
||||
return Astro.redirect("/404/")
|
||||
// 静态构建下这里其实不可达:getStaticPaths 只为 galleryConfig 里存在的相册生成页面,
|
||||
// 访问不存在的相册不会走到这个组件(由服务器交给 404 页面)。留着是为了类型收窄。
|
||||
// 目标指向构建产物里的 404 页面(src/pages/404.astro)—— 原来的 /404/ 是个不存在的路由。
|
||||
return Astro.redirect("/404.html")
|
||||
}
|
||||
|
||||
const photos = scanAlbumPhotos(album.id)
|
||||
@@ -28,7 +31,7 @@ const description = album.description ?? "相册"
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<main class="pt-20 pb-16">
|
||||
<main id="main" tabindex="-1" class="pt-20 pb-16">
|
||||
{/* 封面横幅 */}
|
||||
<section class="relative w-full overflow-hidden">
|
||||
{cover ? (
|
||||
@@ -38,7 +41,7 @@ const description = album.description ?? "相册"
|
||||
|
||||
<a
|
||||
href="/gallery/"
|
||||
class="absolute left-4 top-4 rounded-lg bg-black/35 px-3 py-1.5 text-sm text-white/90 backdrop-blur-sm transition-colors hover:bg-black/55 hover:text-white"
|
||||
class="absolute left-4 top-4 rounded-lg bg-black/50 px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-black/70 hover:text-white"
|
||||
>
|
||||
← 返回相册
|
||||
</a>
|
||||
@@ -61,7 +64,7 @@ const description = album.description ?? "相册"
|
||||
{album.tags && album.tags.length > 0 && (
|
||||
<div class="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{album.tags.map((tag) => (
|
||||
<span class="rounded bg-white/20 px-2 py-0.5 text-xs text-white/90 backdrop-blur-sm">
|
||||
<span class="rounded bg-black/45 px-2 py-0.5 text-xs text-white/90">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ const allTags = getAllAlbumTags(albums)
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<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">
|
||||
{/* 页头 */}
|
||||
<section class="mb-8">
|
||||
@@ -80,7 +80,7 @@ const allTags = getAllAlbumTags(albums)
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent" />
|
||||
|
||||
<div class="absolute right-2 top-2 rounded-full bg-black/50 px-2 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||||
<div class="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs font-medium text-white">
|
||||
{album.photoCount} 张
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ const allTags = getAllAlbumTags(albums)
|
||||
{album.tags && album.tags.length > 0 && (
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{album.tags.slice(0, 4).map((tag) => (
|
||||
<span class="rounded bg-white/20 px-1.5 py-0.5 text-[0.6rem] text-white/90 backdrop-blur-sm">
|
||||
<span class="rounded bg-black/45 px-1.5 py-0.5 text-[0.6rem] text-white/90">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -21,9 +21,15 @@ const thoughtPreviews = thoughts.map((thought) => toThoughtPreview(thought)).sli
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<main id="main" tabindex="-1">
|
||||
<Hero client:idle />
|
||||
<section id="home-main" class="scroll-mt-24">
|
||||
{/*
|
||||
手机端锚点落点要贴页眉下沿:这一屏列表的第一个元素是吸顶胶囊条
|
||||
(article-list.tsx 的 filterBar),它 sticky 在 --header-h 处。scroll-mt 若再留出
|
||||
额外的呼吸位,落点就会在页眉和胶囊条之间露出 Hero 的尾巴,看着像多出一段空白。
|
||||
桌面端列表开头是标题、不是吸顶条,所以 lg 以上仍沿用原来的 96px。
|
||||
*/}
|
||||
<section id="home-main" class="scroll-mt-[var(--header-h)] lg:scroll-mt-24">
|
||||
<ArticleList
|
||||
client:visible
|
||||
articles={allArticles}
|
||||
|
||||
@@ -20,7 +20,7 @@ const description = study.epigraph ?? ""
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore>
|
||||
<SiteHeader />
|
||||
<main class="pt-20">
|
||||
<main id="main" tabindex="-1" class="pt-20">
|
||||
<StudyDetailView client:idle study={study} />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -15,7 +15,7 @@ const studies = await getAllStudies()
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle" data-pagefind-ignore>
|
||||
<SiteHeader />
|
||||
<main class="pt-20">
|
||||
<main id="main" tabindex="-1" class="pt-20">
|
||||
<StudiesList client:idle studies={studies} />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -16,7 +16,7 @@ const thoughtsWithContent = allThoughts.map((thought) => toThoughtMeta(thought))
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<main class="pt-20">
|
||||
<main id="main" tabindex="-1" class="pt-20">
|
||||
<section id="thoughts-main" class="scroll-mt-24">
|
||||
<ThoughtsView client:idle thoughts={thoughtsWithContent} />
|
||||
</section>
|
||||
|
||||
@@ -21,7 +21,7 @@ const timelineItems = posts.map((post) => ({
|
||||
<BaseLayout {title} {description} fontProfile="interface">
|
||||
<div class="min-h-screen bg-background dark:bg-surface-subtle">
|
||||
<SiteHeader />
|
||||
<main class="pt-24">
|
||||
<main id="main" tabindex="-1" class="pt-24">
|
||||
<Timeline items={timelineItems} />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+39
-28
@@ -1,33 +1,44 @@
|
||||
// Copy button functionality for Expressive Code
|
||||
export function initCopyButtons() {
|
||||
document.querySelectorAll('.expressive-code .copy-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const pre = btn.closest('pre');
|
||||
if (!pre) return;
|
||||
import { onPage } from "./lifecycle"
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
onPage("copy-buttons", () => {
|
||||
const onClick = async (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCopyButtons);
|
||||
} else {
|
||||
initCopyButtons();
|
||||
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 {
|
||||
await navigator.clipboard.writeText(text)
|
||||
button.classList.add("copied")
|
||||
window.setTimeout(() => button.classList.remove("copied"), COPIED_FEEDBACK_MS)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", onClick)
|
||||
return () => document.removeEventListener("click", onClick)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
+214
-34
@@ -86,23 +86,15 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.5rem;
|
||||
/*
|
||||
* 纸纹底:内联 SVG 的 feTurbulence 分形噪声(无需图片文件)。
|
||||
* baseFrequency 越大颗粒越细,stitchTiles 保证平铺无接缝。
|
||||
* feColorMatrix saturate=0 把彩色噪点去饱和,得到带随机 alpha 的灰颗粒,
|
||||
* 所以不需要 mix-blend-mode 也亮暗通用。
|
||||
* 纸纹层与磨砂玻璃 token 已于 2026-09-20 移除。
|
||||
*
|
||||
* 原因:iPad(A14)上滚动最重的那篇文章(238 个 KaTeX 块 / 约 4150 个元素)时掉帧,
|
||||
* 并出现未绘制的空白区域。两个"每帧都在算"的常驻开销是主因:
|
||||
* 1. body::before —— 整屏 position:fixed 的噪声层压在正文之上,每帧混合一遍;
|
||||
* 2. 固定页眉的 backdrop-blur-xl(24px)—— 每帧重算整幅宽度。
|
||||
* 恢复办法(git 历史里有原实现):把 --paper-noise / --paper-noise-opacity /
|
||||
* --glass-* 加回这里,body::before 规则加回下方,页眉 class 加回 backdrop-blur-xl。
|
||||
*/
|
||||
--paper-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
--paper-noise-opacity: 0.2;
|
||||
/*
|
||||
* 磨砂玻璃 token —— 只给「浮在内容上方、面积不大」的元素用
|
||||
* (移动端菜单、页眉、搜索面板…)。不要给文章卡片大面积用,
|
||||
* 一屏十几个元素同时 backdrop-filter 会明显掉帧。
|
||||
* 由 --card 派生,所以亮/暗色自动跟随。
|
||||
*/
|
||||
--glass-bg: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
--glass-border: color-mix(in oklch, var(--border) 75%, transparent);
|
||||
--glass-blur: 14px;
|
||||
--glass-saturate: 1.5;
|
||||
--sidebar: oklch(0.968 0.012 84);
|
||||
--sidebar-foreground: oklch(0.235 0.013 68);
|
||||
--sidebar-primary: oklch(0.55 0.22 var(--primary-hue, 25));
|
||||
@@ -185,8 +177,6 @@
|
||||
--math-color: oklch(0.90 0.008 250);
|
||||
--math-display-color: oklch(0.94 0.008 250);
|
||||
--math-display-shadow: 0 1px 0 oklch(0.08 0 0 / 0.18);
|
||||
/* 暗色下颗粒要更明显一点才看得出来 */
|
||||
--paper-noise-opacity: 0.22;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -294,23 +284,10 @@ html {
|
||||
}
|
||||
|
||||
/*
|
||||
* 纸纹底:整屏一层极淡的颗粒。
|
||||
* - position: fixed 让它不随内容滚动,像纸在屏幕后面而不是印在内容上
|
||||
* - z-index: 1 压在正文之上、页眉(z-50)/菜单(z-60) 之下;不透明度只有几个百分点,
|
||||
* 等效于全局加了一层胶片颗粒
|
||||
* - pointer-events: none 必须留,否则整页点不动
|
||||
* - 不支持 backdrop-filter 的老浏览器不受影响,它只是背景图
|
||||
* 纸纹层(body::before)已移除 —— 见上面 :root 里的说明。
|
||||
* 它是压在正文之上的一整屏 position:fixed 层,每帧都要混合一次,
|
||||
* 在 iPad 上会让最重的文章页出现滚动掉帧与空白区域。
|
||||
*/
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background-image: var(--paper-noise);
|
||||
background-repeat: repeat;
|
||||
opacity: var(--paper-noise-opacity);
|
||||
}
|
||||
|
||||
/* 横向滚动的胶囊条要藏掉滚动条,但保留键盘/触摸滚动能力 */
|
||||
.no-scrollbar {
|
||||
@@ -1092,3 +1069,206 @@ a.card-github:hover .github-logo {
|
||||
.thoughts-timeline-body li + li { margin-top: 4px; }
|
||||
.thoughts-timeline-body li > ul,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user