v0.3.4: Bug修复+性能优化+文档大纲功能

🔴 Bug修复 (6):
- Sidebar反斜杠正则修复 (Windows路径规范化)
- FileWatcher文件删除后自动恢复监听
- SearchReplace replaceAll防过期匹配位置
- openFolderDialog null结果守卫
- 保存异常时空路径保护
- Markdown绝对路径图片拼接修复

🟡 性能优化:
- Editor切换标签时内容比较,相同跳过replaceAll

🔵 代码质量:
- 创建共享常量模块 src/shared/constants.ts,消除双副本

🎯 新增功能:
- 文档大纲 (Table of Contents) — 侧边栏标题导航

🔧 换行符统一: CRLF → LF
This commit is contained in:
thzxx
2026-06-04 13:24:23 +08:00
parent efa7f61809
commit 899d2b7914
28 changed files with 2657 additions and 2374 deletions
+271 -271
View File
@@ -1,271 +1,271 @@
<p align="center">
<img src="assets/icon.ico" alt="MarkLite" width="128" height="128">
</p>
<h1 align="center">MarkLite</h1>
<p align="center">
<strong>轻量级 Windows 本地 Markdown 编辑器</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.2-orange?style=flat-square" alt="Version">
</p>
<p align="center">
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。
</p>
---
## ✨ 功能特性
| 功能 | 说明 |
|------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 |
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
## 🚀 快速开始
### 环境要求
- **Node.js** >= 18.x
- **npm** >= 9.x
- **Windows** 10/11 x64
### 安装与运行
```bash
# 克隆仓库
git clone https://gitee.com/thzxx/MarkLite.git
cd MarkLite
# 安装依赖
npm install
# 启动开发模式(带 HMR 热更新)
npm run dev
```
### 打包为 exe 安装包
```bash
# 打包 Windows x64 NSIS 安装包
npm run build
# 打包为便携版(免安装)
npm run build:portable
```
打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。
### 其他命令
```bash
# TypeScript 类型检查
npm run typecheck
# ESLint 代码检查
npm run lint
# 运行单元测试
npm run test
# 监听模式运行测试
npm run test:watch
# 生成测试覆盖率报告
npm run test:coverage
```
## 🧪 测试
项目使用 **Vitest** + **React Testing Library** 作为测试框架。
```bash
# 运行全部测试
npm run test
# 监听模式(开发时持续运行)
npm run test:watch
# 生成覆盖率报告
npm run test:coverage
```
测试覆盖以下核心模块:
- `stores/` — Zustand 状态管理(tabStore, editorStore
- `lib/` — 工具库(fileUtils, markdown, errorHandler
- `hooks/` — 自定义 Hooks
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`
## ⌨️ 快捷键
| 快捷键 | 功能 |
|:-------|:-----|
| `Ctrl + T` | 新建标签页 |
| `Ctrl + W` | 关闭当前标签页 |
| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) |
| `Ctrl + Shift + Tab` | 切换到上一个标签页 |
| `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 预览模式 |
| `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 |
| `Ctrl + B` | 粗体 |
| `Ctrl + I` | 斜体 |
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
| `Esc` | 关闭搜索栏 |
## 🛠️ 技术栈
| 组件 | 技术 | 说明 |
|:-----|:-----|:-----|
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| 编辑器 | [CodeMirror](https://codemirror.net/) v6 | 现代化代码编辑器 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
## 📁 项目结构
```
MarkLite/
├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
├── tsconfig.json # TypeScript 配置
├── tsconfig.node.json # Node 端 TypeScript 配置
├── electron.vite.config.ts # electron-vite 构建配置
├── vitest.config.ts # Vitest 测试框架配置
├── .eslintrc.cjs # ESLint + TypeScript 规则
├── CONTRIBUTING.md # 贡献指南
├── src/
│ ├── main/ # 主进程 (Node.js)
│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册
│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听)
│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁
│ │
│ ├── preload/ # 预加载脚本
│ │ └── index.ts # contextBridge 类型安全暴露
│ │
│ ├── renderer/ # 渲染进程 (React 18)
│ │ ├── index.html # 入口 HTML(含 CSP 策略)
│ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │
│ │ ├── components/ # UI 组件
│ │ │ ├── Toolbar/ # 工具栏
│ │ │ ├── TabBar/ # 标签页栏
│ │ │ ├── Editor/ # CodeMirror 6 编辑器
│ │ │ ├── Preview/ # Markdown 预览面板
│ │ │ ├── Sidebar/ # 侧边栏文件树
│ │ │ ├── StatusBar/ # 状态栏
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
│ │ │ ├── Toast/ # Toast 通知
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
│ │ │ └── Icons.tsx # SVG 图标库
│ │ │
│ │ ├── stores/ # Zustand 状态管理
│ │ │ ├── tabStore.ts # 标签页状态
│ │ │ ├── editorStore.ts # 编辑器状态
│ │ │ └── sidebarStore.ts # 侧边栏状态
│ │ │
│ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useTheme.ts # 暗色/亮色主题
│ │ │ ├── useSettings.ts # 用户设置
│ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ └── useUnsavedWarning.ts # 未保存提醒
│ │ │
│ │ ├── lib/ # 工具库
│ │ │ ├── markdown.ts # Markdown 渲染管线
│ │ │ ├── fileUtils.ts # 文件工具函数
│ │ │ └── constants.ts # 常量定义
│ │ │
│ │ ├── db/ # IndexedDB 持久化层
│ │ │ ├── schema.ts # Dexie 数据库定义
│ │ │ ├── tabRepository.ts # 标签页 CRUD
│ │ │ ├── settingsRepository.ts # 设置 CRUD
│ │ │ └── recentFilesRepository.ts # 最近文件
│ │ │
│ │ ├── types/ # TypeScript 类型
│ │ └── styles/ # 全局样式
│ │
│ └── shared/ # 主进程/渲染进程共享
│ ├── ipc-channels.ts # IPC 通道名常量
│ └── types.ts # 共享类型定义
├── assets/
│ └── icon.ico # 应用图标
├── DESIGN.md # 架构设计文档
├── DEVSETUP.md # 开发环境配置指南
├── LICENSE # MIT 许可证
└── README.md # 本文件
```
## 📝 支持的 Markdown 语法
- ✅ 标题(h1 ~ h6
-**粗体** / *斜体* / ~~删除线~~
- ✅ 有序列表 / 无序列表
- ✅ 任务列表 `- [x]`
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
- ✅ 行内代码
- ✅ 链接 / 图片(支持相对路径)
- ✅ 表格
- ✅ 引用块
- ✅ 水平线
- ✅ HTML 内联元素
- ✅ GFMGitHub Flavored Markdown
## 🔧 Git Hooks
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
```bash
# 初始化 git hooksnpm install 时自动执行)
npm run prepare
```
## 📄 许可证
[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx)
---
<p align="center">
如果觉得有用,请点个 ⭐ Star 支持一下!
</p>
<p align="center">
<img src="assets/icon.ico" alt="MarkLite" width="128" height="128">
</p>
<h1 align="center">MarkLite</h1>
<p align="center">
<strong>轻量级 Windows 本地 Markdown 编辑器</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.2-orange?style=flat-square" alt="Version">
</p>
<p align="center">
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。
</p>
---
## ✨ 功能特性
| 功能 | 说明 |
|------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 |
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
## 🚀 快速开始
### 环境要求
- **Node.js** >= 18.x
- **npm** >= 9.x
- **Windows** 10/11 x64
### 安装与运行
```bash
# 克隆仓库
git clone https://gitee.com/thzxx/MarkLite.git
cd MarkLite
# 安装依赖
npm install
# 启动开发模式(带 HMR 热更新)
npm run dev
```
### 打包为 exe 安装包
```bash
# 打包 Windows x64 NSIS 安装包
npm run build
# 打包为便携版(免安装)
npm run build:portable
```
打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。
### 其他命令
```bash
# TypeScript 类型检查
npm run typecheck
# ESLint 代码检查
npm run lint
# 运行单元测试
npm run test
# 监听模式运行测试
npm run test:watch
# 生成测试覆盖率报告
npm run test:coverage
```
## 🧪 测试
项目使用 **Vitest** + **React Testing Library** 作为测试框架。
```bash
# 运行全部测试
npm run test
# 监听模式(开发时持续运行)
npm run test:watch
# 生成覆盖率报告
npm run test:coverage
```
测试覆盖以下核心模块:
- `stores/` — Zustand 状态管理(tabStore, editorStore
- `lib/` — 工具库(fileUtils, markdown, errorHandler
- `hooks/` — 自定义 Hooks
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`
## ⌨️ 快捷键
| 快捷键 | 功能 |
|:-------|:-----|
| `Ctrl + T` | 新建标签页 |
| `Ctrl + W` | 关闭当前标签页 |
| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) |
| `Ctrl + Shift + Tab` | 切换到上一个标签页 |
| `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 预览模式 |
| `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 |
| `Ctrl + B` | 粗体 |
| `Ctrl + I` | 斜体 |
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
| `Esc` | 关闭搜索栏 |
## 🛠️ 技术栈
| 组件 | 技术 | 说明 |
|:-----|:-----|:-----|
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| 编辑器 | [CodeMirror](https://codemirror.net/) v6 | 现代化代码编辑器 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
## 📁 项目结构
```
MarkLite/
├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
├── tsconfig.json # TypeScript 配置
├── tsconfig.node.json # Node 端 TypeScript 配置
├── electron.vite.config.ts # electron-vite 构建配置
├── vitest.config.ts # Vitest 测试框架配置
├── .eslintrc.cjs # ESLint + TypeScript 规则
├── CONTRIBUTING.md # 贡献指南
├── src/
│ ├── main/ # 主进程 (Node.js)
│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册
│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听)
│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁
│ │
│ ├── preload/ # 预加载脚本
│ │ └── index.ts # contextBridge 类型安全暴露
│ │
│ ├── renderer/ # 渲染进程 (React 18)
│ │ ├── index.html # 入口 HTML(含 CSP 策略)
│ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │
│ │ ├── components/ # UI 组件
│ │ │ ├── Toolbar/ # 工具栏
│ │ │ ├── TabBar/ # 标签页栏
│ │ │ ├── Editor/ # CodeMirror 6 编辑器
│ │ │ ├── Preview/ # Markdown 预览面板
│ │ │ ├── Sidebar/ # 侧边栏文件树
│ │ │ ├── StatusBar/ # 状态栏
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
│ │ │ ├── Toast/ # Toast 通知
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
│ │ │ └── Icons.tsx # SVG 图标库
│ │ │
│ │ ├── stores/ # Zustand 状态管理
│ │ │ ├── tabStore.ts # 标签页状态
│ │ │ ├── editorStore.ts # 编辑器状态
│ │ │ └── sidebarStore.ts # 侧边栏状态
│ │ │
│ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useTheme.ts # 暗色/亮色主题
│ │ │ ├── useSettings.ts # 用户设置
│ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ └── useUnsavedWarning.ts # 未保存提醒
│ │ │
│ │ ├── lib/ # 工具库
│ │ │ ├── markdown.ts # Markdown 渲染管线
│ │ │ ├── fileUtils.ts # 文件工具函数
│ │ │ └── constants.ts # 常量定义
│ │ │
│ │ ├── db/ # IndexedDB 持久化层
│ │ │ ├── schema.ts # Dexie 数据库定义
│ │ │ ├── tabRepository.ts # 标签页 CRUD
│ │ │ ├── settingsRepository.ts # 设置 CRUD
│ │ │ └── recentFilesRepository.ts # 最近文件
│ │ │
│ │ ├── types/ # TypeScript 类型
│ │ └── styles/ # 全局样式
│ │
│ └── shared/ # 主进程/渲染进程共享
│ ├── ipc-channels.ts # IPC 通道名常量
│ └── types.ts # 共享类型定义
├── assets/
│ └── icon.ico # 应用图标
├── DESIGN.md # 架构设计文档
├── DEVSETUP.md # 开发环境配置指南
├── LICENSE # MIT 许可证
└── README.md # 本文件
```
## 📝 支持的 Markdown 语法
- ✅ 标题(h1 ~ h6
-**粗体** / *斜体* / ~~删除线~~
- ✅ 有序列表 / 无序列表
- ✅ 任务列表 `- [x]`
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
- ✅ 行内代码
- ✅ 链接 / 图片(支持相对路径)
- ✅ 表格
- ✅ 引用块
- ✅ 水平线
- ✅ HTML 内联元素
- ✅ GFMGitHub Flavored Markdown
## 🔧 Git Hooks
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
```bash
# 初始化 git hooksnpm install 时自动执行)
npm run prepare
```
## 📄 许可证
[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx)
---
<p align="center">
如果觉得有用,请点个 ⭐ Star 支持一下!
</p>
+28 -28
View File
@@ -1,28 +1,28 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'warn',
'prefer-const': 'error',
},
},
{
ignores: ['dist/', 'node_modules/', 'assets/', 'lib/'],
},
)
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'warn',
'prefer-const': 'error',
},
},
{
ignores: ['dist/', 'node_modules/', 'assets/', 'lib/'],
},
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.3.2",
"version": "0.3.4",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
+3 -7
View File
@@ -1,13 +1,9 @@
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises'
import { join, extname, dirname, basename } from 'path'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
try {
@@ -67,7 +63,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
}
} else {
const ext = extname(entry.name).toLowerCase()
if (ALLOWED_EXTENSIONS.has(ext)) {
if (ALLOWED_EXTENSIONS_SET.has(ext)) {
children.push({ name: entry.name, path: childPath, type: 'file' })
}
}
+15 -5
View File
@@ -24,12 +24,22 @@ export class FileWatcher {
})
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
this.watcher.on('error', () => {
if (this.watcher) {
this.watcher.close()
this.watcher = null
this.stop()
const originalPath = this.currentPath
if (originalPath) {
const pollInterval = setInterval(() => {
try {
if (fs.existsSync(originalPath)) {
clearInterval(pollInterval)
this.start(originalPath)
}
} catch {
clearInterval(pollInterval)
}
}, 2000)
// 最多轮询30秒
setTimeout(() => clearInterval(pollInterval), 30000)
}
this.currentPath = null
this.isSelfWriting = false
})
} catch {
// silently ignore
+140 -140
View File
@@ -1,140 +1,140 @@
import { app, BrowserWindow } from 'electron'
import { join } from 'path'
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { registerIpcHandlers } from './ipc-handlers'
import { readFileContent } from './file-system'
let mainWindow: BrowserWindow | null = null
const state = {
activeFilePath: null as string | null,
pendingFilePath: null as string | null,
isClosing: false,
closeTimeout: null as NodeJS.Timeout | null
}
const fileWatcher = new FileWatcher(() => mainWindow)
const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => {
if (result.success && mainWindow && !mainWindow.isDestroyed()) {
state.activeFilePath = filePath
fileWatcher.start(filePath)
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
}
}).catch((err) => {
// eslint-disable-next-line no-console -- IPC file open error
console.error('openFileInTab failed:', err)
})
}
const lockOk = setupSingleInstanceLock((filePath) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
if (filePath) openFileInTab(filePath)
}
})
if (!lockOk) {
app.quit()
} else {
function setupCloseHandler(): void {
if (!mainWindow) return
mainWindow.on('close', (e) => {
if (state.isClosing) return
state.isClosing = true
e.preventDefault()
try {
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('window:confirmClose')
} else {
mainWindow?.removeAllListeners('close')
mainWindow?.close()
return
}
} catch {
mainWindow?.removeAllListeners('close')
mainWindow?.close()
return
}
state.closeTimeout = setTimeout(() => {
state.closeTimeout = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.removeAllListeners('close')
mainWindow.close()
}
}, 5000)
})
}
// C-05: 窗口重建时重置状态
function initWindow(): void {
state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
state.closeTimeout = null
}
mainWindow = createWindow()
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.webContents.on('did-finish-load', () => {
if (state.pendingFilePath) {
openFileInTab(state.pendingFilePath)
state.pendingFilePath = null
}
})
setupCloseHandler()
mainWindow.on('closed', () => {
fileWatcher.stop()
sidebarWatcher.stop()
mainWindow = null
})
}
app.whenReady().then(() => {
app.on('open-file', (event, filePath) => {
event.preventDefault()
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
state.pendingFilePath = filePath
} else if (mainWindow && !mainWindow.isDestroyed()) {
openFileInTab(filePath)
} else {
state.pendingFilePath = filePath
}
})
// C-03: IPC 处理器只注册一次
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
initWindow()
const cmdFile = getFilePathFromArgs(process.argv)
if (cmdFile) {
state.pendingFilePath = cmdFile
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
// C-03: activate 只重建窗口,不重复注册 IPC
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
initWindow()
}
})
}
import { app, BrowserWindow } from 'electron'
import { join } from 'path'
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { registerIpcHandlers } from './ipc-handlers'
import { readFileContent } from './file-system'
let mainWindow: BrowserWindow | null = null
const state = {
activeFilePath: null as string | null,
pendingFilePath: null as string | null,
isClosing: false,
closeTimeout: null as NodeJS.Timeout | null
}
const fileWatcher = new FileWatcher(() => mainWindow)
const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => {
if (result.success && mainWindow && !mainWindow.isDestroyed()) {
state.activeFilePath = filePath
fileWatcher.start(filePath)
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
}
}).catch((err) => {
// eslint-disable-next-line no-console -- IPC file open error
console.error('openFileInTab failed:', err)
})
}
const lockOk = setupSingleInstanceLock((filePath) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
if (filePath) openFileInTab(filePath)
}
})
if (!lockOk) {
app.quit()
} else {
function setupCloseHandler(): void {
if (!mainWindow) return
mainWindow.on('close', (e) => {
if (state.isClosing) return
state.isClosing = true
e.preventDefault()
try {
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('window:confirmClose')
} else {
mainWindow?.removeAllListeners('close')
mainWindow?.close()
return
}
} catch {
mainWindow?.removeAllListeners('close')
mainWindow?.close()
return
}
state.closeTimeout = setTimeout(() => {
state.closeTimeout = null
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.removeAllListeners('close')
mainWindow.close()
}
}, 5000)
})
}
// C-05: 窗口重建时重置状态
function initWindow(): void {
state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
state.closeTimeout = null
}
mainWindow = createWindow()
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.webContents.on('did-finish-load', () => {
if (state.pendingFilePath) {
openFileInTab(state.pendingFilePath)
state.pendingFilePath = null
}
})
setupCloseHandler()
mainWindow.on('closed', () => {
fileWatcher.stop()
sidebarWatcher.stop()
mainWindow = null
})
}
app.whenReady().then(() => {
app.on('open-file', (event, filePath) => {
event.preventDefault()
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
state.pendingFilePath = filePath
} else if (mainWindow && !mainWindow.isDestroyed()) {
openFileInTab(filePath)
} else {
state.pendingFilePath = filePath
}
})
// C-03: IPC 处理器只注册一次
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
initWindow()
const cmdFile = getFilePathFromArgs(process.argv)
if (cmdFile) {
state.pendingFilePath = cmdFile
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
// C-03: activate 只重建窗口,不重复注册 IPC
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
initWindow()
}
})
}
+230 -228
View File
@@ -1,228 +1,230 @@
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises'
import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击
function validatePath(filePath: string): boolean {
if (!filePath || typeof filePath !== 'string') return false
// 拒绝空字节
if (filePath.includes('\0')) return false
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow'.. 已消失
if (filePath.includes('..')) return false
// 必须是绝对路径
if (!isAbsolute(filePath)) return false
return true
}
export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
fileWatcher: FileWatcher,
sidebarWatcher: SidebarWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
): void {
// 打开文件对话框
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
const win = getMainWindow()
if (!win) return null
try {
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileResult = await readFileContent(filePath)
if (fileResult.success) {
state.activeFilePath = filePath
fileWatcher.start(filePath)
win.setTitle(`MarkLite - ${basename(filePath)}`)
return { filePath, content: fileResult.content }
}
return { error: fileResult.error }
}
return null
} catch (err) {
return { error: (err as Error).message }
}
})
// 读取文件
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
if (!validatePath(filePath)) {
return { success: false, error: '无效的文件路径' }
}
return readFileContent(filePath)
})
// 保存文件
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
if (data.filePath && !validatePath(data.filePath)) {
return { success: false, error: '无效的文件路径' }
}
const win = getMainWindow()
try {
if (data.filePath) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true)
const result = await saveFileContent(data.filePath, data.content)
fileWatcher.setSelfWriting(false)
state.activeFilePath = data.filePath
setTimeout(() => {
if (state.activeFilePath === data.filePath && data.filePath) {
fileWatcher.start(data.filePath)
}
}, 300)
if (win && !win.isDestroyed()) {
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
}
return result
} else {
if (!win) return { success: false, error: '窗口不可用' }
const saveResult = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
})
if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true)
const result = await saveFileContent(saveResult.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (result.success) {
state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath)
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
}
return result
}
return { success: false, canceled: true }
}
} catch (err) {
fileWatcher.setSelfWriting(false)
fileWatcher.start(state.activeFilePath || '')
return { success: false, error: (err as Error).message }
}
})
// 另存为
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const result = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
})
if (!result.canceled) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (saveResult.success) {
state.activeFilePath = result.filePath
setTimeout(() => {
if (state.activeFilePath === result.filePath) {
fileWatcher.start(result.filePath)
}
}, 300)
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
}
return saveResult
}
return { success: false, canceled: true }
} catch (err) {
fileWatcher.setSelfWriting(false)
return { success: false, error: (err as Error).message }
}
})
// 获取当前路径
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
// 文件统计
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
if (!validatePath(filePath)) {
return { success: false, error: '无效的文件路径' }
}
try {
const fileStat = await stat(filePath)
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// 重新加载
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
const result = await readFileContent(state.activeFilePath)
return { ...result, filePath: state.activeFilePath }
})
// 目录树
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) {
return { success: false, error: '无效的目录路径' }
}
try {
const tree = await buildDirTree(dirPath)
return { success: true, tree, rootPath: dirPath }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// 打开文件夹对话框
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
const win = getMainWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]
}
return null
})
// 目录监听
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) return
sidebarWatcher.start(dirPath)
})
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
sidebarWatcher.stop()
})
// 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
state.activeFilePath = filePath || null
if (filePath && !validatePath(filePath)) {
fileWatcher.stop()
return
}
fileWatcher.start(filePath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite')
}
})
// 窗口控制
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
fileWatcher.stop()
win.removeAllListeners('close')
win.close()
}
})
// B-01: 重置关闭状态 + 清除超时定时器
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
state.closeTimeout = null
}
})
}
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises'
import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击
function validatePath(filePath: string): boolean {
if (!filePath || typeof filePath !== 'string') return false
// 拒绝空字节
if (filePath.includes('\0')) return false
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow'.. 已消失
if (filePath.includes('..')) return false
// 必须是绝对路径
if (!isAbsolute(filePath)) return false
return true
}
export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
fileWatcher: FileWatcher,
sidebarWatcher: SidebarWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
): void {
// 打开文件对话框
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
const win = getMainWindow()
if (!win) return null
try {
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileResult = await readFileContent(filePath)
if (fileResult.success) {
state.activeFilePath = filePath
fileWatcher.start(filePath)
win.setTitle(`MarkLite - ${basename(filePath)}`)
return { filePath, content: fileResult.content }
}
return { error: fileResult.error }
}
return null
} catch (err) {
return { error: (err as Error).message }
}
})
// 读取文件
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
if (!validatePath(filePath)) {
return { success: false, error: '无效的文件路径' }
}
return readFileContent(filePath)
})
// 保存文件
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
if (data.filePath && !validatePath(data.filePath)) {
return { success: false, error: '无效的文件路径' }
}
const win = getMainWindow()
try {
if (data.filePath) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true)
const result = await saveFileContent(data.filePath, data.content)
fileWatcher.setSelfWriting(false)
state.activeFilePath = data.filePath
setTimeout(() => {
if (state.activeFilePath === data.filePath && data.filePath) {
fileWatcher.start(data.filePath)
}
}, 300)
if (win && !win.isDestroyed()) {
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
}
return result
} else {
if (!win) return { success: false, error: '窗口不可用' }
const saveResult = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
})
if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true)
const result = await saveFileContent(saveResult.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (result.success) {
state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath)
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
}
return result
}
return { success: false, canceled: true }
}
} catch (err) {
fileWatcher.setSelfWriting(false)
if (state.activeFilePath) {
fileWatcher.start(state.activeFilePath)
}
return { success: false, error: (err as Error).message }
}
})
// 另存为
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const result = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
})
if (!result.canceled) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (saveResult.success) {
state.activeFilePath = result.filePath
setTimeout(() => {
if (state.activeFilePath === result.filePath) {
fileWatcher.start(result.filePath)
}
}, 300)
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
}
return saveResult
}
return { success: false, canceled: true }
} catch (err) {
fileWatcher.setSelfWriting(false)
return { success: false, error: (err as Error).message }
}
})
// 获取当前路径
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
// 文件统计
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
if (!validatePath(filePath)) {
return { success: false, error: '无效的文件路径' }
}
try {
const fileStat = await stat(filePath)
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// 重新加载
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
const result = await readFileContent(state.activeFilePath)
return { ...result, filePath: state.activeFilePath }
})
// 目录树
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) {
return { success: false, error: '无效的目录路径' }
}
try {
const tree = await buildDirTree(dirPath)
return { success: true, tree, rootPath: dirPath }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// 打开文件夹对话框
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
const win = getMainWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0]
}
return null
})
// 目录监听
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) return
sidebarWatcher.start(dirPath)
})
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
sidebarWatcher.stop()
})
// 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
state.activeFilePath = filePath || null
if (filePath && !validatePath(filePath)) {
fileWatcher.stop()
return
}
fileWatcher.start(filePath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite')
}
})
// 窗口控制
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
fileWatcher.stop()
win.removeAllListeners('close')
win.close()
}
})
// B-01: 重置关闭状态 + 清除超时定时器
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
state.closeTimeout = null
}
})
}
+121 -121
View File
@@ -1,121 +1,121 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch'
import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useToast } from './hooks/useToast'
import { useConfirm } from './hooks/useConfirm'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ToastContainer } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const createTab = useTabStore(s => s.createTab)
const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB)
const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit()
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast)
useFileWatch()
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return confirm({
title: '未保存的更改',
message,
variant: 'warning',
confirmLabel: '不保存',
cancelLabel: '取消'
})
}, [confirm])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners(handleSave, handleSaveAs)
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
}, [activeTabId, tabs])
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content) {
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
}
setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
return (
<ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
viewMode={viewMode} onViewModeChange={saveViewMode}
darkMode={darkMode} onToggleDark={toggleDarkMode}
onShowAbout={() => setShowAbout(true)}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
</div>
</ErrorBoundary>
)
}
App.displayName = 'App'
export default App
import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch'
import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useToast } from './hooks/useToast'
import { useConfirm } from './hooks/useConfirm'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ToastContainer } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const createTab = useTabStore(s => s.createTab)
const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB)
const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit()
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast)
useFileWatch()
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return confirm({
title: '未保存的更改',
message,
variant: 'warning',
confirmLabel: '不保存',
cancelLabel: '取消'
})
}, [confirm])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners(handleSave, handleSaveAs)
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
}, [activeTabId, tabs])
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content) {
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
}
setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
return (
<ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
viewMode={viewMode} onViewModeChange={saveViewMode}
darkMode={darkMode} onToggleDark={toggleDarkMode}
onShowAbout={() => setShowAbout(true)}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
</div>
</ErrorBoundary>
)
}
App.displayName = 'App'
export default App
@@ -1,55 +1,56 @@
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.3.2'
interface AboutDialogProps {
onClose: () => void
}
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
} else {
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
}
}
return (
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="about-header">
<AppIcon size={64} />
<h2>MarkLite</h2>
<span className="about-version">{APP_VERSION}</span>
</div>
<div className="about-body">
<p> Windows Markdown </p>
<div className="about-features">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={onClose}></button>
</div>
</div>
)
})
AboutDialog.displayName = 'AboutDialog'
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.3.4'
interface AboutDialogProps {
onClose: () => void
}
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
} else {
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
}
}
return (
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="about-header">
<AppIcon size={64} />
<h2>MarkLite</h2>
<span className="about-version">{APP_VERSION}</span>
</div>
<div className="about-body">
<p> Windows Markdown </p>
<div className="about-features">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={onClose}></button>
</div>
</div>
)
})
AboutDialog.displayName = 'AboutDialog'
@@ -1,108 +1,108 @@
import React, { useEffect, useRef, useCallback } from 'react'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog = React.memo(function ConfirmDialog({
open,
title,
message,
confirmLabel = '确定',
cancelLabel = '取消',
variant = 'warning',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => {
if (!open) {
previousFocusRef.current?.focus()
return
}
previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => {
clearTimeout(timer)
}
}, [open])
// ESC 键关闭
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}, [onCancel])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
// 防止背景滚动
useEffect(() => {
if (!open) return
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = original }
}, [open])
if (!open) return null
return (
<div
className="confirm-overlay"
onClick={onCancel}
role="presentation"
>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
onClick={e => e.stopPropagation()}
>
<div className={`confirm-header confirm-${variant}`}>
<h3 id="confirm-title">{title}</h3>
</div>
<div className="confirm-body">
<p id="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn confirm-btn-cancel"
onClick={onCancel}
type="button"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
className={`confirm-btn confirm-btn-${variant}`}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
})
ConfirmDialog.displayName = 'ConfirmDialog'
import React, { useEffect, useRef, useCallback } from 'react'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog = React.memo(function ConfirmDialog({
open,
title,
message,
confirmLabel = '确定',
cancelLabel = '取消',
variant = 'warning',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => {
if (!open) {
previousFocusRef.current?.focus()
return
}
previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => {
clearTimeout(timer)
}
}, [open])
// ESC 键关闭
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}, [onCancel])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
// 防止背景滚动
useEffect(() => {
if (!open) return
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = original }
}, [open])
if (!open) return null
return (
<div
className="confirm-overlay"
onClick={onCancel}
role="presentation"
>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
onClick={e => e.stopPropagation()}
>
<div className={`confirm-header confirm-${variant}`}>
<h3 id="confirm-title">{title}</h3>
</div>
<div className="confirm-body">
<p id="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn confirm-btn-cancel"
onClick={onCancel}
type="button"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
className={`confirm-btn confirm-btn-${variant}`}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
})
ConfirmDialog.displayName = 'ConfirmDialog'
+116 -105
View File
@@ -1,105 +1,116 @@
import React, { useEffect, useCallback, useState } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
import { SearchReplace } from '../SearchReplace'
interface EditorProps {
darkMode: boolean
}
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const [showSearch, setShowSearch] = useState(false)
const {
containerRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
} = useMilkdown({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode
})
// Load content when switching tabs (only trigger on tab switch, not content edits)
useEffect(() => {
if (!activeTab) return
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection()
})
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
if (!activeTabId) return
updateTabScroll(activeTabId, {
scrollTop: getScrollTop(),
selectionStart: getSelection().from,
selectionEnd: getSelection().to
})
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'b') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleStrongCommand.key))
})
}
if (isCtrl && e.key === 'i') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleEmphasisCommand.key))
})
}
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
e.preventDefault()
setShowSearch(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} />
{showSearch && (
<SearchReplace
getView={getView}
onClose={() => setShowSearch(false)}
/>
)}
<div ref={containerRef} className="milkdown-wrapper" />
</div>
)
})
Editor.displayName = 'Editor'
import React, { useEffect, useCallback, useState, useRef } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
import { SearchReplace } from '../SearchReplace'
import { setEditorViewGetter } from '../../stores/editorStore'
interface EditorProps {
darkMode: boolean
}
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const [showSearch, setShowSearch] = useState(false)
const currentContentRef = useRef('')
const {
containerRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
} = useMilkdown({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode
})
// Load content when switching tabs (only trigger on tab switch, not content edits)
useEffect(() => {
if (!activeTab) return
if (activeTab.content !== currentContentRef.current) {
currentContentRef.current = activeTab.content
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection()
})
}
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Register getView for OutlinePanel navigation
useEffect(() => {
setEditorViewGetter(getView)
return () => setEditorViewGetter(() => null)
}, [getView])
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
if (!activeTabId) return
updateTabScroll(activeTabId, {
scrollTop: getScrollTop(),
selectionStart: getSelection().from,
selectionEnd: getSelection().to
})
}
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'b') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleStrongCommand.key))
})
}
if (isCtrl && e.key === 'i') {
e.preventDefault()
action((editor) => {
editor.action(callCommand(toggleEmphasisCommand.key))
})
}
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
e.preventDefault()
setShowSearch(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} />
{showSearch && (
<SearchReplace
getView={getView}
onClose={() => setShowSearch(false)}
/>
)}
<div ref={containerRef} className="milkdown-wrapper" />
</div>
)
})
Editor.displayName = 'Editor'
+252 -252
View File
@@ -1,252 +1,252 @@
import { useCallback, useRef, useEffect } from 'react'
import {
Editor,
rootCtx,
defaultValueCtx,
editorViewCtx,
prosePluginsCtx
} from '@milkdown/core'
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
import { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
import { history } from '@milkdown/plugin-history'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing'
import { clipboard } from '@milkdown/plugin-clipboard'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state'
// --- Search highlight plugin ---
export interface SearchMatch {
from: number
to: number
}
export interface SearchPluginState {
matches: SearchMatch[]
currentIndex: number
query: string
}
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
function createSearchPlugin(): Plugin<SearchPluginState> {
return new Plugin<SearchPluginState>({
key: searchPluginKey,
state: {
init(): SearchPluginState {
return { matches: [], currentIndex: -1, query: '' }
},
apply(tr, value): SearchPluginState {
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
if (meta) return meta
// When the document changes, map existing match positions through the change
// but only if there's an active search
if (tr.docChanged && value.query) {
// The SearchReplace component will re-search when doc changes,
// but we map positions so decorations stay roughly correct in the interim
return { ...value }
}
return value
}
},
props: {
decorations(state: EditorState) {
const pluginState = searchPluginKey.getState(state)
if (!pluginState || pluginState.matches.length === 0) {
return null
}
const decos: Decoration[] = pluginState.matches.map((match, index) => {
const cls =
index === pluginState.currentIndex
? 'search-match-active'
: 'search-match-highlight'
return Decoration.inline(match.from, match.to, { class: cls })
})
return DecorationSet.create(state.doc, decos)
}
}
})
}
// --- Hook ---
interface UseMilkdownOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
}
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
const isExternalUpdate = useRef(false)
const initialContentRef = useRef(content)
// Keep onChangeRef fresh
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Initialize editor
useEffect(() => {
if (!containerRef.current) return
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
// Configure listener for content changes
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown === prevMarkdown) return
if (!isExternalUpdate.current) {
onChangeRef.current(markdown)
}
})
// Listen for blur events (used for potential future state saving)
lm.blur(() => {
// blur handler placeholder
})
})
.use(commonmark)
.use(gfm)
.use(history)
.use(listener)
.use(indent)
.use(trailing)
.use(clipboard)
let cancelled = false
editor.create().then((created) => {
if (cancelled) {
// 组件已卸载,立即销毁以避免内存泄漏
created.destroy()
return
}
editorRef.current = created
}).catch((err) => {
// eslint-disable-next-line no-console -- editor creation failure must be reported
console.error('Milkdown editor creation failed:', err)
})
return () => {
cancelled = true
editorRef.current?.destroy()
editorRef.current = null
}
}, [])
// Dark mode: update CSS custom properties on the container
useEffect(() => {
const container = containerRef.current
if (!container) return
if (darkMode) {
container.classList.add('milkdown-dark')
} else {
container.classList.remove('milkdown-dark')
}
}, [darkMode])
// External content update (tab switch)
const setContent = useCallback((newContent: string) => {
const editor = editorRef.current
if (!editor) return
isExternalUpdate.current = true
try {
editor.action(milkdownReplaceAll(newContent))
} catch {
// replaceAll may fail if editor is not fully ready
} finally {
isExternalUpdate.current = false
}
}, [])
// Scroll position management
const getScrollTop = useCallback((): number => {
const container = containerRef.current
if (!container) return 0
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
return scroller?.scrollTop ?? container.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
const container = containerRef.current
if (!container) return
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
if (scroller) {
scroller.scrollTop = top
} else {
container.scrollTop = top
}
}, [])
// Selection management
const getSelection = useCallback((): { from: number; to: number } => {
const editor = editorRef.current
if (!editor) return { from: 0, to: 0 }
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (view?.state?.selection) {
return { from: view.state.selection.from, to: view.state.selection.to }
}
return { from: 0, to: 0 }
})
} catch {
return { from: 0, to: 0 }
}
}, [])
const setSelection = useCallback(() => {
// ProseMirror selection setting requires the view instance
// which we access lazily; for now we focus the editor
const container = containerRef.current
if (!container) return
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
pmEditor?.focus()
}, [])
// Get ProseMirror EditorView instance
const getView = useCallback(() => {
const editor = editorRef.current
if (!editor) return null
try {
return editor.action((ctx) => ctx.get(editorViewCtx))
} catch {
return null
}
}, [])
// Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
if (!editor) return
fn(editor)
}, [])
return {
containerRef,
editorRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
}
}
import { useCallback, useRef, useEffect } from 'react'
import {
Editor,
rootCtx,
defaultValueCtx,
editorViewCtx,
prosePluginsCtx
} from '@milkdown/core'
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
import { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
import { history } from '@milkdown/plugin-history'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing'
import { clipboard } from '@milkdown/plugin-clipboard'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state'
// --- Search highlight plugin ---
export interface SearchMatch {
from: number
to: number
}
export interface SearchPluginState {
matches: SearchMatch[]
currentIndex: number
query: string
}
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
function createSearchPlugin(): Plugin<SearchPluginState> {
return new Plugin<SearchPluginState>({
key: searchPluginKey,
state: {
init(): SearchPluginState {
return { matches: [], currentIndex: -1, query: '' }
},
apply(tr, value): SearchPluginState {
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
if (meta) return meta
// When the document changes, map existing match positions through the change
// but only if there's an active search
if (tr.docChanged && value.query) {
// The SearchReplace component will re-search when doc changes,
// but we map positions so decorations stay roughly correct in the interim
return { ...value }
}
return value
}
},
props: {
decorations(state: EditorState) {
const pluginState = searchPluginKey.getState(state)
if (!pluginState || pluginState.matches.length === 0) {
return null
}
const decos: Decoration[] = pluginState.matches.map((match, index) => {
const cls =
index === pluginState.currentIndex
? 'search-match-active'
: 'search-match-highlight'
return Decoration.inline(match.from, match.to, { class: cls })
})
return DecorationSet.create(state.doc, decos)
}
}
})
}
// --- Hook ---
interface UseMilkdownOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
}
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
const isExternalUpdate = useRef(false)
const initialContentRef = useRef(content)
// Keep onChangeRef fresh
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Initialize editor
useEffect(() => {
if (!containerRef.current) return
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
// Configure listener for content changes
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown === prevMarkdown) return
if (!isExternalUpdate.current) {
onChangeRef.current(markdown)
}
})
// Listen for blur events (used for potential future state saving)
lm.blur(() => {
// blur handler placeholder
})
})
.use(commonmark)
.use(gfm)
.use(history)
.use(listener)
.use(indent)
.use(trailing)
.use(clipboard)
let cancelled = false
editor.create().then((created) => {
if (cancelled) {
// 组件已卸载,立即销毁以避免内存泄漏
created.destroy()
return
}
editorRef.current = created
}).catch((err) => {
// eslint-disable-next-line no-console -- editor creation failure must be reported
console.error('Milkdown editor creation failed:', err)
})
return () => {
cancelled = true
editorRef.current?.destroy()
editorRef.current = null
}
}, [])
// Dark mode: update CSS custom properties on the container
useEffect(() => {
const container = containerRef.current
if (!container) return
if (darkMode) {
container.classList.add('milkdown-dark')
} else {
container.classList.remove('milkdown-dark')
}
}, [darkMode])
// External content update (tab switch)
const setContent = useCallback((newContent: string) => {
const editor = editorRef.current
if (!editor) return
isExternalUpdate.current = true
try {
editor.action(milkdownReplaceAll(newContent))
} catch {
// replaceAll may fail if editor is not fully ready
} finally {
isExternalUpdate.current = false
}
}, [])
// Scroll position management
const getScrollTop = useCallback((): number => {
const container = containerRef.current
if (!container) return 0
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
return scroller?.scrollTop ?? container.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
const container = containerRef.current
if (!container) return
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
if (scroller) {
scroller.scrollTop = top
} else {
container.scrollTop = top
}
}, [])
// Selection management
const getSelection = useCallback((): { from: number; to: number } => {
const editor = editorRef.current
if (!editor) return { from: 0, to: 0 }
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (view?.state?.selection) {
return { from: view.state.selection.from, to: view.state.selection.to }
}
return { from: 0, to: 0 }
})
} catch {
return { from: 0, to: 0 }
}
}, [])
const setSelection = useCallback(() => {
// ProseMirror selection setting requires the view instance
// which we access lazily; for now we focus the editor
const container = containerRef.current
if (!container) return
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
pmEditor?.focus()
}, [])
// Get ProseMirror EditorView instance
const getView = useCallback(() => {
const editor = editorRef.current
if (!editor) return null
try {
return editor.action((ctx) => ctx.get(editorViewCtx))
} catch {
return null
}
}, [])
// Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
if (!editor) return
fn(editor)
}, [])
return {
containerRef,
editorRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
}
}
@@ -1,88 +1,88 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// eslint-disable-next-line no-console -- React error boundary standard pattern
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = (): void => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
textAlign: 'center',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2>
<pre
style={{
padding: '1rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
maxWidth: '600px',
overflow: 'auto',
fontSize: '0.875rem',
color: '#666',
}}
>
{this.state.error?.message}
</pre>
<button
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1.5rem',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '1rem',
}}
>
</button>
</div>
)
}
return this.props.children
}
}
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// eslint-disable-next-line no-console -- React error boundary standard pattern
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = (): void => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
textAlign: 'center',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2>
<pre
style={{
padding: '1rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
maxWidth: '600px',
overflow: 'auto',
fontSize: '0.875rem',
color: '#666',
}}
>
{this.state.error?.message}
</pre>
<button
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1.5rem',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '1rem',
}}
>
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,99 @@
import React, { memo } from 'react'
// --- Types ---
export interface Heading {
level: number
text: string
/** Position in document (character offset from markdown source) */
pos: number
}
// --- Heading Parser ---
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
/**
* Parse headings from raw markdown content using regex.
*/
export function parseHeadings(markdown: string): Heading[] {
const headings: Heading[] = []
let match: RegExpExecArray | null
// Reset regex state
HEADING_RE.lastIndex = 0
while ((match = HEADING_RE.exec(markdown)) !== null) {
const level = match[1].length
const text = match[2].trim()
headings.push({ level, text, pos: match.index })
}
return headings
}
// --- Component ---
interface OutlinePanelProps {
headings: Heading[]
onNavigate: (pos: number) => void
activeHeadingIndex: number | null
}
interface OutlineItemProps {
heading: Heading
isActive: boolean
onNavigate: (pos: number) => void
}
const OutlineItem = memo(function OutlineItem({
heading,
isActive,
onNavigate
}: OutlineItemProps) {
return (
<button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading.pos)}
title={heading.text}
aria-label={`跳转到标题:${heading.text}`}
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
>
<span className="outline-level-dot" />
<span className="outline-item-text">{heading.text}</span>
</button>
)
})
export const OutlinePanel = memo(function OutlinePanel({
headings,
onNavigate,
activeHeadingIndex
}: OutlinePanelProps) {
if (headings.length === 0) {
return (
<div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div>
<div className="outline-empty"></div>
</div>
)
}
return (
<div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div>
<div className="outline-list" role="list" aria-label="标题列表">
{headings.map((h, i) => (
<OutlineItem
key={`${h.text}-${h.pos}`}
heading={h}
isActive={i === activeHeadingIndex}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
})
OutlinePanel.displayName = 'OutlinePanel'
@@ -0,0 +1,2 @@
export { OutlinePanel, parseHeadings } from './OutlinePanel'
export type { Heading } from './OutlinePanel'
+102 -102
View File
@@ -1,102 +1,102 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { renderMarkdown } from '../../lib/markdown'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
// PF-07: 防抖延迟常量
const PREVIEW_DEBOUNCE_MS = 150
export const Preview = React.memo(function Preview() {
const [html, setHtml] = useState('')
const [rendering, setRendering] = useState(false)
const previewRef = useRef<HTMLDivElement>(null)
const requestIdRef = useRef(0)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const setLoading = useEditorStore(s => s.setLoading)
// PF-07: 响应式渲染 + 防抖
useEffect(() => {
if (!activeTab) {
setHtml('')
setRendering(false)
setLoading('markdown-render', false)
return
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
setRendering(true)
setLoading('markdown-render', true)
debounceTimerRef.current = setTimeout(() => {
const requestId = ++requestIdRef.current
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
if (requestId === requestIdRef.current) {
setHtml(result)
setRendering(false)
setLoading('markdown-render', false)
}
})
}, PREVIEW_DEBOUNCE_MS)
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const link = (e.target as HTMLElement).closest('a')
if (!link) return
e.preventDefault()
const href: string | null = link.getAttribute('href')
if (!href) return
if (href.startsWith('#')) {
const target = previewRef.current?.querySelector(href)
if (target) target.scrollIntoView({ behavior: 'smooth' })
return
}
try {
const url = new URL(href)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
} catch {
return
}
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} else {
window.open(href, '_blank', 'noopener')
}
}, [])
return (
<div
ref={previewRef}
id="preview"
className="markdown-body"
role="region"
aria-label="Markdown预览"
aria-live="polite"
aria-busy={rendering}
onClick={handleClick}
>
{rendering && html === '' && (
<div className="preview-loading" aria-label="正在渲染Markdown">
<LoadingSpinner size="medium" label="正在渲染..." />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
Preview.displayName = 'Preview'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { renderMarkdown } from '../../lib/markdown'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
// PF-07: 防抖延迟常量
const PREVIEW_DEBOUNCE_MS = 150
export const Preview = React.memo(function Preview() {
const [html, setHtml] = useState('')
const [rendering, setRendering] = useState(false)
const previewRef = useRef<HTMLDivElement>(null)
const requestIdRef = useRef(0)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const setLoading = useEditorStore(s => s.setLoading)
// PF-07: 响应式渲染 + 防抖
useEffect(() => {
if (!activeTab) {
setHtml('')
setRendering(false)
setLoading('markdown-render', false)
return
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
setRendering(true)
setLoading('markdown-render', true)
debounceTimerRef.current = setTimeout(() => {
const requestId = ++requestIdRef.current
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
if (requestId === requestIdRef.current) {
setHtml(result)
setRendering(false)
setLoading('markdown-render', false)
}
})
}, PREVIEW_DEBOUNCE_MS)
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const link = (e.target as HTMLElement).closest('a')
if (!link) return
e.preventDefault()
const href: string | null = link.getAttribute('href')
if (!href) return
if (href.startsWith('#')) {
const target = previewRef.current?.querySelector(href)
if (target) target.scrollIntoView({ behavior: 'smooth' })
return
}
try {
const url = new URL(href)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
} catch {
return
}
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} else {
window.open(href, '_blank', 'noopener')
}
}, [])
return (
<div
ref={previewRef}
id="preview"
className="markdown-body"
role="region"
aria-label="Markdown预览"
aria-live="polite"
aria-busy={rendering}
onClick={handleClick}
>
{rendering && html === '' && (
<div className="preview-loading" aria-label="正在渲染Markdown">
<LoadingSpinner size="medium" label="正在渲染..." />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
Preview.displayName = 'Preview'
@@ -205,17 +205,21 @@ export const SearchReplace = memo(function SearchReplace({
const view = getView()
if (!view) return
const matches = matchesRef.current
if (matches.length === 0) return
// 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current)
if (freshMatches.length === 0) return
// Process matches in reverse order to preserve positions
// 从后往前替换以保持位置正确
const tr = view.state.tr
for (let i = matches.length - 1; i >= 0; i--) {
tr.insertText(replacement, matches[i].from, matches[i].to)
for (let i = freshMatches.length - 1; i >= 0; i--) {
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
}
view.dispatch(tr)
// Re-search after replacement
// 更新ref
matchesRef.current = []
// 替换后重新搜索
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current)
})
+139 -108
View File
@@ -1,108 +1,139 @@
import React, { useCallback } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { FolderPlus, File } from '../Icons'
import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
const norm = (p: string) => p.replace(/\\\\/g, '/')
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useTabStore(s => s.getActiveTab())
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const isVisible = useSidebarStore(s => s.isVisible)
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
useAutoExpandDir(activeFilePath)
const handleFileClick = useCallback(async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return }
if (!window.electronAPI) return
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
recentFilesRepository.add(path)
}
}, [tabs, switchToTab, createTab])
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
if (!rootPath) return true
return !norm(t.filePath).startsWith(norm(rootPath))
})
if (!isVisible) return null
return (
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
<div id="sidebar-header">
<span id="sidebar-title"></span>
<button
className="sidebar-header-btn"
onClick={handleOpenFolder}
title="打开文件夹"
aria-label="打开文件夹"
>
<FolderPlus size={14} />
</button>
</div>
<nav id="sidebar-tree" role="tree" aria-label="文件树">
{independentFiles.length > 0 && (
<div className="independent-files-section" role="group" aria-label="已打开的文件">
<div className="independent-files-header" id="independent-files-label"></div>
{independentFiles.map(tab => (
<div key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
role="treeitem"
tabIndex={0}
aria-selected={tab.id === activeTabId}
aria-label={getFileName(tab.filePath!)}
onClick={() => switchToTab(tab.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
>
<span className="tree-icon"><File size={14} /></span>
<span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>}
</div>
))}
</div>
)}
{rootPath && (
<>
<div className="sidebar-section-header" id="folder-tree-label"></div>
<FileTree
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</aside>
)
})
Sidebar.displayName = 'Sidebar'
import React, { useCallback, useMemo } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { FolderPlus, File } from '../Icons'
import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import { getEditorView } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state'
const norm = (p: string) => p.replace(/\\/g, '/')
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useTabStore(s => s.getActiveTab())
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const isVisible = useSidebarStore(s => s.isVisible)
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
const headings = useMemo(() => {
if (!activeTab?.content) return []
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// Navigate to heading position in editor
const handleHeadingNavigate = useCallback((pos: number) => {
const view = getEditorView()
if (!view) return
try {
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, pos, pos)
)
view.dispatch(tr)
view.focus()
} catch {
// Position may be invalid if content has changed
}
}, [])
const handleFileClick = useCallback(async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return }
if (!window.electronAPI) return
const result = await window.electronAPI.readFile(path)
if (result.success && result.content) {
createTab(path, result.content)
recentFilesRepository.add(path)
}
}, [tabs, switchToTab, createTab])
const independentFiles = tabs.filter(t => {
if (!t.filePath) return false
if (!rootPath) return true
return !norm(t.filePath).startsWith(norm(rootPath))
})
if (!isVisible) return null
return (
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
<div id="sidebar-header">
<span id="sidebar-title"></span>
<button
className="sidebar-header-btn"
onClick={handleOpenFolder}
title="打开文件夹"
aria-label="打开文件夹"
>
<FolderPlus size={14} />
</button>
</div>
<nav id="sidebar-tree" role="tree" aria-label="文件树">
{independentFiles.length > 0 && (
<div className="independent-files-section" role="group" aria-label="已打开的文件">
<div className="independent-files-header" id="independent-files-label"></div>
{independentFiles.map(tab => (
<div key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
role="treeitem"
tabIndex={0}
aria-selected={tab.id === activeTabId}
aria-label={getFileName(tab.filePath!)}
onClick={() => switchToTab(tab.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
>
<span className="tree-icon"><File size={14} /></span>
<span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>}
</div>
))}
</div>
)}
{rootPath && (
<>
<div className="sidebar-section-header" id="folder-tree-label"></div>
<FileTree
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div className="sidebar-outline-section">
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={null}
/>
</div>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</aside>
)
})
Sidebar.displayName = 'Sidebar'
+253 -253
View File
@@ -1,253 +1,253 @@
import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useConfirm } from '../../hooks/useConfirm'
import { getFileName } from '../../lib/fileUtils'
import { Close, Plus } from '../Icons'
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
interface ContextMenuState {
visible: boolean
x: number
y: number
tabId: string
}
export const TabBar = React.memo(function TabBar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const switchToTab = useTabStore(s => s.switchToTab)
const closeTab = useTabStore(s => s.closeTab)
const createTab = useTabStore(s => s.createTab)
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
const closeAllTabs = useTabStore(s => s.closeAllTabs)
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签
const scrollToActiveTab = useCallback(() => {
const tabList = tabListRef.current
if (!tabList) return
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
if (!activeTab) return
const listRect = tabList.getBoundingClientRect()
const tabRect = activeTab.getBoundingClientRect()
// 如果标签在可视区域左侧之外
if (tabRect.left < listRect.left) {
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
}
// 如果标签在可视区域右侧之外
else if (tabRect.right > listRect.right) {
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
}
}, [])
// 自动滚动到活动标签
useEffect(() => {
requestAnimationFrame(scrollToActiveTab)
}, [activeTabId, scrollToActiveTab])
// 支持鼠标滚轮水平滚动标签栏
useEffect(() => {
const tabList = tabListRef.current
if (!tabList) return
const handleWheel = (e: WheelEvent) => {
// 检查是否有水平溢出
if (tabList.scrollWidth <= tabList.clientWidth) return
// 阻止默认滚动
e.preventDefault()
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
tabList.scrollLeft += delta
}
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
tabList.addEventListener('wheel', handleWheel, { passive: false })
return () => tabList.removeEventListener('wheel', handleWheel)
}, [])
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(tabId)
}, [tabs, closeTab, confirm])
// C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault()
e.stopPropagation()
const MENU_WIDTH = 170
const MENU_HEIGHT = 140
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
}, [])
useEffect(() => {
if (!menu.visible) return
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [menu.visible])
const handleMenuClose = useCallback(async () => {
const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab, confirm])
const handleMenuCloseOthers = useCallback(async () => {
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
if (otherModified.length > 0) {
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭其他标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs, confirm])
const handleMenuCloseAll = useCallback(async () => {
const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭全部标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs, confirm])
const handleMenuCloseRight = useCallback(async () => {
const index = tabs.findIndex(t => t.id === menu.tabId)
const rightTabs = tabs.slice(index + 1)
const modified = rightTabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭右侧标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm])
const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1
})()
if (tabs.length === 0) return null
return (
<>
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
</span>
<button
className="tab-close"
onClick={(e) => handleClose(e, tab.id)}
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
>
<Close size={10} />
</button>
</div>
))}
</div>
<button
className="tab-add-btn"
onClick={() => createTab(null, '')}
title="新建标签页 (Ctrl+T)"
aria-label="新建标签页"
>
<Plus size={14} />
</button>
{/* 右键菜单 */}
{menu.visible && (
<div
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
role="menu"
aria-label="标签操作"
onClick={(e) => e.stopPropagation()}
>
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
</div>
{tabs.length > 1 && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
</div>
)}
{hasRightTabs && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
</div>
)}
<div className="tab-context-divider" role="separator" />
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
</div>
</div>
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</>
)
})
TabBar.displayName = 'TabBar'
import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useConfirm } from '../../hooks/useConfirm'
import { getFileName } from '../../lib/fileUtils'
import { Close, Plus } from '../Icons'
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
interface ContextMenuState {
visible: boolean
x: number
y: number
tabId: string
}
export const TabBar = React.memo(function TabBar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
const switchToTab = useTabStore(s => s.switchToTab)
const closeTab = useTabStore(s => s.closeTab)
const createTab = useTabStore(s => s.createTab)
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
const closeAllTabs = useTabStore(s => s.closeAllTabs)
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签
const scrollToActiveTab = useCallback(() => {
const tabList = tabListRef.current
if (!tabList) return
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
if (!activeTab) return
const listRect = tabList.getBoundingClientRect()
const tabRect = activeTab.getBoundingClientRect()
// 如果标签在可视区域左侧之外
if (tabRect.left < listRect.left) {
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
}
// 如果标签在可视区域右侧之外
else if (tabRect.right > listRect.right) {
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
}
}, [])
// 自动滚动到活动标签
useEffect(() => {
requestAnimationFrame(scrollToActiveTab)
}, [activeTabId, scrollToActiveTab])
// 支持鼠标滚轮水平滚动标签栏
useEffect(() => {
const tabList = tabListRef.current
if (!tabList) return
const handleWheel = (e: WheelEvent) => {
// 检查是否有水平溢出
if (tabList.scrollWidth <= tabList.clientWidth) return
// 阻止默认滚动
e.preventDefault()
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
tabList.scrollLeft += delta
}
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
tabList.addEventListener('wheel', handleWheel, { passive: false })
return () => tabList.removeEventListener('wheel', handleWheel)
}, [])
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(tabId)
}, [tabs, closeTab, confirm])
// C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault()
e.stopPropagation()
const MENU_WIDTH = 170
const MENU_HEIGHT = 140
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
}, [])
useEffect(() => {
if (!menu.visible) return
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [menu.visible])
const handleMenuClose = useCallback(async () => {
const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({
title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab, confirm])
const handleMenuCloseOthers = useCallback(async () => {
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
if (otherModified.length > 0) {
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭其他标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs, confirm])
const handleMenuCloseAll = useCallback(async () => {
const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭全部标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs, confirm])
const handleMenuCloseRight = useCallback(async () => {
const index = tabs.findIndex(t => t.id === menu.tabId)
const rightTabs = tabs.slice(index + 1)
const modified = rightTabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
const confirmed = await confirm({
title: '关闭右侧标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`,
variant: 'warning',
confirmLabel: '关闭'
})
if (!confirmed) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm])
const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1
})()
if (tabs.length === 0) return null
return (
<>
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
</span>
<button
className="tab-close"
onClick={(e) => handleClose(e, tab.id)}
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
>
<Close size={10} />
</button>
</div>
))}
</div>
<button
className="tab-add-btn"
onClick={() => createTab(null, '')}
title="新建标签页 (Ctrl+T)"
aria-label="新建标签页"
>
<Plus size={14} />
</button>
{/* 右键菜单 */}
{menu.visible && (
<div
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
role="menu"
aria-label="标签操作"
onClick={(e) => e.stopPropagation()}
>
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
</div>
{tabs.length > 1 && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
</div>
)}
{hasRightTabs && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
</div>
)}
<div className="tab-context-divider" role="separator" />
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
</div>
</div>
)}
</div>
<ConfirmDialog {...confirmDialogProps} />
</>
)
})
TabBar.displayName = 'TabBar'
+84 -84
View File
@@ -1,84 +1,84 @@
import { useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
import type { ToastType } from '../components/Toast/Toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
*/
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return
setLoading('file-open', true)
const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) {
createTab(result.filePath, result.content)
if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} catch (error) {
logError('打开文件失败', error)
showToast('打开文件失败', 'error')
} finally {
setLoading('file-open', false)
}
}, [createTab, showToast, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content
})
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('保存文件失败', error)
showToast('保存失败', 'error')
}
}, [getActiveTab, showToast])
const handleSaveAs = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFileAs({ content: tab.content })
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('另存为失败', error)
showToast('另存为失败', 'error')
}
}, [getActiveTab, showToast])
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} finally {
setLoading('file-open', false)
}
}, [createTab, setLoading])
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
}
import { useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
import type { ToastType } from '../components/Toast/Toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
*/
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return
setLoading('file-open', true)
const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) {
createTab(result.filePath, result.content)
if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} catch (error) {
logError('打开文件失败', error)
showToast('打开文件失败', 'error')
} finally {
setLoading('file-open', false)
}
}, [createTab, showToast, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content
})
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('保存文件失败', error)
showToast('保存失败', 'error')
}
}, [getActiveTab, showToast])
const handleSaveAs = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFileAs({ content: tab.content })
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success')
}
} catch (error) {
logError('另存为失败', error)
showToast('另存为失败', 'error')
}
}, [getActiveTab, showToast])
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} finally {
setLoading('file-open', false)
}
}, [createTab, setLoading])
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
}
@@ -16,6 +16,7 @@ export function useFolderOperations() {
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.openFolderDialog()
if (!result) return
if (!result.canceled && result.filePaths[0]) {
const dirPath = result.filePaths[0]
setRootPath(dirPath)
+2 -8
View File
@@ -1,8 +1,2 @@
// 常量定义
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
// 重新导出共享常量
export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants'
+43 -43
View File
@@ -1,43 +1,43 @@
/**
* CQ-05: 统一错误处理模块
* 提供一致的错误处理模式,替换分散的 try-catch
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly cause?: unknown
) {
super(message)
this.name = 'AppError'
}
}
/** 日志级别的错误处理(仅 console.error,不中断流程) */
export function logError(context: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error)
// eslint-disable-next-line no-console -- intentional logging utility
console.error(`[${context}] ${message}`, error)
}
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AppError) return error.message
if (error instanceof Error) return `${fallback}: ${error.message}`
return fallback
}
/** 通用 try-catch 包装器,返回 [result, error] */
export async function tryAsync<T>(
fn: () => Promise<T>,
context: string
): Promise<[T | null, null] | [null, AppError]> {
try {
const result = await fn()
return [result, null]
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return [null, new AppError(`${context}: ${message}`, context, error)]
}
}
/**
* CQ-05: 统一错误处理模块
* 提供一致的错误处理模式,替换分散的 try-catch
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly cause?: unknown
) {
super(message)
this.name = 'AppError'
}
}
/** 日志级别的错误处理(仅 console.error,不中断流程) */
export function logError(context: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error)
// eslint-disable-next-line no-console -- intentional logging utility
console.error(`[${context}] ${message}`, error)
}
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AppError) return error.message
if (error instanceof Error) return `${fallback}: ${error.message}`
return fallback
}
/** 通用 try-catch 包装器,返回 [result, error] */
export async function tryAsync<T>(
fn: () => Promise<T>,
context: string
): Promise<[T | null, null] | [null, AppError]> {
try {
const result = await fn()
return [result, null]
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return [null, new AppError(`${context}: ${message}`, context, error)]
}
}
+123 -115
View File
@@ -1,115 +1,123 @@
import { unified, type Plugin } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块)
function resolveRelativePath(base: string, rel: string): string {
const sep = base.includes('\\') ? '\\' : '/'
const parts = base.split(sep)
parts.pop() // 移除文件名
const relParts = rel.split('/')
for (const p of relParts) {
if (p === '.' || p === '') continue
if (p === '..') {
if (parts.length > 0) parts.pop()
} else {
parts.push(p)
}
}
return parts.join(sep)
}
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
return () => (tree: Root) => {
if (!filePath) return
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/'
function visit(node: Element | Root): void {
if (!node.children) return
for (const child of node.children) {
if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src)
if (!resolvedPath.startsWith(dir + sep)) return
child.properties = {
...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
}
}
}
if (child.type === 'element') {
visit(child as Element)
}
}
}
visit(tree)
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 10
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
}
})
.use(rehypeFixImages(filePath ?? null))
.use(rehypeHighlight)
.use(rehypeStringify)
}
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
const key: string = filePath ?? '__null__'
if (processorCache.has(key)) {
const cached = processorCache.get(key)!
processorCache.delete(key)
processorCache.set(key, cached)
return cached
}
const processor = buildProcessor(filePath)
if (processorCache.size >= MAX_CACHE_SIZE) {
const oldestKey = processorCache.keys().next().value
if (oldestKey !== undefined) {
processorCache.delete(oldestKey)
}
}
processorCache.set(key, processor)
return processor
}
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = await processor.process(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
import { unified, type Plugin } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块)
function resolveRelativePath(base: string, rel: string): string {
const sep = base.includes('\\') ? '\\' : '/'
const parts = base.split(sep)
parts.pop() // 移除文件名
const relParts = rel.split('/')
for (const p of relParts) {
if (p === '.' || p === '') continue
if (p === '..') {
if (parts.length > 0) parts.pop()
} else {
parts.push(p)
}
}
return parts.join(sep)
}
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
return () => (tree: Root) => {
if (!filePath) return
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/'
function visit(node: Element | Root): void {
if (!node.children) return
for (const child of node.children) {
if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
// 处理Unix风格绝对路径(以/开头)
if (src.startsWith('/')) {
child.properties = {
...child.properties,
src: 'file://' + src
}
continue
}
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src)
if (!resolvedPath.startsWith(dir + sep)) return
child.properties = {
...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
}
}
}
if (child.type === 'element') {
visit(child as Element)
}
}
}
visit(tree)
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 10
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
}
})
.use(rehypeFixImages(filePath ?? null))
.use(rehypeHighlight)
.use(rehypeStringify)
}
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
const key: string = filePath ?? '__null__'
if (processorCache.has(key)) {
const cached = processorCache.get(key)!
processorCache.delete(key)
processorCache.set(key, cached)
return cached
}
const processor = buildProcessor(filePath)
if (processorCache.size >= MAX_CACHE_SIZE) {
const oldestKey = processorCache.keys().next().value
if (oldestKey !== undefined) {
processorCache.delete(oldestKey)
}
}
processorCache.set(key, processor)
return processor
}
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = await processor.process(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
+13
View File
@@ -1,6 +1,19 @@
import { create } from 'zustand'
import type { EditorView } from '@milkdown/prose/view'
import type { ViewMode } from '../types/settings'
// Module-level getter/setter for the ProseMirror EditorView
// Used by OutlinePanel for heading navigation
let _getView: (() => EditorView | null) | null = null
export function setEditorViewGetter(fn: () => EditorView | null) {
_getView = fn
}
export function getEditorView(): EditorView | null {
return _getView ? _getView() : null
}
interface EditorState {
viewMode: ViewMode
darkMode: boolean
+246 -246
View File
@@ -1,246 +1,246 @@
import { create } from 'zustand'
import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: unknown[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}) as F & { cancel: () => void }
debounced.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return debounced
}
interface TabState {
tabs: Tab[]
activeTabId: string | null
mruStack: string[]
_loaded: boolean
createTab: (filePath?: string | null, content?: string) => Tab
closeTab: (tabId: string) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
closeTabsToRight: (tabId: string) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
loadFromDB: () => Promise<void>
saveToDB: () => Promise<void>
}
// PF-08: 模块级防抖保存函数(500ms延迟)
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
function getActualSaveToDB(get: () => TabState) {
return async () => {
const { tabs } = get()
if (tabs.length === 0) {
await tabRepository.clearAll()
return
}
const snapshots = tabs.map(t => ({
id: t.id,
filePath: t.filePath,
content: t.content,
isModified: t.isModified,
scrollTop: t.scrollTop,
selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd,
updatedAt: Date.now()
}))
await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
}
}
export const useTabStore = create<TabState>((set, get) => {
const actualSave = getActualSaveToDB(get)
_debouncedSaveToDB = debounce(actualSave, 500)
return {
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
loadFromDB: async () => {
if (get()._loaded) return
try {
const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({
id: s.id,
filePath: s.filePath,
content: s.content,
isModified: s.isModified,
scrollTop: s.scrollTop,
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd
}))
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true })
} else {
set({ _loaded: true })
}
} catch (err) {
// eslint-disable-next-line no-console -- DB load error
console.error('Failed to load tabs from DB:', err)
set({ _loaded: true })
}
},
saveToDB: async () => {
if (_debouncedSaveToDB) {
_debouncedSaveToDB()
}
},
createTab: (filePath: string | null = null, content: string = ''): Tab => {
if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) {
get().switchToTab(existing.id)
return existing
}
}
const tab: Tab = {
id: nanoid(),
filePath,
content,
isModified: false,
scrollTop: 0,
selectionStart: 0,
selectionEnd: 0
}
set(state => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id
}))
_debouncedSaveToDB?.()
return tab
},
closeTab: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.filter(t => t.id !== tabId)
const newMru = state.mruStack.filter(id => id !== tabId)
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null
} else {
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) {
newActiveId = mruCandidate
newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
}
}
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
})
_debouncedSaveToDB?.()
},
closeOtherTabs: (tabId: string) => {
set(state => {
const target = state.tabs.find(t => t.id === tabId)
if (!target) return state
return { tabs: [target], activeTabId: tabId, mruStack: [] }
})
_debouncedSaveToDB?.()
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] })
_debouncedSaveToDB?.()
},
closeTabsToRight: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId
: tabId
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
}
})
_debouncedSaveToDB?.()
},
switchToTab: (tabId: string) => {
set(state => {
if (state.activeTabId === tabId) return state
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
updateTabContent: (tabId: string, content: string) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
}))
},
setModified: (tabId: string, modified: boolean) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, isModified: modified } : t
)
}))
},
getActiveTab: (): Tab | null => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
}
}
})
import { create } from 'zustand'
import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: unknown[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}) as F & { cancel: () => void }
debounced.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return debounced
}
interface TabState {
tabs: Tab[]
activeTabId: string | null
mruStack: string[]
_loaded: boolean
createTab: (filePath?: string | null, content?: string) => Tab
closeTab: (tabId: string) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
closeTabsToRight: (tabId: string) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
loadFromDB: () => Promise<void>
saveToDB: () => Promise<void>
}
// PF-08: 模块级防抖保存函数(500ms延迟)
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
function getActualSaveToDB(get: () => TabState) {
return async () => {
const { tabs } = get()
if (tabs.length === 0) {
await tabRepository.clearAll()
return
}
const snapshots = tabs.map(t => ({
id: t.id,
filePath: t.filePath,
content: t.content,
isModified: t.isModified,
scrollTop: t.scrollTop,
selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd,
updatedAt: Date.now()
}))
await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
}
}
export const useTabStore = create<TabState>((set, get) => {
const actualSave = getActualSaveToDB(get)
_debouncedSaveToDB = debounce(actualSave, 500)
return {
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
loadFromDB: async () => {
if (get()._loaded) return
try {
const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({
id: s.id,
filePath: s.filePath,
content: s.content,
isModified: s.isModified,
scrollTop: s.scrollTop,
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd
}))
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true })
} else {
set({ _loaded: true })
}
} catch (err) {
// eslint-disable-next-line no-console -- DB load error
console.error('Failed to load tabs from DB:', err)
set({ _loaded: true })
}
},
saveToDB: async () => {
if (_debouncedSaveToDB) {
_debouncedSaveToDB()
}
},
createTab: (filePath: string | null = null, content: string = ''): Tab => {
if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) {
get().switchToTab(existing.id)
return existing
}
}
const tab: Tab = {
id: nanoid(),
filePath,
content,
isModified: false,
scrollTop: 0,
selectionStart: 0,
selectionEnd: 0
}
set(state => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id
}))
_debouncedSaveToDB?.()
return tab
},
closeTab: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.filter(t => t.id !== tabId)
const newMru = state.mruStack.filter(id => id !== tabId)
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null
} else {
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
if (mruCandidate) {
newActiveId = mruCandidate
newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
}
}
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
})
_debouncedSaveToDB?.()
},
closeOtherTabs: (tabId: string) => {
set(state => {
const target = state.tabs.find(t => t.id === tabId)
if (!target) return state
return { tabs: [target], activeTabId: tabId, mruStack: [] }
})
_debouncedSaveToDB?.()
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] })
_debouncedSaveToDB?.()
},
closeTabsToRight: (tabId: string) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId
: tabId
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
}
})
_debouncedSaveToDB?.()
},
switchToTab: (tabId: string) => {
set(state => {
if (state.activeTabId === tabId) return state
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
updateTabContent: (tabId: string, content: string) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
}))
},
setModified: (tabId: string, modified: boolean) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, isModified: modified } : t
)
}))
},
getActiveTab: (): Tab | null => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
}
}
})
+104
View File
@@ -1600,3 +1600,107 @@ button:focus-visible,
background: rgba(255, 180, 0, 0.4);
outline-color: rgba(255, 180, 0, 0.6);
}
/* ===== Outline Panel (Table of Contents) ===== */
.sidebar-outline-section {
flex-shrink: 0;
max-height: 40%;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.outline-panel {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.outline-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.outline-list::-webkit-scrollbar {
width: 4px;
}
.outline-list::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
.outline-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 3px 8px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
text-align: left;
transition: all 0.1s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.outline-item:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.outline-item.active {
color: var(--primary);
background: var(--primary-light);
}
.outline-item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.outline-level-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
}
.outline-item:hover .outline-level-dot,
.outline-item.active .outline-level-dot {
background: var(--primary);
}
.outline-item-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-empty {
padding: 13px 12px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
+7
View File
@@ -0,0 +1,7 @@
// 共享常量 — 主进程和渲染进程共用
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])