fix(electron): align Windows title bar hover

Use native Window Controls Overlay geometry and sampled background colors so caption hover and drag regions track the system title bar.

Forward original Windows close-handler errors to the shared logger so failures retain their stack traces.
This commit is contained in:
digua authored and digua committed 2026-08-07 22:37:04 +08:00
1 parent 3db40375b6
commit d734389f29
6 files changed
+77 -20

No files matched your search

+2 -2
View File
@@ -25,9 +25,9 @@ export const logger = {
ensureInit()
appLogger.warn('app', message)
},
error: (message: string) => {
error: (message: string, data?: unknown) => {
ensureInit()
appLogger.error('app', message)
appLogger.error('app', message, data)
},
debug: (message: string) => {
ensureInit()
+1 -1
View File
@@ -93,7 +93,7 @@ function handleWindowsClose(win: BrowserWindow): void {
},
quit: requestAppQuit,
onError: (error) => {
logger.error(`Failed to handle Windows close request: ${error instanceof Error ? error.message : String(error)}`)
logger.error('Failed to handle Windows close request', error)
},
})
}
+10 -8
View File
@@ -1,28 +1,26 @@
import type { BrowserWindow, TitleBarOverlayOptions } from 'electron'
const TITLE_BAR_OVERLAY_HEIGHT = 32
const TITLE_BAR_OVERLAY_COLOR = 'rgba(0, 0, 0, 0)'
let currentTitleBarOverlayColor: string | null = null
const TITLE_BAR_OVERLAY_PALETTE = {
light: { symbolColor: '#52525b' },
dark: { symbolColor: '#d4d4d8' },
light: { backgroundColor: '#f9fafb', symbolColor: '#52525b' },
dark: { backgroundColor: '#111827', symbolColor: '#d4d4d8' },
} as const
export function getTitleBarOverlayOptions(isDark: boolean): TitleBarOverlayOptions {
const mode = isDark ? 'dark' : 'light'
return {
color: TITLE_BAR_OVERLAY_COLOR,
color: getTransparentOverlayColor(TITLE_BAR_OVERLAY_PALETTE[mode].backgroundColor),
symbolColor: TITLE_BAR_OVERLAY_PALETTE[mode].symbolColor,
height: TITLE_BAR_OVERLAY_HEIGHT,
}
}
export function getTitleBarOverlayOptionsForColor(color: string): TitleBarOverlayOptions {
return {
color: TITLE_BAR_OVERLAY_COLOR,
// Electron derives the minimize/maximize hover color from the overlay RGB.
// Preserve the sampled background channels while keeping the overlay transparent.
color: getTransparentOverlayColor(color),
symbolColor: getReadableSymbolColor(color),
height: TITLE_BAR_OVERLAY_HEIGHT,
}
}
@@ -44,6 +42,10 @@ export function resetCurrentTitleBarOverlayColor(): void {
currentTitleBarOverlayColor = null
}
function getTransparentOverlayColor(color: string): string {
return /^#[0-9a-f]{6}$/i.test(color) ? `${color}00` : 'rgba(0, 0, 0, 0)'
}
function getReadableSymbolColor(hexColor: string): string {
const match = /^#([0-9a-f]{6})$/i.exec(hexColor)
if (!match) return '#52525b'
+7 -3
View File
@@ -69,14 +69,18 @@
/* 标题栏安全区域高度 - 页面顶部 padding 需要避开此区域
* macOS: 红绿灯在左侧由 Sidebar 处理,右侧内容区无遮挡,使用较小值
* Windows/Linux: 窗口控制按钮在右上角占据 32px 高度,需要完整避开 */
* Windows: 跟随 Window Controls Overlay 提供的系统高度
* Linux: 自定义窗口控制按钮固定占据 32px 高度 */
:root {
--titlebar-area-height: 1rem; /* 16px - macOS */
}
.platform-windows,
.platform-windows {
--titlebar-area-height: env(titlebar-area-height, 2rem);
}
.platform-linux {
--titlebar-area-height: 2rem; /* 32px - 匹配 titleBarOverlay height */
--titlebar-area-height: 2rem;
}
/* 禁用窗口拖拽区域 - 用于弹窗等需要正常交互的元素 */
+14 -1
View File
@@ -33,7 +33,7 @@ function close() {
</script>
<template>
<div class="title-bar">
<div class="title-bar" :class="{ 'title-bar-windows': isWindows }">
<!-- 左侧区域 - macOS 给红绿灯预留空间 -->
<div v-if="isMac" class="traffic-light-spacer" />
@@ -100,6 +100,19 @@ function close() {
height: 100%;
}
/* Window Controls Overlay exposes the system caption geometry through CSS env values. */
.title-bar-windows {
height: env(titlebar-area-height, 32px);
-webkit-app-region: no-drag;
}
.title-bar-windows .drag-region {
width: env(titlebar-area-width, 100%);
margin-left: env(titlebar-area-x, 0px);
flex: none;
-webkit-app-region: drag;
}
/* Windows 窗口控制按钮容器 */
.window-controls {
display: flex;
+43 -5
View File
@@ -8,8 +8,11 @@ interface RgbaColor {
a: number
}
const TITLE_BAR_SAMPLE_Y = 16
const TITLE_BAR_SAMPLE_RIGHT_OFFSET = 80
interface WindowControlsOverlayLike extends EventTarget {
visible: boolean
getTitlebarAreaRect(): DOMRect
}
const TRANSITION_SAMPLE_MS = 420
export function useWindowsTitleBarOverlay(triggers: WatchSource<unknown>[]): void {
@@ -17,6 +20,7 @@ export function useWindowsTitleBarOverlay(triggers: WatchSource<unknown>[]): voi
let frameId = 0
let lastColor = ''
const windowControlsOverlay = getWindowControlsOverlay()
const syncTitleBarColor = () => {
const color = sampleTitleBarBackground()
@@ -50,11 +54,13 @@ export function useWindowsTitleBarOverlay(triggers: WatchSource<unknown>[]): voi
onMounted(() => {
window.addEventListener('resize', handleResize)
windowControlsOverlay?.addEventListener('geometrychange', handleResize)
scheduleSync(TRANSITION_SAMPLE_MS)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
windowControlsOverlay?.removeEventListener('geometrychange', handleResize)
if (frameId) {
cancelAnimationFrame(frameId)
}
@@ -65,11 +71,13 @@ export function useWindowsTitleBarOverlay(triggers: WatchSource<unknown>[]): voi
}
}
// 采样 Windows 标题栏按钮区域下方的真实背景色,并用它推导原生按钮符号颜色。
// 采样 Windows 标题栏按钮区域下方的真实背景色,并用它推导原生按钮符号和 hover 颜色。
// 这里需要沿 elementsFromPoint 返回的层级合成透明背景,才能覆盖设置弹窗淡入淡出时的 blur/backdrop 场景。
function sampleTitleBarBackground(): string | null {
const x = Math.max(0, window.innerWidth - TITLE_BAR_SAMPLE_RIGHT_OFFSET)
const y = TITLE_BAR_SAMPLE_Y
const samplePoint = getTitleBarSamplePoint()
if (!samplePoint) return null
const { x, y } = samplePoint
const elements = document.elementsFromPoint(x, y)
if (elements.length === 0) return null
@@ -87,6 +95,36 @@ function sampleTitleBarBackground(): string | null {
return rgbaToHex(color)
}
function getWindowControlsOverlay(): WindowControlsOverlayLike | undefined {
return (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay
}
function getTitleBarSamplePoint(): { x: number; y: number } | null {
const overlay = getWindowControlsOverlay()
if (!overlay?.visible) return null
const titleBarArea = overlay.getTitlebarAreaRect()
if (titleBarArea.width <= 0 || titleBarArea.height <= 0 || window.innerWidth <= 0) return null
const leftControlsWidth = Math.max(0, titleBarArea.x)
const rightControlsStart = titleBarArea.x + titleBarArea.width
const rightControlsWidth = Math.max(0, window.innerWidth - rightControlsStart)
let x: number
if (rightControlsWidth > 0) {
x = rightControlsStart + rightControlsWidth / 2
} else if (leftControlsWidth > 0) {
x = leftControlsWidth / 2
} else {
return null
}
return {
x: Math.min(window.innerWidth - 1, Math.max(0, x)),
y: Math.max(0, titleBarArea.y + titleBarArea.height / 2),
}
}
function getPageFallbackColor(): RgbaColor {
const rootColor = parseCssColor(window.getComputedStyle(document.documentElement).backgroundColor)
if (rootColor && rootColor.a > 0) return rootColor