frontend: FE-033 明暗主题三态切换并跟随系统,防闪白+全局过渡+Monaco同步
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -165,6 +165,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
|
||||
.theme-transition,
|
||||
.theme-transition * {
|
||||
transition:
|
||||
background-color 250ms var(--ease-out-quart),
|
||||
border-color 250ms var(--ease-out-quart),
|
||||
color 250ms var(--ease-out-quart) !important;
|
||||
}
|
||||
|
||||
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
|
||||
@utility app-drag {
|
||||
-webkit-app-region: drag;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { FolderCog, PanelBottom, PanelLeft, PanelRight, Unplug } from "lucide-react"
|
||||
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
|
||||
import { isMacElectron } from "~/lib/platform"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 主题三态对应的图标与说明文案。
|
||||
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
|
||||
system: { icon: Monitor, label: "主题:跟随系统" },
|
||||
light: { icon: Sun, label: "主题:亮色" },
|
||||
dark: { icon: Moon, label: "主题:暗色" },
|
||||
}
|
||||
|
||||
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
||||
export interface PanelVisibility {
|
||||
tree: boolean
|
||||
@@ -33,6 +41,9 @@ export function TopBar({
|
||||
}) {
|
||||
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
|
||||
const macInset = isMacElectron()
|
||||
const theme = useTheme()
|
||||
const themeVisual = THEME_VISUALS[theme.preference]
|
||||
const ThemeIcon = themeVisual.icon
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -79,6 +90,14 @@ export function TopBar({
|
||||
<PanelBottom />
|
||||
</PanelToggle>
|
||||
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
|
||||
<ThemeIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
|
||||
|
||||
@@ -126,6 +126,7 @@ export function DiffDialog({
|
||||
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
||||
let originalModel: MonacoEditor.ITextModel | null = null
|
||||
let modifiedModel: MonacoEditor.ITextModel | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
void (async () => {
|
||||
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
||||
if (disposed || !containerRef.current) {
|
||||
@@ -167,10 +168,16 @@ export function DiffDialog({
|
||||
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
||||
})
|
||||
editorRef.current = diffEditor
|
||||
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
|
||||
themeObserver = new MutationObserver(() => {
|
||||
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
editorRef.current = null
|
||||
themeObserver?.disconnect()
|
||||
diffEditor?.dispose()
|
||||
originalModel?.dispose()
|
||||
modifiedModel?.dispose()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
|
||||
export type ThemePreference = "system" | "light" | "dark"
|
||||
|
||||
const STORAGE_KEY = "mactfs.theme"
|
||||
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
|
||||
|
||||
/**
|
||||
* 当前系统外观是否为暗色。
|
||||
*/
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 由偏好推导是否应启用暗色。
|
||||
*/
|
||||
function resolveIsDark(preference: ThemePreference): boolean {
|
||||
return preference === "dark" || (preference === "system" && systemPrefersDark())
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
|
||||
*/
|
||||
function loadPreference(): ThemePreference {
|
||||
if (typeof window === "undefined") {
|
||||
return "system"
|
||||
}
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw === "light" || raw === "dark" ? raw : "system"
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
|
||||
*/
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const root = document.documentElement
|
||||
root.classList.add("theme-transition")
|
||||
root.classList.toggle("dark", resolveIsDark(preference))
|
||||
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
|
||||
}
|
||||
|
||||
/**
|
||||
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
|
||||
* 跟随系统时监听系统外观变化自动切换。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(preference)
|
||||
if (preference !== "system") {
|
||||
return
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const onChange = () => applyTheme("system")
|
||||
media.addEventListener("change", onChange)
|
||||
return () => media.removeEventListener("change", onChange)
|
||||
}, [preference])
|
||||
|
||||
/**
|
||||
* 设置主题偏好并持久化。
|
||||
*/
|
||||
const setPreference = useCallback((next: ThemePreference) => {
|
||||
setPreferenceState(next)
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// 本地存储不可用时仅在当前会话生效
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
|
||||
*/
|
||||
const cycle = useCallback(() => {
|
||||
const index = CYCLE_ORDER.indexOf(preference)
|
||||
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
|
||||
}, [preference, setPreference])
|
||||
|
||||
return { preference, setPreference, cycle }
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏前同步应用主题偏好,避免暗色模式闪白 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user