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:
112
src/components/appearance-provider.tsx
Normal file
112
src/components/appearance-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user