"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" function syncTrafficLightPosition(zoom: number) { if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return import("@/lib/tauri").then((t) => t.updateTrafficLightPosition(zoom).catch(() => {}) ) } 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` syncTrafficLightPosition(zoom) try { localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) } catch { // 同上 } }, []) // Sync traffic-light position on mount (initial zoom) useEffect(() => { syncTrafficLightPosition(zoomLevel) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 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` syncTrafficLightPosition(zoom) } } } window.addEventListener("storage", onStorage) return () => window.removeEventListener("storage", onStorage) }, []) return ( {children} ) }