115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
import { create } from 'zustand';
|
||
|
||
/**
|
||
* 主题偏好。
|
||
*
|
||
* 三态而不是「开/关」:`system` 是有意义的第三个值,不是 light 的别名。
|
||
* 只给开关的话,用户在白天设成浅色之后,晚上系统切深色时应用不会跟着变 ——
|
||
* 而那恰恰是大多数人想要的默认行为。
|
||
*/
|
||
export type ThemePref = 'light' | 'dark' | 'system';
|
||
|
||
/** 实际生效的主题(system 解析之后的结果)。 */
|
||
export type ResolvedTheme = 'light' | 'dark';
|
||
|
||
const STORAGE_KEY = 'agentmail.theme';
|
||
|
||
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||
|
||
function readStored(): ThemePref {
|
||
try {
|
||
const v = localStorage.getItem(STORAGE_KEY);
|
||
if (v === 'light' || v === 'dark' || v === 'system') return v;
|
||
} catch {
|
||
// 隐私模式下 localStorage 抛异常。跟随系统是最安全的退路 ——
|
||
// 硬编码 light 会让深色偏好的用户每次开页面都被闪一下白屏
|
||
}
|
||
return 'system';
|
||
}
|
||
|
||
function systemPrefersDark(): boolean {
|
||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||
return window.matchMedia(DARK_QUERY).matches;
|
||
}
|
||
|
||
export function resolveTheme(pref: ThemePref): ResolvedTheme {
|
||
if (pref === 'system') return systemPrefersDark() ? 'dark' : 'light';
|
||
return pref;
|
||
}
|
||
|
||
/**
|
||
* 把主题写进 DOM。
|
||
*
|
||
* 类名挂在 `<html>` 而不是 `<body>`:tailwind 的 darkMode:'class' 默认
|
||
* 从根元素找,而且 `<html>` 上的 background-color 才管得到 overscroll
|
||
* 露出的那一片。
|
||
*/
|
||
function apply(resolved: ResolvedTheme) {
|
||
if (typeof document === 'undefined') return;
|
||
const root = document.documentElement;
|
||
root.classList.toggle('dark', resolved === 'dark');
|
||
// 让浏览器把滚动条、表单控件、autofill 背景一并切换。
|
||
// 不设的话深色页面上会出现一条浅色滚动条与白底的自动填充输入框。
|
||
root.style.colorScheme = resolved;
|
||
}
|
||
|
||
interface ThemeState {
|
||
pref: ThemePref;
|
||
resolved: ResolvedTheme;
|
||
setPref: (p: ThemePref) => void;
|
||
/** 在 light / dark 间直接翻转(顶栏那个按钮用)。 */
|
||
toggle: () => void;
|
||
}
|
||
|
||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||
pref: readStored(),
|
||
resolved: resolveTheme(readStored()),
|
||
|
||
setPref: p => {
|
||
const resolved = resolveTheme(p);
|
||
apply(resolved);
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, p);
|
||
} catch {
|
||
// 存不下不影响本次会话
|
||
}
|
||
set({ pref: p, resolved });
|
||
},
|
||
|
||
/**
|
||
* 翻转。
|
||
*
|
||
* 从 `system` 翻转时落到「与当前生效值相反」的显式值,而不是回到
|
||
* system —— 人点这个按钮的意图是「现在换个样子」,把它变成
|
||
* system→light(可能毫无变化)会让按钮看起来坏了。
|
||
*/
|
||
toggle: () => {
|
||
const next: ThemePref = get().resolved === 'dark' ? 'light' : 'dark';
|
||
get().setPref(next);
|
||
}
|
||
}));
|
||
|
||
/**
|
||
* 启动时立刻套用主题,并订阅系统变化。
|
||
*
|
||
* 在 main.tsx 里于 render 之前调用:晚一步就会让深色偏好的用户
|
||
* 看到一帧白色闪屏。
|
||
*
|
||
* 返回取消订阅函数(实际不会用到 —— 应用生命周期内一直需要监听)。
|
||
*/
|
||
export function initTheme(): () => void {
|
||
const store = useThemeStore.getState();
|
||
apply(store.resolved);
|
||
|
||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||
const mq = window.matchMedia(DARK_QUERY);
|
||
const onChange = () => {
|
||
// 只有 pref 为 system 时才跟随系统。显式选了 light/dark 的人
|
||
// 不该因为日落而被切换主题。
|
||
const { pref, setPref } = useThemeStore.getState();
|
||
if (pref === 'system') setPref('system');
|
||
};
|
||
mq.addEventListener('change', onChange);
|
||
return () => mq.removeEventListener('change', onChange);
|
||
}
|