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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/hooks/use-appearance.ts
Normal file
24
src/hooks/use-appearance.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useContext } from "react"
|
||||||
|
import { AppearanceContext } from "@/components/appearance-provider"
|
||||||
|
|
||||||
|
export function useAppearance() {
|
||||||
|
const ctx = useContext(AppearanceContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAppearance must be used within AppearanceProvider")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语义化包装:只关心主题色的调用点用这个 */
|
||||||
|
export function useThemeColor() {
|
||||||
|
const { themeColor, setThemeColor } = useAppearance()
|
||||||
|
return { themeColor, setThemeColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语义化包装:只关心缩放档位的调用点用这个 */
|
||||||
|
export function useZoomLevel() {
|
||||||
|
const { zoomLevel, setZoomLevel } = useAppearance()
|
||||||
|
return { zoomLevel, setZoomLevel }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user