diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx new file mode 100644 index 0000000..b4f9626 --- /dev/null +++ b/src/components/appearance-provider.tsx @@ -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( + null +) + +/** + * AppearanceProvider 管理 themeColor 和 zoomLevel 两个外观偏好。 + * + * 与 next-themes 完全正交:next-themes 负责 , + * 这里负责 和 。 + * + * 注意: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(() => { + 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(() => { + 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 ( + + {children} + + ) +} diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts new file mode 100644 index 0000000..62fdc78 --- /dev/null +++ b/src/hooks/use-appearance.ts @@ -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 } +}