feat(appearance): add AppearanceProvider and use-appearance hooks

新增 React Context 管理 themeColor 和 zoomLevel state,与 next-themes 正交,
通过 localStorage 持久化并支持跨标签页同步。提供语义化 hook
useThemeColor / useZoomLevel 供调用点按需使用。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-11 15:48:06 +08:00
parent fc457725c1
commit ecff8832c0
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
"use client"
import { createContext, useCallback, useEffect, useState } from "react"
import {
THEME_COLORS,
DEFAULT_THEME_COLOR,
type ThemeColor,
ZOOM_LEVELS,
DEFAULT_ZOOM_LEVEL,
type ZoomLevel,
} from "@/lib/theme-presets"
import {
STORAGE_KEY_THEME_COLOR,
STORAGE_KEY_ZOOM_LEVEL,
} from "@/lib/appearance-script"
type AppearanceContextValue = {
themeColor: ThemeColor
setThemeColor: (color: ThemeColor) => void
zoomLevel: ZoomLevel
setZoomLevel: (zoom: ZoomLevel) => void
}
export const AppearanceContext = createContext<AppearanceContextValue | null>(
null
)
/**
* AppearanceProvider 管理 themeColor 和 zoomLevel 两个外观偏好。
*
* 与 next-themes 完全正交next-themes 负责 <html class="dark/light">
* 这里负责 <html data-theme="..."> 和 <html style="font-size: ...">。
*
* 注意next-themes 的 attribute 配置必须保持 "class"。如果改为 "data-theme"
* 会与本 Provider 冲突,导致主题色无法生效。
*/
export function AppearanceProvider({
children,
}: {
children: React.ReactNode
}) {
// 初始值从 DOM 读取appearance-script.ts 在 hydration 前已经写好),
// 而不是从 localStorage 读 —— 避免 SSR 与 CSR 不一致导致的双闪烁。
const [themeColor, setThemeColorState] = useState<ThemeColor>(() => {
if (typeof document === "undefined") return DEFAULT_THEME_COLOR
const attr = document.documentElement.getAttribute(
"data-theme"
) as ThemeColor | null
return attr && (THEME_COLORS as readonly string[]).includes(attr)
? attr
: DEFAULT_THEME_COLOR
})
const [zoomLevel, setZoomLevelState] = useState<ZoomLevel>(() => {
if (typeof document === "undefined") return DEFAULT_ZOOM_LEVEL
const px = parseFloat(document.documentElement.style.fontSize || "16")
const level = Math.round((px / 16) * 100) as ZoomLevel
return (ZOOM_LEVELS as readonly number[]).includes(level)
? level
: DEFAULT_ZOOM_LEVEL
})
const setThemeColor = useCallback((color: ThemeColor) => {
setThemeColorState(color)
document.documentElement.setAttribute("data-theme", color)
try {
localStorage.setItem(STORAGE_KEY_THEME_COLOR, color)
} catch {
// 隐私模式 / 禁用 storage 时静默忽略,本次会话内仍然生效
}
}, [])
const setZoomLevel = useCallback((zoom: ZoomLevel) => {
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
try {
localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom))
} catch {
// 同上
}
}, [])
// 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_THEME_COLOR && e.newValue) {
const color = e.newValue as ThemeColor
if ((THEME_COLORS as readonly string[]).includes(color)) {
setThemeColorState(color)
document.documentElement.setAttribute("data-theme", color)
}
}
if (e.key === STORAGE_KEY_ZOOM_LEVEL && e.newValue) {
const zoom = parseInt(e.newValue, 10) as ZoomLevel
if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) {
setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
}
}
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
}, [])
return (
<AppearanceContext.Provider
value={{ themeColor, setThemeColor, zoomLevel, setZoomLevel }}
>
{children}
</AppearanceContext.Provider>
)
}