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