系列导航
技术选型速览
| 决策点 | 选择 | 简要理由 |
|---|---|---|
| 桌面框架 | Electron 39 | 成熟 Stable;React 19 + Node 集成;Windows/macOS/Linux 三端 |
| 构建工具 | electron-vite 5 | 原生支持 main/preload/renderer 三入口 HMR;开箱 alias 配置(@/ 等) |
| UI 栈 | React 19 + Tailwind 4 | React 19 并发特性;Tailwind v4 的 @theme 自定义 token 系统原生支持 CSS 变量 |
| 窗口方案 | 无边框(frame: false) | 吸附卷起必须脱离系统标题栏限制 |
| 状态管理 | useState + useCallback | 待办场景足够简单;无需 zustand / Redux |
| 拖拽排序 | @dnd-kit | React 生态最灵活的 DnD 库;SortableContext + PointerSensor 开箱即用 |
| 持久化 | localStorage | 单机数据量极小;无服务端不需要 electron-store 的加密特性 |
| 跨进程设置 | electron-store | 托盘菜单的主题/语言需要跨进程固化,IPC 同步读取 |
| 压缩便携包 | archiver(纯 JS zip) | 避免构建环境依赖系统 7-Zip |
项目架构总览
slide2do/
├── src/
│ ├── main/ # Electron 主进程
│ │ ├── index.js # 入口:窗口创建、管理器初始化、IPC 注册
│ │ ├── managers/
│ │ │ ├── snapManager.js # 吸附状态机核心
│ │ │ └── trayManager.js # 系统托盘
│ │ ├── config/
│ │ │ ├── constants.js # 窗口/吸附配置常量
│ │ │ └── store.js # electron-store 封装
│ │ └── utils/
│ │ └── windowShape.js # setShape 窗口裁剪工具
│ ├── preload/
│ │ └── index.js # contextBridge 暴露 window.api
│ ├── renderer/
│ │ └── src/
│ │ ├── main.jsx # React 挂载入口
│ │ ├── App.jsx # 根组件:吸附动画 + 撤销 Toast
│ │ ├── contexts/
│ │ │ ├── ThemeContext.jsx # 主题上下文
│ │ │ └── I18nContext.jsx # 国际化上下文
│ │ ├── hooks/
│ │ │ └── useTodos.js # 待办数据管理与持久化
│ │ └── components/
│ │ ├── AddTodoForm.jsx # 表单 + 优先级选择
│ │ ├── TodoList.jsx # 过滤/搜索/拖拽容器
│ │ ├── TodoItem.jsx # 单条:编辑/优先级/行内展开
│ │ └── UndoToast.jsx # 撤销删除浮层
│ └── shared/
│ └── locales.js # 主进程与渲染进程共用的语言表
├── scripts/
│ └── postbuild-win.mjs # 构建后压缩便携包
└── electron-builder.yml # 构建配置
三个进程的分工:
- main:窗口生命周期、吸附检测、托盘菜单、Store 持久化。不参与任何业务逻辑。
- preload:用
contextBridge.exposeInMainWorld暴露window.api,只透传主进程的IPC处理器(minimize、close、onWindowSnap)。 - renderer:纯 React 应用,所有业务逻辑(增删改查、排序、过滤、多语言、主题)都在浏览器端完成。
关键是 无服务端:所有数据存 localStorage,设置存 electron-store(通过 IPC 同步读取)。这让架构显著简化——不需要考虑网络状态、请求重试、后端迁移等复杂度。
无边框窗口:隐式最小尺寸与 setShape
痛点
Electron 在 Windows 上的 BrowserWindow 有隐式最小尺寸(约 140px × 140px)。即使显式设置 minWidth: 1, minHeight: 1,setBounds({ width: 5, height: 650 }) 也不会生效——窗口卡在系统最小值。
这与边缘吸附的需求直接冲突:当窗口吸附到屏幕边缘时,我们希望它收缩成一条 5px 厚的窄条,仅留下鼠标命中区域。
方案:setShape
// windowShape.js(简化)
export function applyRolledShape(win, direction, threshold) {
const { width, height } = win.getBounds()
let rects
switch (direction) {
case 'top':
rects = [{ x: 0, y: 0, width, height: threshold }]
break
case 'bottom':
rects = [{ x: 0, y: height - threshold, width, height: threshold }]
break
// left / right 同理
}
win.setShape(rects)
}
思路:窗口的 setBounds 保持原始大小(例如 450×650),但用 setShape 把命中区域裁剪到仅留一条 5px 的窄条。这样系统认为窗口仍然是 450×650(不触发最小尺寸限制),但用户只能点到那 5px 的边缘。
反向操作是 win.setShape([])——清空形状,让整个窗口重新可交互。
此方案来自 electron#32302。仅在 Windows 需要,macOS 无此隐式限制。
配套:滚轮检测的坐标系转换
由于 getBounds 返回的还是完整窗口尺寸,而 setShape 只裁剪命中区,鼠标悬停检测不能用 getBounds 直接判断。因此 windowShape.js 额外提供了 rolledHitScreenRect():
export function rolledHitScreenRect(direction, bounds, threshold) {
// 返回与 setShape 对齐的 5px 屏幕矩形
// 供轮询代码判断鼠标是否在"可见条"内
}
这是关键一致性约定:setShape 裁剪了多少,滚动检测就认为窗口有多大,两者永远对齐。
吸附状态机
snapManager.js 的核心是一个四状态自动机:
┌──────────────────────────────────┐
│ │
▼ │
┌─────────┐ 拖拽到边缘 ┌──────────┐ │
│ NONE │ ───────────────► │ SETUP │ │
│ │ │ (卷起中) │ │
└─────────┘ └────┬─────┘ │
▲ │ │
│ 卷起动画完成 │
│ │ │
│ ┌──────▼─────┐ │
│ │ ROLLED │ │
│ │ (5px 窄条) │ │
│ └──────┬─────┘ │
│ │ │
│ 鼠标悬停/展开 │
│ │ │
│ ┌──────▼─────┐ │
│ │ EXPANDED ├─┘
│ │ (正常尺寸) │
│ └──────┬─────┘
│ │
│ 鼠标离开/卷起
│ │
└───── cleanup() ───────────┘
状态由三个字段表示:
state = {
direction: 'top' | 'bottom' | 'left' | 'right' | null,
isSnapped: boolean, // 是否处于吸附模式
isAnimating: boolean, // 动画进行中(防止轮询重复触发)
suppressRollUp: boolean // 抑制自动卷起(托盘打开时临时置位)
}
三种触发路径
| 触发方式 | 调用方法 | 行为 |
|---|---|---|
| 拖拽到屏幕边缘 | handleWindowMove() | 检测边缘阈值 → setup(direction) |
| 窗口失去焦点 | rollUp() | 如果已吸附且展开态 → 卷起 |
| 托盘点击打开 | expand() | 展开但不退出吸附,置 suppressRollUp |
鼠标轮询:展开/卷起的实时检测
核心难题:窗口卷成 5px 后,用户怎么再看到它?
方案是鼠标位置轮询:
startMousePolling(direction) {
this.mousePollInterval = setInterval(() => {
// 1. 跳过动画中和抑制期
if (this.state.isAnimating) return
if (this.state.suppressRollUp) return
// 2. 获取鼠标屏幕坐标
const cursor = screen.getCursorScreenPoint()
// 3. 判断鼠标是否在卷起条的命中区内
const bounds = win.getBounds()
const hitRect = rolledHitScreenRect(direction, bounds)
const isInside = pointInRect(cursor.x, cursor.y, hitRect)
// 4. 展开或卷起
if (isInside && 是卷起态) → expand()
if (!isInside && 是展开态) → rollUp()
}, 100) // 100ms 间隔
}
几个边界情况的处理:
- 动画冲突:
isAnimating标志位防止展开动画还在跑时就触发卷起,反之亦然。 - 托盘抑制:从托盘打开窗口时,鼠标大概率在窗口外,轮询会立即触发卷回。所以
expand()先置suppressRollUp = true,等鼠标移入再解除。 - 解除抑制:轮询代码中有专门一条分支检测”鼠标在展开窗口内 →
suppressRollUp = false”。
Windows blur 事件的可靠性修复
问题
Electron 在 Windows 上,无边框透明窗口的 blur 事件只在窗口被 setBounds 或 move “触达”之后才触发。初次创建窗口时,点击其他窗口不会触发 blur。
发现过程
在托盘 focusMainWindow 中看到:
expand() {
// ...
clearRolledShape(win)
win.setBounds(restoreBounds)
win.setBounds(win.getBounds()) // ← 关键行
notifySnapChange(false, direction)
}
setBounds(win.getBounds()) 看起来像一个无操作,但它强迫 Electron 的 Windows 实现重新注册命中测试,后续的 blur 事件才能正常工作。
这个修复的一致性约束:每次展开后(包括轮询中的展开)都必须保证后续有一次 setBounds 调用。目前的轮询展开直接调 mainWindow.setBounds(restoreBounds) 已经够用了——真正的问题只在托盘展开路径上暴露。
另一个坑:cleanup() 破坏 blur → rollUp 链
cleanup() {
this.state = { direction: null, isSnapped: false }
// 清空 isSnapped!blur 里的 if (isSnapped) rollUp() 不会执行
}
如果从 rollUp() 调用 cleanup(),窗口卷回后就永远不能重新吸附了。关键修复:托盘”打开”操作必须用 expand()(保留 isSnapped = true),而不是 cleanup()。
托盘与主进程协作模型
托盘点击
└→ focusMainWindow()
├→ snapManager.expand() // 展开(保留 isSnapped)
├→ win.show()
├→ win.focus()
└→ win.setAlwaysOnTop(true)
│
失去焦点时
└→ mainWindow.on('blur')
└→ snapManager.rollUp() // 自动卷回
trayManager 与 snapManager 通过依赖注入连接:
// index.js
trayManager = createTrayManager({ snapManager, mainWindow, ... })
trayManager 不直接操作窗口,而是调用 snapManager.expand(),由 snapManager 决定展开后的位置、方向、状态。解耦的目的是将来可以独立测试吸附逻辑。
系列导航
小结
- 用
setShape绕过了 Windows 无边框窗口的隐式最小尺寸限制,实现了 5px 卷起条——同时保持与其他平台的兼容(非 Windows 跳过 setShape)。 - 吸附状态机用 三个布尔字段 覆盖了六种状态转换路径,由 100ms 鼠标轮询 驱动。
- Windows
blur修复的关键是setBounds(getBounds())触达事件系统,且expand()必须保留isSnapped。 - 一切为单机设计:无服务端、localStorage 持久化、IPC 只做窗口操作与设置同步。