Files
codeg/src/components/appearance-provider.tsx
xintaofei 843cf8df19 feat(macos): set traffic-light position via Tauri builder API and sync with zoom
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>
2026-04-12 18:11:40 +08:00

129 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}