Use Tauri's native `traffic_light_position()` builder method to position macOS window controls instead of runtime objc2 calls. A global AtomicU32 tracks the current zoom level so newly created windows reflect the latest zoom. The frontend syncs zoom changes to the backend via a new `update_traffic_light_position` command. - Add `traffic_light_position()` to `apply_platform_window_style` builder - Add `CURRENT_ZOOM` atomic and `traffic_light_position()` helper - Register `update_traffic_light_position` Tauri command - Add `syncTrafficLightPosition` in appearance-provider to sync on zoom change, mount, and cross-tab storage events - Consolidate `ensure_windows_undecorated` calls into `post_window_setup` - Remove dead `on_window_resized` no-op and its Resized event listener Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
129 lines
4.1 KiB
TypeScript
129 lines
4.1 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(() => {})
|
||
)
|
||
}
|
||
|
||
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 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 (
|
||
<AppearanceContext.Provider
|
||
value={{ themeColor, setThemeColor, zoomLevel, setZoomLevel }}
|
||
>
|
||
{children}
|
||
</AppearanceContext.Provider>
|
||
)
|
||
}
|