Detect dark/light mode before React hydrates to eliminate the visible white-to-dark flash when opening windows in dark mode. Frontend: - Inline script now reads next-themes localStorage key and applies .dark class, colorScheme, and backgroundColor on <html> before first paint - Add CSS-only fallback via prefers-color-scheme media query in an inline <style> tag that fires before any JS executes macOS backend: - Detect system dark mode via `defaults read -g AppleInterfaceStyle` (cached with OnceLock) and set native window background color to match dark theme in apply_platform_window_style - Persist user appearance mode preference (dark/light/system) to DB alongside zoom level so new windows use the correct background - Add update_appearance_mode Tauri command; frontend syncs on mount, on settings change, and on cross-window storage events Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
"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(() => {})
|
||
)
|
||
}
|
||
|
||
function syncAppearanceMode(mode: string) {
|
||
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window))
|
||
return
|
||
import("@/lib/tauri").then((t) =>
|
||
t.updateAppearanceMode(mode).catch(() => {})
|
||
)
|
||
}
|
||
|
||
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`
|
||
syncTrafficLightPosition(zoom)
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom))
|
||
} catch {
|
||
// 同上
|
||
}
|
||
}, [])
|
||
|
||
// Sync traffic-light position and appearance mode on mount
|
||
useEffect(() => {
|
||
syncTrafficLightPosition(zoomLevel)
|
||
try {
|
||
syncAppearanceMode(localStorage.getItem("theme") ?? "system")
|
||
} catch {
|
||
// localStorage unavailable
|
||
}
|
||
// 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)
|
||
}
|
||
}
|
||
// Sync appearance mode to Tauri DB when changed in another window
|
||
if (e.key === "theme") {
|
||
syncAppearanceMode(e.newValue ?? "system")
|
||
}
|
||
}
|
||
window.addEventListener("storage", onStorage)
|
||
return () => window.removeEventListener("storage", onStorage)
|
||
}, [])
|
||
|
||
return (
|
||
<AppearanceContext.Provider
|
||
value={{ themeColor, setThemeColor, zoomLevel, setZoomLevel }}
|
||
>
|
||
{children}
|
||
</AppearanceContext.Provider>
|
||
)
|
||
}
|