feat(ele): 自绘窗口标题栏 —— 对齐 HarmonyOS 的 PC/2in1 窗口外观
## 问题
用户:「现在窗口外观还是 ele 默认外观,很原始」。
查证了两端在 PC 上的实际做法,差距是**结构性的**:
| | HarmonyOS (PC/2in1) | Electron(改之前)|
|---|---|---|
| 窗口装饰 | `setWindowDecorVisible(false)` 隐掉系统标题栏 | 系统默认标题栏 |
| 顶栏 | 自绘 `AppHeader`(圆角/材质/标题/可选返回键) | 无 |
| 避让 | `Insets` 三量:`statusBar`/`navIndicator`/`windowDecor` | 无这个概念 |
| 拖动 | 系统 | 系统 |
⇒ `grep app-region|titleBarStyle` 在整个 `client/electron/` 命中 **0**。
鸿蒙那边早就走完「内容铺满 + 自绘顶栏」,Electron 还停在最原始的系统窗口。
## 修法
* `frame: false` + `titleBarStyle: 'hidden'` —— 两个**一起**。
只给 `titleBarStyle` 在 Win/Linux 上仍留着系统边框(可拖动、可双击),
观感还是「系统窗口 + 一条自己画的头」;既然自绘窗口按钮 = 框架整个接管,
那圈系统边框就是多余的一层。
★ 不用 `titleBarOverlay`(官方「留系统按钮」那条):它留的是**系统**按钮,
观感仍由系统决定,与「自绘」目标相反,且 Linux 支持不齐。
本项目只有 win/linux target(无 mac)⇒ 统一一条路,不做两套形态。
* 新增 `src/components/TitleBar.tsx`:固定顶栏 + 左标题 + 右三键。
★ 挂点选在 `main.tsx`、与 `.app-backdrop` 同层,**不在 App 里面** ——
App 的根节点有四个 return 分支(宽屏/窄屏/独页/…),
塞进去就得改四处,漏一处就是「某个页面没有标题栏」。
它自己是 `position: fixed`,与 App 布局零耦合。
## 三条实现纪律(都写进注释了)
① **拖动靠 CSS `-webkit-app-region: drag`,不靠 JS 鼠标事件。**
`drag` 区域由浏览器/系统处理,不受页面重排影响(sandbox 下那种
mousemove 算窗口位置的写法既慢又脆)。
★ 代价:**drag 区域里的交互元素收不到点击** ⇒ 按钮与标题文字都显式
`no-drag`。实测 `elementFromPoint` 命中 `BUTTON.titlebar-btn`(不是拖拽层)。
② **最大化状态是「订阅」来的,不是「查」来的。**
最大化有三条**不经过按钮**的路径:双击拖拽区(系统处理,JS 收不到事件)、
`Win+↑↓`、拖到屏幕边缘的 Snap Layouts ⇒ 只在点按钮时查一次,
图标必然与真实状态脱节。所以主进程用 `pushMaxState` 主动推
(`maximize`/`unmaximize`/进退全屏四个事件),渲染层只订阅。
另:`toggleMaximize` 刻意**不**拆成 maximize/unmaximize ——
双击时序上会多一次异步往返,IPC 往返期间用户可能又双击了一次。
⇒ 读状态与决定动作在主进程侧原子完成。
③ **浏览器里整条不渲染。**
没有 bridge 时 `TitleBar` 返回 `null`;高度占位(`html.titlebar-on`)
由 `main.tsx` 用**同一个** `__AGENTMAIL_SHELL__` 判据挂上 ——
CSS 不会看 bridge,不挂则网页端白丢 36px。
## 尺寸为什么是 36px
鸿蒙 PC/2in1 实测 `windowDecor=37`(见 `MainPage.ets` 的 insets 日志
statusBar=38.6 navIndicator=27.8 windowDecor=37)⇒ 两端窗口控件高度对齐同一量级,
免得并排摆两个应用时一个头厚一个头薄。这里取 36:桌面端按物理像素算,
1x 下更接近常见做法,且 12px 字号不出血。**要改就两端一起改。**
## 顺带记一条踩过的坑(它就在这批代码里)
`preload.cjs` 是 `.cjs`,**不能写 TS 类型标注**。第一版写了
`(cb: (maximized: boolean) => void)` ⇒ 整个 preload **静默**加载失败 ⇒
`window.agentmail === undefined` ⇒ 账号读不到(回退到网页版登录页,
显示用户名+密码,桌面壳里注定失败)+ 标题栏不渲染。
★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,照常全绿。
已在 preload 注释里写明,并在 `main-process-security.test.mjs` 加了加载闸门
(下一个提交)。
## 实测
`DISPLAY` 起真窗口 + CDP 取证:
shell=desktop hasBridge=true hasWin=true titlebar=true h=36 appRegion=drag
btns=[最小化, 还原, 关闭] btnHitTarget=BUTTON.titlebar-btn
errs=[](渲染层零异常)
标题栏实测截图含深浅两态,面板圆角与阴影不变。
## 未验(本机无 GUI 交互,只能取证不能点)
最大化/还原按钮点击、双击标题栏、**关闭进托盘**(这个最需要小心,
点错会把应用整个退出)。上一条留待有人手上有真桌面时验。
This commit is contained in:
@ -1743,3 +1743,191 @@ html[data-bg='on'] .glass-card:hover {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
自绘窗口标题栏(2026-10-04)
|
||||
══════════════════════════════════════════════════════════════════════
|
||||
|
||||
主进程已改成 `frame: false`(见 electron/main.cjs 的 BrowserWindow 配置),
|
||||
所以这一条是**窗口框架本身**,不是装饰。
|
||||
|
||||
★ 高度为什么定 36px:鸿蒙侧 PC/2in1 的 `windowDecor` 实测 37vp
|
||||
(见 `MainPage.ets` 的 insets 日志:statusBar=38.6 navIndicator=27.8
|
||||
**windowDecor=37**),两端的窗口控件高度对齐到同一量级,
|
||||
免得并排摆两个应用时一个头厚一个头薄。
|
||||
这里取 36 而不是 37:桌面端按物理像素算,36 在 1x 下更接近常见做法,
|
||||
且能让标题文字在 12px 字号下不出血。**要改就两个一起改**(本仓只有 win/linux)。
|
||||
|
||||
★ 为什么用 `position: fixed` 而不是布局里的一行:
|
||||
它必须覆盖**所有**路由(登录页、加载页、初始化向导、写信全屏),
|
||||
而那些分支各自 `return` 自己的根 div —— 塞进布局就得改每一处,
|
||||
漏一处就是「某个页面没有标题栏」。见 main.tsx 的接入点说明。
|
||||
*/
|
||||
.titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 36px;
|
||||
z-index: 40;
|
||||
|
||||
/*
|
||||
* ★ 拖动区。整条是 drag,但里面的按钮与文字必须 no-drag ——
|
||||
* `drag` 区域里的交互元素**收不到鼠标事件**(这是 Chromium 的规定,
|
||||
* 不是本项目的取舍)。漏了 no-drag 的症状是「按钮画得出来、点不动」。
|
||||
*/
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* 标题文字不参与拖拽命中:否则双击标题想最大化会被当成选中文字 */
|
||||
user-select: none;
|
||||
|
||||
/*
|
||||
* 底色跟着主题走:浅色用 chrome-100、深色用 chrome-900 ——
|
||||
* 与侧栏同族(见上面 --c-chrome-* 的色阶说明),
|
||||
* 这样标题栏与侧栏看起来是**同一块** chrome,不会多出一条"外来"的带子。
|
||||
*/
|
||||
background: rgb(var(--c-chrome-100));
|
||||
/* 底下那条 1px 分隔线用面板分隔线色,不用黑色半透明 —— 深色下后者会脏。 */
|
||||
border-bottom: 1px solid rgb(var(--c-gray-200) / 0.9);
|
||||
}
|
||||
|
||||
.dark .titlebar {
|
||||
background: rgb(var(--c-chrome-900));
|
||||
border-bottom-color: rgb(var(--c-chrome-800));
|
||||
}
|
||||
|
||||
/*
|
||||
* 标题文字。
|
||||
*
|
||||
* ★ `-webkit-app-region: no-drag` 在这里**不是**为了让文字可点,
|
||||
* 而是为了让**双击标题栏最大化**生效:drag 区域的双击由系统接管,
|
||||
* 而文字层如果自己是 no-drag,双击就会被它吃掉、系统收不到。
|
||||
* (按钮也 no-drag,但按钮上的双击不构成「双击标题栏」,语义不同。)
|
||||
*/
|
||||
.titlebar-title {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--c-gray-600));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dark .titlebar-title {
|
||||
color: rgb(var(--c-chrome-400));
|
||||
}
|
||||
|
||||
/* 右侧三个按钮 */
|
||||
.titlebar-btns {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.titlebar-btn {
|
||||
/*
|
||||
* 命中区 46×36:宽高都过了通行下限(Windows 标题栏按钮 46×32,
|
||||
* 这里跟随宽度、把高度补满整条,视觉上更整)。
|
||||
*/
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgb(var(--c-gray-700));
|
||||
cursor: default; /* 系统标题栏按钮不是 pointer */
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.dark .titlebar-btn {
|
||||
color: rgb(var(--c-chrome-400));
|
||||
}
|
||||
|
||||
.titlebar-btn:hover {
|
||||
background: rgb(var(--c-gray-900) / 0.08);
|
||||
}
|
||||
|
||||
.dark .titlebar-btn:hover {
|
||||
background: rgb(var(--c-chrome-400) / 0.12);
|
||||
}
|
||||
|
||||
/* 关闭 hover 用红,与 Windows 一致 —— 这是"唯一危险动作"的通用语言 */
|
||||
.titlebar-btn-close:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
}
|
||||
.dark .titlebar-btn-close:hover {
|
||||
background: #c42b1c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 焦点可见性:键盘用户必须能看到焦点在哪(见 G-1 无障碍欠账的同族要求) */
|
||||
.titlebar-btn:focus-visible {
|
||||
outline: 2px solid rgb(var(--c-accent, 37 99 235));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 图标:12×12 的 viewBox,用 stroke 画(与 icons.tsx 同一套 `currentColor` 约定)。
|
||||
* `fill: none` 必须显式给 —— 否则 SVG 默认 fill=black,叉会变成一个黑块。
|
||||
*/
|
||||
.tb-glyph {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none; /* 图标不参与命中,命中归按钮 */
|
||||
}
|
||||
|
||||
/*
|
||||
* ══ 标题栏占位:把内容整体往下让出 36px ══
|
||||
*
|
||||
* ★ 为什么用 padding 而不是把 App 往下挪:
|
||||
* App 的三个分支(宽屏 / 窄屏 / 无列表的独页)各自是 `h-full`,
|
||||
* 改它们的根节点要改三处且都与各自的布局判断耦合。
|
||||
* 而 `html.titlebar-on` 上加 padding-top 是**一处**、对所有分支一致 ——
|
||||
* 与 `.safe-frame` 处理窄屏安全区是同一形状(那里也是在外层加 padding)。
|
||||
*
|
||||
* ⚠️ 但 `html, body, #root { height: var(--app-height) }` 是 `height` 不是 `min-height`
|
||||
* ⇒ 在 html 上加 padding 会让**总高超出视口**(100% + 36px),
|
||||
* 于是内容底部被裁掉 36px。所以下面这条必须把高度改成 `calc`:
|
||||
* 见 `.titlebar-on { height: ... }` 那段。
|
||||
*/
|
||||
html.titlebar-on {
|
||||
height: calc(var(--app-height) - 36px);
|
||||
/* 上面减了 36,下面补 36 ⇒ 布局视口仍是满的,只有内容被推下去 */
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
html.titlebar-on body,
|
||||
html.titlebar-on #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* 开机动画:`App.tsx` 根上那个 `animation` 会做位移/缩放
|
||||
* (见它 `viewMode`/`commTab` 变化时 cancelAnimationFrame 那段)。
|
||||
* 标题栏是 fixed 的、与内容无关 ⇒ 不参与那些动画,否则每切一次路由
|
||||
* 标题栏自己也要淡入一次(看着像闪)。
|
||||
*/
|
||||
|
||||
/*
|
||||
* ══ 最大化时:标题栏仍然在(Win/Linux 的系统标题栏最大化后也在),
|
||||
* 但不再需要那条分隔线 —— 面板顶边就是分界。 ══
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user