Skip to content

Go back

Electron应用架构与边缘吸附系统《一》

Published:  at 

系列导航


技术选型速览

决策点选择简要理由
桌面框架Electron 39成熟 Stable;React 19 + Node 集成;Windows/macOS/Linux 三端
构建工具electron-vite 5原生支持 main/preload/renderer 三入口 HMR;开箱 alias 配置(@/ 等)
UI 栈React 19 + Tailwind 4React 19 并发特性;Tailwind v4 的 @theme 自定义 token 系统原生支持 CSS 变量
窗口方案无边框(frame: false吸附卷起必须脱离系统标题栏限制
状态管理useState + useCallback待办场景足够简单;无需 zustand / Redux
拖拽排序@dnd-kitReact 生态最灵活的 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  # 构建配置

三个进程的分工:

关键是 无服务端:所有数据存 localStorage,设置存 electron-store(通过 IPC 同步读取)。这让架构显著简化——不需要考虑网络状态、请求重试、后端迁移等复杂度。


无边框窗口:隐式最小尺寸与 setShape

痛点

Electron 在 Windows 上的 BrowserWindow隐式最小尺寸(约 140px × 140px)。即使显式设置 minWidth: 1, minHeight: 1setBounds({ 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 间隔
}

几个边界情况的处理:


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()   // 自动卷回

trayManagersnapManager 通过依赖注入连接:

// index.js
trayManager = createTrayManager({ snapManager, mainWindow, ... })

trayManager 不直接操作窗口,而是调用 snapManager.expand(),由 snapManager 决定展开后的位置、方向、状态。解耦的目的是将来可以独立测试吸附逻辑。


系列导航


小结



Previous Post
Electron主题、国际化与待办事项核心系统《二》
Next Post
从零实现AI助手 - 中断、错误与防抖《四》