From 899d2b7914be2fe6fde0ad484f7d424573ed95ac Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 4 Jun 2026 13:24:23 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.4:=20Bug=E4=BF=AE=E5=A4=8D+=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96+=E6=96=87=E6=A1=A3=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Bug修复 (6): - Sidebar反斜杠正则修复 (Windows路径规范化) - FileWatcher文件删除后自动恢复监听 - SearchReplace replaceAll防过期匹配位置 - openFolderDialog null结果守卫 - 保存异常时空路径保护 - Markdown绝对路径图片拼接修复 🟡 性能优化: - Editor切换标签时内容比较,相同跳过replaceAll 🔵 代码质量: - 创建共享常量模块 src/shared/constants.ts,消除双副本 🎯 新增功能: - 文档大纲 (Table of Contents) — 侧边栏标题导航 🔧 换行符统一: CRLF → LF --- README.md | 542 +++++++++--------- eslint.config.mjs | 56 +- package.json | 2 +- src/main/file-system.ts | 10 +- src/main/file-watcher.ts | 20 +- src/main/index.ts | 280 ++++----- src/main/ipc-handlers.ts | 458 +++++++-------- src/renderer/App.tsx | 242 ++++---- .../components/AboutDialog/AboutDialog.tsx | 111 ++-- .../ConfirmDialog/ConfirmDialog.tsx | 216 +++---- src/renderer/components/Editor/Editor.tsx | 221 +++---- src/renderer/components/Editor/useMilkdown.ts | 504 ++++++++-------- .../ErrorBoundary/ErrorBoundary.tsx | 176 +++--- .../components/OutlinePanel/OutlinePanel.tsx | 99 ++++ src/renderer/components/OutlinePanel/index.ts | 2 + src/renderer/components/Preview/Preview.tsx | 204 +++---- .../SearchReplace/SearchReplace.tsx | 16 +- src/renderer/components/Sidebar/Sidebar.tsx | 247 ++++---- src/renderer/components/TabBar/TabBar.tsx | 506 ++++++++-------- src/renderer/hooks/useFileOperations.ts | 168 +++--- src/renderer/hooks/useFolderOperations.ts | 1 + src/renderer/lib/constants.ts | 10 +- src/renderer/lib/errorHandler.ts | 86 +-- src/renderer/lib/markdown.ts | 238 ++++---- src/renderer/stores/editorStore.ts | 13 + src/renderer/stores/tabStore.ts | 492 ++++++++-------- src/renderer/styles/global.css | 104 ++++ src/shared/constants.ts | 7 + 28 files changed, 2657 insertions(+), 2374 deletions(-) create mode 100644 src/renderer/components/OutlinePanel/OutlinePanel.tsx create mode 100644 src/renderer/components/OutlinePanel/index.ts create mode 100644 src/shared/constants.ts diff --git a/README.md b/README.md index 0eec743..d27e4fc 100644 --- a/README.md +++ b/README.md @@ -1,271 +1,271 @@ -

- MarkLite -

- -

MarkLite

- -

- 轻量级 Windows 本地 Markdown 编辑器 -

- -

- Platform - Electron - TypeScript - React - License - Version -

- -

- 基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。
- 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。 -

- ---- - -## ✨ 功能特性 - -| 功能 | 说明 | -|------|------| -| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | -| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | -| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 | -| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | -| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | -| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 | -| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) | -| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | -| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | -| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 | -| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | -| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | -| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | -| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | - -## 🚀 快速开始 - -### 环境要求 - -- **Node.js** >= 18.x -- **npm** >= 9.x -- **Windows** 10/11 x64 - -### 安装与运行 - -```bash -# 克隆仓库 -git clone https://gitee.com/thzxx/MarkLite.git -cd MarkLite - -# 安装依赖 -npm install - -# 启动开发模式(带 HMR 热更新) -npm run dev -``` - -### 打包为 exe 安装包 - -```bash -# 打包 Windows x64 NSIS 安装包 -npm run build - -# 打包为便携版(免安装) -npm run build:portable -``` - -打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。 - -### 其他命令 - -```bash -# TypeScript 类型检查 -npm run typecheck - -# ESLint 代码检查 -npm run lint - -# 运行单元测试 -npm run test - -# 监听模式运行测试 -npm run test:watch - -# 生成测试覆盖率报告 -npm run test:coverage -``` - -## 🧪 测试 - -项目使用 **Vitest** + **React Testing Library** 作为测试框架。 - -```bash -# 运行全部测试 -npm run test - -# 监听模式(开发时持续运行) -npm run test:watch - -# 生成覆盖率报告 -npm run test:coverage -``` - -测试覆盖以下核心模块: -- `stores/` — Zustand 状态管理(tabStore, editorStore) -- `lib/` — 工具库(fileUtils, markdown, errorHandler) -- `hooks/` — 自定义 Hooks - -测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。 - -## ⌨️ 快捷键 - -| 快捷键 | 功能 | -|:-------|:-----| -| `Ctrl + T` | 新建标签页 | -| `Ctrl + W` | 关闭当前标签页 | -| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) | -| `Ctrl + Shift + Tab` | 切换到上一个标签页 | -| `Ctrl + O` | 打开文件 | -| `Ctrl + S` | 保存文件 | -| `Ctrl + Shift + S` | 另存为 | -| `Ctrl + 1` | 编辑模式 | -| `Ctrl + 2` | 预览模式 | -| `Ctrl + F` | 搜索 | -| `Ctrl + H` | 搜索并替换 | -| `Ctrl + B` | 粗体 | -| `Ctrl + I` | 斜体 | -| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | -| `Esc` | 关闭搜索栏 | - -## 🛠️ 技术栈 - -| 组件 | 技术 | 说明 | -|:-----|:-----|:-----| -| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | -| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | -| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | -| 编辑器 | [CodeMirror](https://codemirror.net/) v6 | 现代化代码编辑器 | -| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | -| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | -| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | -| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | -| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | -| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | -| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 | - -## 📁 项目结构 - -``` -MarkLite/ -├── package.json # 项目配置 & 依赖 & electron-builder 打包配置 -├── tsconfig.json # TypeScript 配置 -├── tsconfig.node.json # Node 端 TypeScript 配置 -├── electron.vite.config.ts # electron-vite 构建配置 -├── vitest.config.ts # Vitest 测试框架配置 -├── .eslintrc.cjs # ESLint + TypeScript 规则 -├── CONTRIBUTING.md # 贡献指南 -│ -├── src/ -│ ├── main/ # 主进程 (Node.js) -│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁 -│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册 -│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离 -│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听) -│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁 -│ │ -│ ├── preload/ # 预加载脚本 -│ │ └── index.ts # contextBridge 类型安全暴露 -│ │ -│ ├── renderer/ # 渲染进程 (React 18) -│ │ ├── index.html # 入口 HTML(含 CSP 策略) -│ │ ├── main.tsx # React 入口 -│ │ ├── App.tsx # 根组件:布局编排、全局事件 -│ │ │ -│ │ ├── components/ # UI 组件 -│ │ │ ├── Toolbar/ # 工具栏 -│ │ │ ├── TabBar/ # 标签页栏 -│ │ │ ├── Editor/ # CodeMirror 6 编辑器 -│ │ │ ├── Preview/ # Markdown 预览面板 -│ │ │ ├── Sidebar/ # 侧边栏文件树 -│ │ │ ├── StatusBar/ # 状态栏 -│ │ │ ├── WelcomeScreen/ # 欢迎屏幕 -│ │ │ ├── Toast/ # Toast 通知 -│ │ │ ├── ModifiedBanner/ # 文件外部修改提示 -│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 -│ │ │ └── Icons.tsx # SVG 图标库 -│ │ │ -│ │ ├── stores/ # Zustand 状态管理 -│ │ │ ├── tabStore.ts # 标签页状态 -│ │ │ ├── editorStore.ts # 编辑器状态 -│ │ │ └── sidebarStore.ts # 侧边栏状态 -│ │ │ -│ │ ├── hooks/ # 自定义 Hooks -│ │ │ ├── useTheme.ts # 暗色/亮色主题 -│ │ │ ├── useSettings.ts # 用户设置 -│ │ │ ├── useKeyboard.ts # 全局快捷键 -│ │ │ ├── useDragDrop.ts # 拖拽打开 -│ │ │ ├── useFileWatch.ts # 外部修改监听 -│ │ │ └── useUnsavedWarning.ts # 未保存提醒 -│ │ │ -│ │ ├── lib/ # 工具库 -│ │ │ ├── markdown.ts # Markdown 渲染管线 -│ │ │ ├── fileUtils.ts # 文件工具函数 -│ │ │ └── constants.ts # 常量定义 -│ │ │ -│ │ ├── db/ # IndexedDB 持久化层 -│ │ │ ├── schema.ts # Dexie 数据库定义 -│ │ │ ├── tabRepository.ts # 标签页 CRUD -│ │ │ ├── settingsRepository.ts # 设置 CRUD -│ │ │ └── recentFilesRepository.ts # 最近文件 -│ │ │ -│ │ ├── types/ # TypeScript 类型 -│ │ └── styles/ # 全局样式 -│ │ -│ └── shared/ # 主进程/渲染进程共享 -│ ├── ipc-channels.ts # IPC 通道名常量 -│ └── types.ts # 共享类型定义 -│ -├── assets/ -│ └── icon.ico # 应用图标 -├── DESIGN.md # 架构设计文档 -├── DEVSETUP.md # 开发环境配置指南 -├── LICENSE # MIT 许可证 -└── README.md # 本文件 -``` - -## 📝 支持的 Markdown 语法 - -- ✅ 标题(h1 ~ h6) -- ✅ **粗体** / *斜体* / ~~删除线~~ -- ✅ 有序列表 / 无序列表 -- ✅ 任务列表 `- [x]` -- ✅ 代码块(围栏式 + 语法高亮,180+ 语言) -- ✅ 行内代码 -- ✅ 链接 / 图片(支持相对路径) -- ✅ 表格 -- ✅ 引用块 -- ✅ 水平线 -- ✅ HTML 内联元素 -- ✅ GFM(GitHub Flavored Markdown) - -## 🔧 Git Hooks - -项目使用 **Husky** + **lint-staged** 自动执行代码检查: - -- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化 - -```bash -# 初始化 git hooks(npm install 时自动执行) -npm run prepare -``` - -## 📄 许可证 - -[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx) - ---- - -

- 如果觉得有用,请点个 ⭐ Star 支持一下! -

+

+ MarkLite +

+ +

MarkLite

+ +

+ 轻量级 Windows 本地 Markdown 编辑器 +

+ +

+ Platform + Electron + TypeScript + React + License + Version +

+ +

+ 基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。
+ 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。 +

+ +--- + +## ✨ 功能特性 + +| 功能 | 说明 | +|------|------| +| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | +| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | +| ✏️ **实时编辑** | 左侧编辑器(CodeMirror 6),支持 Tab 缩进、行号显示、折叠、括号匹配 | +| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | +| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | +| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 | +| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) | +| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | +| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | +| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 | +| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | +| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | +| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | +| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | + +## 🚀 快速开始 + +### 环境要求 + +- **Node.js** >= 18.x +- **npm** >= 9.x +- **Windows** 10/11 x64 + +### 安装与运行 + +```bash +# 克隆仓库 +git clone https://gitee.com/thzxx/MarkLite.git +cd MarkLite + +# 安装依赖 +npm install + +# 启动开发模式(带 HMR 热更新) +npm run dev +``` + +### 打包为 exe 安装包 + +```bash +# 打包 Windows x64 NSIS 安装包 +npm run build + +# 打包为便携版(免安装) +npm run build:portable +``` + +打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。 + +### 其他命令 + +```bash +# TypeScript 类型检查 +npm run typecheck + +# ESLint 代码检查 +npm run lint + +# 运行单元测试 +npm run test + +# 监听模式运行测试 +npm run test:watch + +# 生成测试覆盖率报告 +npm run test:coverage +``` + +## 🧪 测试 + +项目使用 **Vitest** + **React Testing Library** 作为测试框架。 + +```bash +# 运行全部测试 +npm run test + +# 监听模式(开发时持续运行) +npm run test:watch + +# 生成覆盖率报告 +npm run test:coverage +``` + +测试覆盖以下核心模块: +- `stores/` — Zustand 状态管理(tabStore, editorStore) +- `lib/` — 工具库(fileUtils, markdown, errorHandler) +- `hooks/` — 自定义 Hooks + +测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。 + +## ⌨️ 快捷键 + +| 快捷键 | 功能 | +|:-------|:-----| +| `Ctrl + T` | 新建标签页 | +| `Ctrl + W` | 关闭当前标签页 | +| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) | +| `Ctrl + Shift + Tab` | 切换到上一个标签页 | +| `Ctrl + O` | 打开文件 | +| `Ctrl + S` | 保存文件 | +| `Ctrl + Shift + S` | 另存为 | +| `Ctrl + 1` | 编辑模式 | +| `Ctrl + 2` | 预览模式 | +| `Ctrl + F` | 搜索 | +| `Ctrl + H` | 搜索并替换 | +| `Ctrl + B` | 粗体 | +| `Ctrl + I` | 斜体 | +| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | +| `Esc` | 关闭搜索栏 | + +## 🛠️ 技术栈 + +| 组件 | 技术 | 说明 | +|:-----|:-----|:-----| +| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | +| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | +| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | +| 编辑器 | [CodeMirror](https://codemirror.net/) v6 | 现代化代码编辑器 | +| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | +| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | +| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | +| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | +| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | +| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | +| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 | + +## 📁 项目结构 + +``` +MarkLite/ +├── package.json # 项目配置 & 依赖 & electron-builder 打包配置 +├── tsconfig.json # TypeScript 配置 +├── tsconfig.node.json # Node 端 TypeScript 配置 +├── electron.vite.config.ts # electron-vite 构建配置 +├── vitest.config.ts # Vitest 测试框架配置 +├── .eslintrc.cjs # ESLint + TypeScript 规则 +├── CONTRIBUTING.md # 贡献指南 +│ +├── src/ +│ ├── main/ # 主进程 (Node.js) +│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁 +│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册 +│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离 +│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听) +│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁 +│ │ +│ ├── preload/ # 预加载脚本 +│ │ └── index.ts # contextBridge 类型安全暴露 +│ │ +│ ├── renderer/ # 渲染进程 (React 18) +│ │ ├── index.html # 入口 HTML(含 CSP 策略) +│ │ ├── main.tsx # React 入口 +│ │ ├── App.tsx # 根组件:布局编排、全局事件 +│ │ │ +│ │ ├── components/ # UI 组件 +│ │ │ ├── Toolbar/ # 工具栏 +│ │ │ ├── TabBar/ # 标签页栏 +│ │ │ ├── Editor/ # CodeMirror 6 编辑器 +│ │ │ ├── Preview/ # Markdown 预览面板 +│ │ │ ├── Sidebar/ # 侧边栏文件树 +│ │ │ ├── StatusBar/ # 状态栏 +│ │ │ ├── WelcomeScreen/ # 欢迎屏幕 +│ │ │ ├── Toast/ # Toast 通知 +│ │ │ ├── ModifiedBanner/ # 文件外部修改提示 +│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 +│ │ │ └── Icons.tsx # SVG 图标库 +│ │ │ +│ │ ├── stores/ # Zustand 状态管理 +│ │ │ ├── tabStore.ts # 标签页状态 +│ │ │ ├── editorStore.ts # 编辑器状态 +│ │ │ └── sidebarStore.ts # 侧边栏状态 +│ │ │ +│ │ ├── hooks/ # 自定义 Hooks +│ │ │ ├── useTheme.ts # 暗色/亮色主题 +│ │ │ ├── useSettings.ts # 用户设置 +│ │ │ ├── useKeyboard.ts # 全局快捷键 +│ │ │ ├── useDragDrop.ts # 拖拽打开 +│ │ │ ├── useFileWatch.ts # 外部修改监听 +│ │ │ └── useUnsavedWarning.ts # 未保存提醒 +│ │ │ +│ │ ├── lib/ # 工具库 +│ │ │ ├── markdown.ts # Markdown 渲染管线 +│ │ │ ├── fileUtils.ts # 文件工具函数 +│ │ │ └── constants.ts # 常量定义 +│ │ │ +│ │ ├── db/ # IndexedDB 持久化层 +│ │ │ ├── schema.ts # Dexie 数据库定义 +│ │ │ ├── tabRepository.ts # 标签页 CRUD +│ │ │ ├── settingsRepository.ts # 设置 CRUD +│ │ │ └── recentFilesRepository.ts # 最近文件 +│ │ │ +│ │ ├── types/ # TypeScript 类型 +│ │ └── styles/ # 全局样式 +│ │ +│ └── shared/ # 主进程/渲染进程共享 +│ ├── ipc-channels.ts # IPC 通道名常量 +│ └── types.ts # 共享类型定义 +│ +├── assets/ +│ └── icon.ico # 应用图标 +├── DESIGN.md # 架构设计文档 +├── DEVSETUP.md # 开发环境配置指南 +├── LICENSE # MIT 许可证 +└── README.md # 本文件 +``` + +## 📝 支持的 Markdown 语法 + +- ✅ 标题(h1 ~ h6) +- ✅ **粗体** / *斜体* / ~~删除线~~ +- ✅ 有序列表 / 无序列表 +- ✅ 任务列表 `- [x]` +- ✅ 代码块(围栏式 + 语法高亮,180+ 语言) +- ✅ 行内代码 +- ✅ 链接 / 图片(支持相对路径) +- ✅ 表格 +- ✅ 引用块 +- ✅ 水平线 +- ✅ HTML 内联元素 +- ✅ GFM(GitHub Flavored Markdown) + +## 🔧 Git Hooks + +项目使用 **Husky** + **lint-staged** 自动执行代码检查: + +- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化 + +```bash +# 初始化 git hooks(npm install 时自动执行) +npm run prepare +``` + +## 📄 许可证 + +[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx) + +--- + +

+ 如果觉得有用,请点个 ⭐ Star 支持一下! +

diff --git a/eslint.config.mjs b/eslint.config.mjs index 91d7e57..0f22f03 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,28 +1,28 @@ -import eslint from '@eslint/js' -import tseslint from 'typescript-eslint' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - { - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - '@typescript-eslint/no-unused-vars': 'error', - 'no-console': 'warn', - 'prefer-const': 'error', - }, - }, - { - ignores: ['dist/', 'node_modules/', 'assets/', 'lib/'], - }, -) +import eslint from '@eslint/js' +import tseslint from 'typescript-eslint' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': 'error', + 'no-console': 'warn', + 'prefer-const': 'error', + }, + }, + { + ignores: ['dist/', 'node_modules/', 'assets/', 'lib/'], + }, +) diff --git a/package.json b/package.json index f07c37c..06e73a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.2", + "version": "0.3.4", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/main/file-system.ts b/src/main/file-system.ts index be4b9fe..fe542d5 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -1,13 +1,9 @@ import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises' import { join, extname, dirname, basename } from 'path' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' +import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' -const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB -const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt']) -const SKIP_DIRS = new Set([ - 'node_modules', '.git', '.svn', '.hg', 'dist', 'out', - '.next', '.nuxt', '__pycache__', '.DS_Store' -]) +const ALLOWED_EXTENSIONS_SET = new Set(ALLOWED_EXTENSIONS) export async function readFileContent(filePath: string): Promise { try { @@ -67,7 +63,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P } } else { const ext = extname(entry.name).toLowerCase() - if (ALLOWED_EXTENSIONS.has(ext)) { + if (ALLOWED_EXTENSIONS_SET.has(ext)) { children.push({ name: entry.name, path: childPath, type: 'file' }) } } diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 1143ad4..50fa193 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -24,12 +24,22 @@ export class FileWatcher { }) // L-02: 监听 error 事件,文件被删除时显式关闭并清理状态 this.watcher.on('error', () => { - if (this.watcher) { - this.watcher.close() - this.watcher = null + this.stop() + const originalPath = this.currentPath + if (originalPath) { + const pollInterval = setInterval(() => { + try { + if (fs.existsSync(originalPath)) { + clearInterval(pollInterval) + this.start(originalPath) + } + } catch { + clearInterval(pollInterval) + } + }, 2000) + // 最多轮询30秒 + setTimeout(() => clearInterval(pollInterval), 30000) } - this.currentPath = null - this.isSelfWriting = false }) } catch { // silently ignore diff --git a/src/main/index.ts b/src/main/index.ts index 95147fb..25f1780 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,140 +1,140 @@ -import { app, BrowserWindow } from 'electron' -import { join } from 'path' -import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager' -import { FileWatcher, SidebarWatcher } from './file-watcher' -import { registerIpcHandlers } from './ipc-handlers' -import { readFileContent } from './file-system' - -let mainWindow: BrowserWindow | null = null -const state = { - activeFilePath: null as string | null, - pendingFilePath: null as string | null, - isClosing: false, - closeTimeout: null as NodeJS.Timeout | null -} - -const fileWatcher = new FileWatcher(() => mainWindow) -const sidebarWatcher = new SidebarWatcher(() => mainWindow) - -function openFileInTab(filePath: string): void { - if (!mainWindow || mainWindow.isDestroyed()) return - readFileContent(filePath).then(result => { - if (result.success && mainWindow && !mainWindow.isDestroyed()) { - state.activeFilePath = filePath - fileWatcher.start(filePath) - mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) - mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) - } - }).catch((err) => { - // eslint-disable-next-line no-console -- IPC file open error - console.error('openFileInTab failed:', err) - }) -} - -const lockOk = setupSingleInstanceLock((filePath) => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() - if (filePath) openFileInTab(filePath) - } -}) - -if (!lockOk) { - app.quit() -} else { - - function setupCloseHandler(): void { - if (!mainWindow) return - mainWindow.on('close', (e) => { - if (state.isClosing) return - state.isClosing = true - e.preventDefault() - try { - if (mainWindow && !mainWindow.webContents.isDestroyed()) { - mainWindow.webContents.send('window:confirmClose') - } else { - mainWindow?.removeAllListeners('close') - mainWindow?.close() - return - } - } catch { - mainWindow?.removeAllListeners('close') - mainWindow?.close() - return - } - state.closeTimeout = setTimeout(() => { - state.closeTimeout = null - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.removeAllListeners('close') - mainWindow.close() - } - }, 5000) - }) - } - - // C-05: 窗口重建时重置状态 - function initWindow(): void { - state.isClosing = false - if (state.closeTimeout) { - clearTimeout(state.closeTimeout) - state.closeTimeout = null - } - - mainWindow = createWindow() - - if (process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } - - mainWindow.webContents.on('did-finish-load', () => { - if (state.pendingFilePath) { - openFileInTab(state.pendingFilePath) - state.pendingFilePath = null - } - }) - - setupCloseHandler() - - mainWindow.on('closed', () => { - fileWatcher.stop() - sidebarWatcher.stop() - mainWindow = null - }) - } - - app.whenReady().then(() => { - app.on('open-file', (event, filePath) => { - event.preventDefault() - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { - state.pendingFilePath = filePath - } else if (mainWindow && !mainWindow.isDestroyed()) { - openFileInTab(filePath) - } else { - state.pendingFilePath = filePath - } - }) - - // C-03: IPC 处理器只注册一次 - registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) - - initWindow() - - const cmdFile = getFilePathFromArgs(process.argv) - if (cmdFile) { - state.pendingFilePath = cmdFile - } - }) - - app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() - }) - - // C-03: activate 只重建窗口,不重复注册 IPC - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - initWindow() - } - }) -} +import { app, BrowserWindow } from 'electron' +import { join } from 'path' +import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager' +import { FileWatcher, SidebarWatcher } from './file-watcher' +import { registerIpcHandlers } from './ipc-handlers' +import { readFileContent } from './file-system' + +let mainWindow: BrowserWindow | null = null +const state = { + activeFilePath: null as string | null, + pendingFilePath: null as string | null, + isClosing: false, + closeTimeout: null as NodeJS.Timeout | null +} + +const fileWatcher = new FileWatcher(() => mainWindow) +const sidebarWatcher = new SidebarWatcher(() => mainWindow) + +function openFileInTab(filePath: string): void { + if (!mainWindow || mainWindow.isDestroyed()) return + readFileContent(filePath).then(result => { + if (result.success && mainWindow && !mainWindow.isDestroyed()) { + state.activeFilePath = filePath + fileWatcher.start(filePath) + mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) + mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) + } + }).catch((err) => { + // eslint-disable-next-line no-console -- IPC file open error + console.error('openFileInTab failed:', err) + }) +} + +const lockOk = setupSingleInstanceLock((filePath) => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + if (filePath) openFileInTab(filePath) + } +}) + +if (!lockOk) { + app.quit() +} else { + + function setupCloseHandler(): void { + if (!mainWindow) return + mainWindow.on('close', (e) => { + if (state.isClosing) return + state.isClosing = true + e.preventDefault() + try { + if (mainWindow && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('window:confirmClose') + } else { + mainWindow?.removeAllListeners('close') + mainWindow?.close() + return + } + } catch { + mainWindow?.removeAllListeners('close') + mainWindow?.close() + return + } + state.closeTimeout = setTimeout(() => { + state.closeTimeout = null + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.removeAllListeners('close') + mainWindow.close() + } + }, 5000) + }) + } + + // C-05: 窗口重建时重置状态 + function initWindow(): void { + state.isClosing = false + if (state.closeTimeout) { + clearTimeout(state.closeTimeout) + state.closeTimeout = null + } + + mainWindow = createWindow() + + if (process.env.ELECTRON_RENDERER_URL) { + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + + mainWindow.webContents.on('did-finish-load', () => { + if (state.pendingFilePath) { + openFileInTab(state.pendingFilePath) + state.pendingFilePath = null + } + }) + + setupCloseHandler() + + mainWindow.on('closed', () => { + fileWatcher.stop() + sidebarWatcher.stop() + mainWindow = null + }) + } + + app.whenReady().then(() => { + app.on('open-file', (event, filePath) => { + event.preventDefault() + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { + state.pendingFilePath = filePath + } else if (mainWindow && !mainWindow.isDestroyed()) { + openFileInTab(filePath) + } else { + state.pendingFilePath = filePath + } + }) + + // C-03: IPC 处理器只注册一次 + registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) + + initWindow() + + const cmdFile = getFilePathFromArgs(process.argv) + if (cmdFile) { + state.pendingFilePath = cmdFile + } + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + + // C-03: activate 只重建窗口,不重复注册 IPC + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + initWindow() + } + }) +} diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 87955a9..21b52bd 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,228 +1,230 @@ -import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron' -import { readFileContent, saveFileContent, buildDirTree } from './file-system' -import { FileWatcher, SidebarWatcher } from './file-watcher' -import { IPC_CHANNELS } from '../shared/ipc-channels' -import { stat } from 'fs/promises' -import { basename, isAbsolute } from 'path' - -// 安全校验:拒绝路径遍历攻击 -function validatePath(filePath: string): boolean { - if (!filePath || typeof filePath !== 'string') return false - // 拒绝空字节 - if (filePath.includes('\0')) return false - // 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过 - // 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失 - if (filePath.includes('..')) return false - // 必须是绝对路径 - if (!isAbsolute(filePath)) return false - return true -} - -export function registerIpcHandlers( - getMainWindow: () => BrowserWindow | null, - fileWatcher: FileWatcher, - sidebarWatcher: SidebarWatcher, - state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } -): void { - // 打开文件对话框 - ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { - const win = getMainWindow() - if (!win) return null - try { - const result = await dialog.showOpenDialog(win, { - properties: ['openFile'], - filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] - }) - if (!result.canceled && result.filePaths.length > 0) { - const filePath = result.filePaths[0] - const fileResult = await readFileContent(filePath) - if (fileResult.success) { - state.activeFilePath = filePath - fileWatcher.start(filePath) - win.setTitle(`MarkLite - ${basename(filePath)}`) - return { filePath, content: fileResult.content } - } - return { error: fileResult.error } - } - return null - } catch (err) { - return { error: (err as Error).message } - } - }) - - // 读取文件 - ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => { - if (!validatePath(filePath)) { - return { success: false, error: '无效的文件路径' } - } - return readFileContent(filePath) - }) - - // 保存文件 - ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { - if (data.filePath && !validatePath(data.filePath)) { - return { success: false, error: '无效的文件路径' } - } - const win = getMainWindow() - try { - if (data.filePath) { - fileWatcher.stop() - fileWatcher.setSelfWriting(true) - const result = await saveFileContent(data.filePath, data.content) - fileWatcher.setSelfWriting(false) - state.activeFilePath = data.filePath - setTimeout(() => { - if (state.activeFilePath === data.filePath && data.filePath) { - fileWatcher.start(data.filePath) - } - }, 300) - if (win && !win.isDestroyed()) { - win.setTitle(`MarkLite - ${basename(data.filePath)}`) - } - return result - } else { - if (!win) return { success: false, error: '窗口不可用' } - const saveResult = await dialog.showSaveDialog(win, { - filters: [{ name: 'Markdown 文件', extensions: ['md'] }] - }) - if (!saveResult.canceled) { - fileWatcher.setSelfWriting(true) - const result = await saveFileContent(saveResult.filePath, data.content) - fileWatcher.setSelfWriting(false) - if (result.success) { - state.activeFilePath = saveResult.filePath - fileWatcher.start(saveResult.filePath) - win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`) - } - return result - } - return { success: false, canceled: true } - } - } catch (err) { - fileWatcher.setSelfWriting(false) - fileWatcher.start(state.activeFilePath || '') - return { success: false, error: (err as Error).message } - } - }) - - // 另存为 - ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { - const win = getMainWindow() - if (!win) return { success: false, error: '窗口不可用' } - try { - const result = await dialog.showSaveDialog(win, { - filters: [{ name: 'Markdown 文件', extensions: ['md'] }] - }) - if (!result.canceled) { - fileWatcher.stop() - fileWatcher.setSelfWriting(true) - const saveResult = await saveFileContent(result.filePath, data.content) - fileWatcher.setSelfWriting(false) - if (saveResult.success) { - state.activeFilePath = result.filePath - setTimeout(() => { - if (state.activeFilePath === result.filePath) { - fileWatcher.start(result.filePath) - } - }, 300) - win.setTitle(`MarkLite - ${basename(result.filePath)}`) - } - return saveResult - } - return { success: false, canceled: true } - } catch (err) { - fileWatcher.setSelfWriting(false) - return { success: false, error: (err as Error).message } - } - }) - - // 获取当前路径 - ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) - - // 文件统计 - ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { - if (!validatePath(filePath)) { - return { success: false, error: '无效的文件路径' } - } - try { - const fileStat = await stat(filePath) - return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } - } catch (err) { - return { success: false, error: (err as Error).message } - } - }) - - // 重新加载 - ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => { - if (!state.activeFilePath) return { success: false, error: '没有打开的文件' } - const result = await readFileContent(state.activeFilePath) - return { ...result, filePath: state.activeFilePath } - }) - - // 目录树 - ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { - if (!validatePath(dirPath)) { - return { success: false, error: '无效的目录路径' } - } - try { - const tree = await buildDirTree(dirPath) - return { success: true, tree, rootPath: dirPath } - } catch (err) { - return { success: false, error: (err as Error).message } - } - }) - - // 打开文件夹对话框 - ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { - const win = getMainWindow() - if (!win) return null - const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) - if (!result.canceled && result.filePaths.length > 0) { - return result.filePaths[0] - } - return null - }) - - // 目录监听 - ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { - if (!validatePath(dirPath)) return - sidebarWatcher.start(dirPath) - }) - - ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => { - sidebarWatcher.stop() - }) - - // 标签切换 - ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { - state.activeFilePath = filePath || null - if (filePath && !validatePath(filePath)) { - fileWatcher.stop() - return - } - fileWatcher.start(filePath || '') - const win = getMainWindow() - if (win && !win.isDestroyed()) { - win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite') - } - }) - - // 窗口控制 - ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { - const win = getMainWindow() - if (win && !win.isDestroyed()) { - fileWatcher.stop() - win.removeAllListeners('close') - win.close() - } - }) - - // B-01: 重置关闭状态 + 清除超时定时器 - ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { - state.isClosing = false - if (state.closeTimeout) { - clearTimeout(state.closeTimeout) - state.closeTimeout = null - } - }) -} +import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron' +import { readFileContent, saveFileContent, buildDirTree } from './file-system' +import { FileWatcher, SidebarWatcher } from './file-watcher' +import { IPC_CHANNELS } from '../shared/ipc-channels' +import { stat } from 'fs/promises' +import { basename, isAbsolute } from 'path' + +// 安全校验:拒绝路径遍历攻击 +function validatePath(filePath: string): boolean { + if (!filePath || typeof filePath !== 'string') return false + // 拒绝空字节 + if (filePath.includes('\0')) return false + // 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过 + // 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失 + if (filePath.includes('..')) return false + // 必须是绝对路径 + if (!isAbsolute(filePath)) return false + return true +} + +export function registerIpcHandlers( + getMainWindow: () => BrowserWindow | null, + fileWatcher: FileWatcher, + sidebarWatcher: SidebarWatcher, + state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } +): void { + // 打开文件对话框 + ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { + const win = getMainWindow() + if (!win) return null + try { + const result = await dialog.showOpenDialog(win, { + properties: ['openFile'], + filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] + }) + if (!result.canceled && result.filePaths.length > 0) { + const filePath = result.filePaths[0] + const fileResult = await readFileContent(filePath) + if (fileResult.success) { + state.activeFilePath = filePath + fileWatcher.start(filePath) + win.setTitle(`MarkLite - ${basename(filePath)}`) + return { filePath, content: fileResult.content } + } + return { error: fileResult.error } + } + return null + } catch (err) { + return { error: (err as Error).message } + } + }) + + // 读取文件 + ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => { + if (!validatePath(filePath)) { + return { success: false, error: '无效的文件路径' } + } + return readFileContent(filePath) + }) + + // 保存文件 + ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { + if (data.filePath && !validatePath(data.filePath)) { + return { success: false, error: '无效的文件路径' } + } + const win = getMainWindow() + try { + if (data.filePath) { + fileWatcher.stop() + fileWatcher.setSelfWriting(true) + const result = await saveFileContent(data.filePath, data.content) + fileWatcher.setSelfWriting(false) + state.activeFilePath = data.filePath + setTimeout(() => { + if (state.activeFilePath === data.filePath && data.filePath) { + fileWatcher.start(data.filePath) + } + }, 300) + if (win && !win.isDestroyed()) { + win.setTitle(`MarkLite - ${basename(data.filePath)}`) + } + return result + } else { + if (!win) return { success: false, error: '窗口不可用' } + const saveResult = await dialog.showSaveDialog(win, { + filters: [{ name: 'Markdown 文件', extensions: ['md'] }] + }) + if (!saveResult.canceled) { + fileWatcher.setSelfWriting(true) + const result = await saveFileContent(saveResult.filePath, data.content) + fileWatcher.setSelfWriting(false) + if (result.success) { + state.activeFilePath = saveResult.filePath + fileWatcher.start(saveResult.filePath) + win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`) + } + return result + } + return { success: false, canceled: true } + } + } catch (err) { + fileWatcher.setSelfWriting(false) + if (state.activeFilePath) { + fileWatcher.start(state.activeFilePath) + } + return { success: false, error: (err as Error).message } + } + }) + + // 另存为 + ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { + const win = getMainWindow() + if (!win) return { success: false, error: '窗口不可用' } + try { + const result = await dialog.showSaveDialog(win, { + filters: [{ name: 'Markdown 文件', extensions: ['md'] }] + }) + if (!result.canceled) { + fileWatcher.stop() + fileWatcher.setSelfWriting(true) + const saveResult = await saveFileContent(result.filePath, data.content) + fileWatcher.setSelfWriting(false) + if (saveResult.success) { + state.activeFilePath = result.filePath + setTimeout(() => { + if (state.activeFilePath === result.filePath) { + fileWatcher.start(result.filePath) + } + }, 300) + win.setTitle(`MarkLite - ${basename(result.filePath)}`) + } + return saveResult + } + return { success: false, canceled: true } + } catch (err) { + fileWatcher.setSelfWriting(false) + return { success: false, error: (err as Error).message } + } + }) + + // 获取当前路径 + ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) + + // 文件统计 + ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { + if (!validatePath(filePath)) { + return { success: false, error: '无效的文件路径' } + } + try { + const fileStat = await stat(filePath) + return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } + } catch (err) { + return { success: false, error: (err as Error).message } + } + }) + + // 重新加载 + ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => { + if (!state.activeFilePath) return { success: false, error: '没有打开的文件' } + const result = await readFileContent(state.activeFilePath) + return { ...result, filePath: state.activeFilePath } + }) + + // 目录树 + ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { + if (!validatePath(dirPath)) { + return { success: false, error: '无效的目录路径' } + } + try { + const tree = await buildDirTree(dirPath) + return { success: true, tree, rootPath: dirPath } + } catch (err) { + return { success: false, error: (err as Error).message } + } + }) + + // 打开文件夹对话框 + ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { + const win = getMainWindow() + if (!win) return null + const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0] + } + return null + }) + + // 目录监听 + ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { + if (!validatePath(dirPath)) return + sidebarWatcher.start(dirPath) + }) + + ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => { + sidebarWatcher.stop() + }) + + // 标签切换 + ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { + state.activeFilePath = filePath || null + if (filePath && !validatePath(filePath)) { + fileWatcher.stop() + return + } + fileWatcher.start(filePath || '') + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite') + } + }) + + // 窗口控制 + ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + fileWatcher.stop() + win.removeAllListeners('close') + win.close() + } + }) + + // B-01: 重置关闭状态 + 清除超时定时器 + ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { + state.isClosing = false + if (state.closeTimeout) { + clearTimeout(state.closeTimeout) + state.closeTimeout = null + } + }) +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7d11795..407a63a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,121 +1,121 @@ -import React, { useState, useEffect, useCallback } from 'react' -import { useTabStore } from './stores/tabStore' -import { useEditorStore } from './stores/editorStore' -import { useTheme } from './hooks/useTheme' -import { useSettings } from './hooks/useSettings' -import { useSettingsInit } from './hooks/useSettingsInit' -import { useKeyboard } from './hooks/useKeyboard' -import { useUnsavedWarning } from './hooks/useUnsavedWarning' -import { useFileWatch } from './hooks/useFileWatch' -import { useFileOperations } from './hooks/useFileOperations' -import { useDragDrop } from './hooks/useDragDrop' -import { useIpcListeners } from './hooks/useIpcListeners' -import { useToast } from './hooks/useToast' -import { useConfirm } from './hooks/useConfirm' -import { Toolbar } from './components/Toolbar/Toolbar' -import { TabBar } from './components/TabBar/TabBar' -import { Editor } from './components/Editor/Editor' -import { Preview } from './components/Preview/Preview' -import { Sidebar } from './components/Sidebar/Sidebar' -import { StatusBar } from './components/StatusBar/StatusBar' -import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' -import { ToastContainer } from './components/Toast/Toast' -import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' -import { DropOverlay } from './components/DropOverlay/DropOverlay' -import { ErrorBoundary } from './components/ErrorBoundary' -import { AboutDialog } from './components/AboutDialog' -import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog' - -export function App() { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - const createTab = useTabStore(s => s.createTab) - const setModified = useTabStore(s => s.setModified) - const updateTabContent = useTabStore(s => s.updateTabContent) - const loadFromDB = useTabStore(s => s.loadFromDB) - const viewMode = useEditorStore(s => s.viewMode) - const externallyModified = useEditorStore(s => s.externallyModified) - const setExternallyModified = useEditorStore(s => s.setExternallyModified) - const { darkMode, toggleDarkMode } = useTheme() - const { saveViewMode } = useSettings() - const { toasts, showToast, dismissToast, cleanup } = useToast() - const { confirm, confirmDialogProps } = useConfirm() - const [showAbout, setShowAbout] = useState(false) - const handleCloseAbout = useCallback(() => setShowAbout(false), []) - - useSettingsInit() - useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup]) - - const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) - useDragDrop(showToast) - useFileWatch() - - // UX-01: 传入 confirm 函数替代原生 confirm() - const handleConfirmClose = useCallback(async (message: string): Promise => { - return confirm({ - title: '未保存的更改', - message, - variant: 'warning', - confirmLabel: '不保存', - cancelLabel: '取消' - }) - }, [confirm]) - - useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose) - useKeyboard(handleOpenFile, handleSave, handleSaveAs) - useIpcListeners(handleSave, handleSaveAs) - - useEffect(() => { - if (!window.electronAPI) return - const activeTab = tabs.find(t => t.id === activeTabId) - window.electronAPI.tabSwitched(activeTab?.filePath ?? null) - }, [activeTabId, tabs]) - - const handleReloadModified = useCallback(async () => { - if (!externallyModified?.filePath || !window.electronAPI) return - const result = await window.electronAPI.readFile(externallyModified.filePath) - if (result.success && result.content) { - const tab = tabs.find(t => t.filePath === externallyModified.filePath) - if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) } - } - setExternallyModified(null) - }, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified]) - - return ( - -
- setShowAbout(true)} - /> -
- -
- - {externallyModified && ( - setExternallyModified(null)} /> - )} - {tabs.length > 0 ? ( -
- {viewMode === 'editor' &&
} - {viewMode === 'preview' &&
} -
- ) : ( - createTab(null, '')} onOpenRecent={handleOpenRecent} /> - )} -
-
- - - - {showAbout && } - -
-
- ) -} - -App.displayName = 'App' -export default App +import React, { useState, useEffect, useCallback } from 'react' +import { useTabStore } from './stores/tabStore' +import { useEditorStore } from './stores/editorStore' +import { useTheme } from './hooks/useTheme' +import { useSettings } from './hooks/useSettings' +import { useSettingsInit } from './hooks/useSettingsInit' +import { useKeyboard } from './hooks/useKeyboard' +import { useUnsavedWarning } from './hooks/useUnsavedWarning' +import { useFileWatch } from './hooks/useFileWatch' +import { useFileOperations } from './hooks/useFileOperations' +import { useDragDrop } from './hooks/useDragDrop' +import { useIpcListeners } from './hooks/useIpcListeners' +import { useToast } from './hooks/useToast' +import { useConfirm } from './hooks/useConfirm' +import { Toolbar } from './components/Toolbar/Toolbar' +import { TabBar } from './components/TabBar/TabBar' +import { Editor } from './components/Editor/Editor' +import { Preview } from './components/Preview/Preview' +import { Sidebar } from './components/Sidebar/Sidebar' +import { StatusBar } from './components/StatusBar/StatusBar' +import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' +import { ToastContainer } from './components/Toast/Toast' +import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' +import { DropOverlay } from './components/DropOverlay/DropOverlay' +import { ErrorBoundary } from './components/ErrorBoundary' +import { AboutDialog } from './components/AboutDialog' +import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog' + +export function App() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const createTab = useTabStore(s => s.createTab) + const setModified = useTabStore(s => s.setModified) + const updateTabContent = useTabStore(s => s.updateTabContent) + const loadFromDB = useTabStore(s => s.loadFromDB) + const viewMode = useEditorStore(s => s.viewMode) + const externallyModified = useEditorStore(s => s.externallyModified) + const setExternallyModified = useEditorStore(s => s.setExternallyModified) + const { darkMode, toggleDarkMode } = useTheme() + const { saveViewMode } = useSettings() + const { toasts, showToast, dismissToast, cleanup } = useToast() + const { confirm, confirmDialogProps } = useConfirm() + const [showAbout, setShowAbout] = useState(false) + const handleCloseAbout = useCallback(() => setShowAbout(false), []) + + useSettingsInit() + useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup]) + + const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) + useDragDrop(showToast) + useFileWatch() + + // UX-01: 传入 confirm 函数替代原生 confirm() + const handleConfirmClose = useCallback(async (message: string): Promise => { + return confirm({ + title: '未保存的更改', + message, + variant: 'warning', + confirmLabel: '不保存', + cancelLabel: '取消' + }) + }, [confirm]) + + useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose) + useKeyboard(handleOpenFile, handleSave, handleSaveAs) + useIpcListeners(handleSave, handleSaveAs) + + useEffect(() => { + if (!window.electronAPI) return + const activeTab = tabs.find(t => t.id === activeTabId) + window.electronAPI.tabSwitched(activeTab?.filePath ?? null) + }, [activeTabId, tabs]) + + const handleReloadModified = useCallback(async () => { + if (!externallyModified?.filePath || !window.electronAPI) return + const result = await window.electronAPI.readFile(externallyModified.filePath) + if (result.success && result.content) { + const tab = tabs.find(t => t.filePath === externallyModified.filePath) + if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) } + } + setExternallyModified(null) + }, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified]) + + return ( + +
+ setShowAbout(true)} + /> +
+ +
+ + {externallyModified && ( + setExternallyModified(null)} /> + )} + {tabs.length > 0 ? ( +
+ {viewMode === 'editor' &&
} + {viewMode === 'preview' &&
} +
+ ) : ( + createTab(null, '')} onOpenRecent={handleOpenRecent} /> + )} +
+
+ + + + {showAbout && } + +
+
+ ) +} + +App.displayName = 'App' +export default App diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx index 98dd553..f38067c 100644 --- a/src/renderer/components/AboutDialog/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog/AboutDialog.tsx @@ -1,55 +1,56 @@ -import React from 'react' -import { AppIcon, Gitee } from '../Icons' - -const APP_VERSION = 'v0.3.2' - -interface AboutDialogProps { - onClose: () => void -} - -export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) { - const handleLinkClick = (e: React.MouseEvent): void => { - e.preventDefault() - if (window.electronAPI?.openExternal) { - window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite') - } else { - window.open('https://gitee.com/thzxx/MarkLite', '_blank') - } - } - - return ( -
-
e.stopPropagation()}> -
- -

MarkLite

- {APP_VERSION} -
-
-

一款轻量级的 Windows 本地 Markdown 编辑器

-
- 多标签页 - 实时预览 - 代码高亮 - 暗色主题 - 拖拽打开 - 搜索替换 - 文件树 - 状态持久化 -
-
-
- - - gitee.com/thzxx/MarkLite - -

基于 Electron + React + TypeScript 构建

-

© 2026 thzxx

-
- -
-
- ) -}) - -AboutDialog.displayName = 'AboutDialog' +import React from 'react' +import { AppIcon, Gitee } from '../Icons' + +const APP_VERSION = 'v0.3.4' + +interface AboutDialogProps { + onClose: () => void +} + +export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) { + const handleLinkClick = (e: React.MouseEvent): void => { + e.preventDefault() + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite') + } else { + window.open('https://gitee.com/thzxx/MarkLite', '_blank') + } + } + + return ( +
+
e.stopPropagation()}> +
+ +

MarkLite

+ {APP_VERSION} +
+
+

一款轻量级的 Windows 本地 Markdown 编辑器

+
+ 多标签页 + 实时预览 + 代码高亮 + 暗色主题 + 拖拽打开 + 搜索替换 + 文件树 + 文档大纲 + 状态持久化 +
+
+
+ + + gitee.com/thzxx/MarkLite + +

基于 Electron + React + TypeScript 构建

+

© 2026 thzxx

+
+ +
+
+ ) +}) + +AboutDialog.displayName = 'AboutDialog' diff --git a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx index ddb3739..47612af 100644 --- a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx +++ b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx @@ -1,108 +1,108 @@ -import React, { useEffect, useRef, useCallback } from 'react' - -interface ConfirmDialogProps { - open: boolean - title: string - message: string - confirmLabel?: string - cancelLabel?: string - variant?: 'danger' | 'warning' | 'info' - onConfirm: () => void - onCancel: () => void -} - -export const ConfirmDialog = React.memo(function ConfirmDialog({ - open, - title, - message, - confirmLabel = '确定', - cancelLabel = '取消', - variant = 'warning', - onConfirm, - onCancel -}: ConfirmDialogProps) { - const confirmRef = useRef(null) - const previousFocusRef = useRef(null) - - // 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点 - useEffect(() => { - if (!open) { - previousFocusRef.current?.focus() - return - } - - previousFocusRef.current = document.activeElement as HTMLElement - const timer = setTimeout(() => confirmRef.current?.focus(), 50) - - return () => { - clearTimeout(timer) - } - }, [open]) - - // ESC 键关闭 - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onCancel() - } - }, [onCancel]) - - useEffect(() => { - if (!open) return - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, handleKeyDown]) - - // 防止背景滚动 - useEffect(() => { - if (!open) return - const original = document.body.style.overflow - document.body.style.overflow = 'hidden' - return () => { document.body.style.overflow = original } - }, [open]) - - if (!open) return null - - return ( -
-
e.stopPropagation()} - > -
-

{title}

-
-
-

{message}

-
-
- - -
-
-
- ) -}) - -ConfirmDialog.displayName = 'ConfirmDialog' +import React, { useEffect, useRef, useCallback } from 'react' + +interface ConfirmDialogProps { + open: boolean + title: string + message: string + confirmLabel?: string + cancelLabel?: string + variant?: 'danger' | 'warning' | 'info' + onConfirm: () => void + onCancel: () => void +} + +export const ConfirmDialog = React.memo(function ConfirmDialog({ + open, + title, + message, + confirmLabel = '确定', + cancelLabel = '取消', + variant = 'warning', + onConfirm, + onCancel +}: ConfirmDialogProps) { + const confirmRef = useRef(null) + const previousFocusRef = useRef(null) + + // 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点 + useEffect(() => { + if (!open) { + previousFocusRef.current?.focus() + return + } + + previousFocusRef.current = document.activeElement as HTMLElement + const timer = setTimeout(() => confirmRef.current?.focus(), 50) + + return () => { + clearTimeout(timer) + } + }, [open]) + + // ESC 键关闭 + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }, [onCancel]) + + useEffect(() => { + if (!open) return + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, handleKeyDown]) + + // 防止背景滚动 + useEffect(() => { + if (!open) return + const original = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = original } + }, [open]) + + if (!open) return null + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+
+
+

{message}

+
+
+ + +
+
+
+ ) +}) + +ConfirmDialog.displayName = 'ConfirmDialog' diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 32db05d..9022f6b 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,105 +1,116 @@ -import React, { useEffect, useCallback, useState } from 'react' -import { callCommand } from '@milkdown/utils' -import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark' -import { useTabStore } from '../../stores/tabStore' -import { useMilkdown } from './useMilkdown' -import { EditorToolbar } from './EditorToolbar' -import { SearchReplace } from '../SearchReplace' - -interface EditorProps { - darkMode: boolean -} - -export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { - const activeTab = useTabStore(s => s.getActiveTab()) - const activeTabId = useTabStore(s => s.activeTabId) - const updateTabContent = useTabStore(s => s.updateTabContent) - const setModified = useTabStore(s => s.setModified) - const updateTabScroll = useTabStore(s => s.updateTabScroll) - const [showSearch, setShowSearch] = useState(false) - - const { - containerRef, - action, - setContent, - getScrollTop, - setScrollTop, - getSelection, - setSelection, - getView - } = useMilkdown({ - content: activeTab?.content ?? '', - onChange: useCallback((value: string) => { - if (!activeTabId) return - updateTabContent(activeTabId, value) - setModified(activeTabId, true) - }, [activeTabId, updateTabContent, setModified]), - darkMode - }) - - // Load content when switching tabs (only trigger on tab switch, not content edits) - useEffect(() => { - if (!activeTab) return - setContent(activeTab.content) - requestAnimationFrame(() => { - setScrollTop(activeTab.scrollTop) - setSelection() - }) - // stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId]) - - // Save current tab state on unmount or tab switch - useEffect(() => { - return () => { - if (!activeTabId) return - updateTabScroll(activeTabId, { - scrollTop: getScrollTop(), - selectionStart: getSelection().from, - selectionEnd: getSelection().to - }) - } - // stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId]) - - // Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const isCtrl = e.ctrlKey || e.metaKey - if (isCtrl && e.key === 'b') { - e.preventDefault() - action((editor) => { - editor.action(callCommand(toggleStrongCommand.key)) - }) - } - if (isCtrl && e.key === 'i') { - e.preventDefault() - action((editor) => { - editor.action(callCommand(toggleEmphasisCommand.key)) - }) - } - if (isCtrl && (e.key === 'f' || e.key === 'h')) { - e.preventDefault() - setShowSearch(true) - } - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [action]) - - return ( -
- - {showSearch && ( - setShowSearch(false)} - /> - )} -
-
- ) -}) - -Editor.displayName = 'Editor' +import React, { useEffect, useCallback, useState, useRef } from 'react' +import { callCommand } from '@milkdown/utils' +import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark' +import { useTabStore } from '../../stores/tabStore' +import { useMilkdown } from './useMilkdown' +import { EditorToolbar } from './EditorToolbar' +import { SearchReplace } from '../SearchReplace' +import { setEditorViewGetter } from '../../stores/editorStore' + +interface EditorProps { + darkMode: boolean +} + +export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { + const activeTab = useTabStore(s => s.getActiveTab()) + const activeTabId = useTabStore(s => s.activeTabId) + const updateTabContent = useTabStore(s => s.updateTabContent) + const setModified = useTabStore(s => s.setModified) + const updateTabScroll = useTabStore(s => s.updateTabScroll) + const [showSearch, setShowSearch] = useState(false) + const currentContentRef = useRef('') + + const { + containerRef, + action, + setContent, + getScrollTop, + setScrollTop, + getSelection, + setSelection, + getView + } = useMilkdown({ + content: activeTab?.content ?? '', + onChange: useCallback((value: string) => { + if (!activeTabId) return + updateTabContent(activeTabId, value) + setModified(activeTabId, true) + }, [activeTabId, updateTabContent, setModified]), + darkMode + }) + + // Load content when switching tabs (only trigger on tab switch, not content edits) + useEffect(() => { + if (!activeTab) return + if (activeTab.content !== currentContentRef.current) { + currentContentRef.current = activeTab.content + setContent(activeTab.content) + requestAnimationFrame(() => { + setScrollTop(activeTab.scrollTop) + setSelection() + }) + } + // stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTabId]) + + // Register getView for OutlinePanel navigation + useEffect(() => { + setEditorViewGetter(getView) + return () => setEditorViewGetter(() => null) + }, [getView]) + + // Save current tab state on unmount or tab switch + useEffect(() => { + return () => { + if (!activeTabId) return + updateTabScroll(activeTabId, { + scrollTop: getScrollTop(), + selectionStart: getSelection().from, + selectionEnd: getSelection().to + }) + } + // stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTabId]) + + // Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isCtrl = e.ctrlKey || e.metaKey + if (isCtrl && e.key === 'b') { + e.preventDefault() + action((editor) => { + editor.action(callCommand(toggleStrongCommand.key)) + }) + } + if (isCtrl && e.key === 'i') { + e.preventDefault() + action((editor) => { + editor.action(callCommand(toggleEmphasisCommand.key)) + }) + } + if (isCtrl && (e.key === 'f' || e.key === 'h')) { + e.preventDefault() + setShowSearch(true) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [action]) + + return ( +
+ + {showSearch && ( + setShowSearch(false)} + /> + )} +
+
+ ) +}) + +Editor.displayName = 'Editor' diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts index 01a6a48..d2f4f6f 100644 --- a/src/renderer/components/Editor/useMilkdown.ts +++ b/src/renderer/components/Editor/useMilkdown.ts @@ -1,252 +1,252 @@ -import { useCallback, useRef, useEffect } from 'react' -import { - Editor, - rootCtx, - defaultValueCtx, - editorViewCtx, - prosePluginsCtx -} from '@milkdown/core' -import { replaceAll as milkdownReplaceAll } from '@milkdown/utils' -import { commonmark } from '@milkdown/preset-commonmark' -import { gfm } from '@milkdown/preset-gfm' -import { history } from '@milkdown/plugin-history' -import { listener, listenerCtx } from '@milkdown/plugin-listener' -import { indent } from '@milkdown/plugin-indent' -import { trailing } from '@milkdown/plugin-trailing' -import { clipboard } from '@milkdown/plugin-clipboard' -import { Plugin, PluginKey } from '@milkdown/prose/state' -import { Decoration, DecorationSet } from '@milkdown/prose/view' -import type { EditorState } from '@milkdown/prose/state' - -// --- Search highlight plugin --- - -export interface SearchMatch { - from: number - to: number -} - -export interface SearchPluginState { - matches: SearchMatch[] - currentIndex: number - query: string -} - -export const searchPluginKey = new PluginKey('search-highlight') - -function createSearchPlugin(): Plugin { - return new Plugin({ - key: searchPluginKey, - state: { - init(): SearchPluginState { - return { matches: [], currentIndex: -1, query: '' } - }, - apply(tr, value): SearchPluginState { - const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined - if (meta) return meta - // When the document changes, map existing match positions through the change - // but only if there's an active search - if (tr.docChanged && value.query) { - // The SearchReplace component will re-search when doc changes, - // but we map positions so decorations stay roughly correct in the interim - return { ...value } - } - return value - } - }, - props: { - decorations(state: EditorState) { - const pluginState = searchPluginKey.getState(state) - if (!pluginState || pluginState.matches.length === 0) { - return null - } - - const decos: Decoration[] = pluginState.matches.map((match, index) => { - const cls = - index === pluginState.currentIndex - ? 'search-match-active' - : 'search-match-highlight' - return Decoration.inline(match.from, match.to, { class: cls }) - }) - - return DecorationSet.create(state.doc, decos) - } - } - }) -} - -// --- Hook --- - -interface UseMilkdownOptions { - content: string - onChange: (value: string) => void - darkMode: boolean -} - -export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) { - const containerRef = useRef(null) - const editorRef = useRef(null) - const onChangeRef = useRef(onChange) - const isExternalUpdate = useRef(false) - const initialContentRef = useRef(content) - - // Keep onChangeRef fresh - useEffect(() => { - onChangeRef.current = onChange - }, [onChange]) - - // Initialize editor - useEffect(() => { - if (!containerRef.current) return - - const editor = Editor.make() - .config((ctx) => { - ctx.set(rootCtx, containerRef.current!) - ctx.set(defaultValueCtx, initialContentRef.current) - - // Inject search highlight plugin into ProseMirror plugin list - ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()]) - - // Configure listener for content changes - const lm = ctx.get(listenerCtx) - lm.markdownUpdated((_ctx, markdown, prevMarkdown) => { - if (markdown === prevMarkdown) return - if (!isExternalUpdate.current) { - onChangeRef.current(markdown) - } - }) - - // Listen for blur events (used for potential future state saving) - lm.blur(() => { - // blur handler placeholder - }) - }) - .use(commonmark) - .use(gfm) - .use(history) - .use(listener) - .use(indent) - .use(trailing) - .use(clipboard) - - let cancelled = false - - editor.create().then((created) => { - if (cancelled) { - // 组件已卸载,立即销毁以避免内存泄漏 - created.destroy() - return - } - editorRef.current = created - }).catch((err) => { - // eslint-disable-next-line no-console -- editor creation failure must be reported - console.error('Milkdown editor creation failed:', err) - }) - - return () => { - cancelled = true - editorRef.current?.destroy() - editorRef.current = null - } - }, []) - - // Dark mode: update CSS custom properties on the container - useEffect(() => { - const container = containerRef.current - if (!container) return - if (darkMode) { - container.classList.add('milkdown-dark') - } else { - container.classList.remove('milkdown-dark') - } - }, [darkMode]) - - // External content update (tab switch) - const setContent = useCallback((newContent: string) => { - const editor = editorRef.current - if (!editor) return - - isExternalUpdate.current = true - try { - editor.action(milkdownReplaceAll(newContent)) - } catch { - // replaceAll may fail if editor is not fully ready - } finally { - isExternalUpdate.current = false - } - }, []) - - // Scroll position management - const getScrollTop = useCallback((): number => { - const container = containerRef.current - if (!container) return 0 - const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null - return scroller?.scrollTop ?? container.scrollTop ?? 0 - }, []) - - const setScrollTop = useCallback((top: number) => { - const container = containerRef.current - if (!container) return - const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null - if (scroller) { - scroller.scrollTop = top - } else { - container.scrollTop = top - } - }, []) - - // Selection management - const getSelection = useCallback((): { from: number; to: number } => { - const editor = editorRef.current - if (!editor) return { from: 0, to: 0 } - try { - return editor.action((ctx) => { - const view = ctx.get(editorViewCtx) - if (view?.state?.selection) { - return { from: view.state.selection.from, to: view.state.selection.to } - } - return { from: 0, to: 0 } - }) - } catch { - return { from: 0, to: 0 } - } - }, []) - - const setSelection = useCallback(() => { - // ProseMirror selection setting requires the view instance - // which we access lazily; for now we focus the editor - const container = containerRef.current - if (!container) return - const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null - pmEditor?.focus() - }, []) - - // Get ProseMirror EditorView instance - const getView = useCallback(() => { - const editor = editorRef.current - if (!editor) return null - try { - return editor.action((ctx) => ctx.get(editorViewCtx)) - } catch { - return null - } - }, []) - - // Execute a Milkdown action (for toolbar commands) - const action = useCallback((fn: (editor: Editor) => void) => { - const editor = editorRef.current - if (!editor) return - fn(editor) - }, []) - - return { - containerRef, - editorRef, - action, - setContent, - getScrollTop, - setScrollTop, - getSelection, - setSelection, - getView - } -} +import { useCallback, useRef, useEffect } from 'react' +import { + Editor, + rootCtx, + defaultValueCtx, + editorViewCtx, + prosePluginsCtx +} from '@milkdown/core' +import { replaceAll as milkdownReplaceAll } from '@milkdown/utils' +import { commonmark } from '@milkdown/preset-commonmark' +import { gfm } from '@milkdown/preset-gfm' +import { history } from '@milkdown/plugin-history' +import { listener, listenerCtx } from '@milkdown/plugin-listener' +import { indent } from '@milkdown/plugin-indent' +import { trailing } from '@milkdown/plugin-trailing' +import { clipboard } from '@milkdown/plugin-clipboard' +import { Plugin, PluginKey } from '@milkdown/prose/state' +import { Decoration, DecorationSet } from '@milkdown/prose/view' +import type { EditorState } from '@milkdown/prose/state' + +// --- Search highlight plugin --- + +export interface SearchMatch { + from: number + to: number +} + +export interface SearchPluginState { + matches: SearchMatch[] + currentIndex: number + query: string +} + +export const searchPluginKey = new PluginKey('search-highlight') + +function createSearchPlugin(): Plugin { + return new Plugin({ + key: searchPluginKey, + state: { + init(): SearchPluginState { + return { matches: [], currentIndex: -1, query: '' } + }, + apply(tr, value): SearchPluginState { + const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined + if (meta) return meta + // When the document changes, map existing match positions through the change + // but only if there's an active search + if (tr.docChanged && value.query) { + // The SearchReplace component will re-search when doc changes, + // but we map positions so decorations stay roughly correct in the interim + return { ...value } + } + return value + } + }, + props: { + decorations(state: EditorState) { + const pluginState = searchPluginKey.getState(state) + if (!pluginState || pluginState.matches.length === 0) { + return null + } + + const decos: Decoration[] = pluginState.matches.map((match, index) => { + const cls = + index === pluginState.currentIndex + ? 'search-match-active' + : 'search-match-highlight' + return Decoration.inline(match.from, match.to, { class: cls }) + }) + + return DecorationSet.create(state.doc, decos) + } + } + }) +} + +// --- Hook --- + +interface UseMilkdownOptions { + content: string + onChange: (value: string) => void + darkMode: boolean +} + +export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) { + const containerRef = useRef(null) + const editorRef = useRef(null) + const onChangeRef = useRef(onChange) + const isExternalUpdate = useRef(false) + const initialContentRef = useRef(content) + + // Keep onChangeRef fresh + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + // Initialize editor + useEffect(() => { + if (!containerRef.current) return + + const editor = Editor.make() + .config((ctx) => { + ctx.set(rootCtx, containerRef.current!) + ctx.set(defaultValueCtx, initialContentRef.current) + + // Inject search highlight plugin into ProseMirror plugin list + ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()]) + + // Configure listener for content changes + const lm = ctx.get(listenerCtx) + lm.markdownUpdated((_ctx, markdown, prevMarkdown) => { + if (markdown === prevMarkdown) return + if (!isExternalUpdate.current) { + onChangeRef.current(markdown) + } + }) + + // Listen for blur events (used for potential future state saving) + lm.blur(() => { + // blur handler placeholder + }) + }) + .use(commonmark) + .use(gfm) + .use(history) + .use(listener) + .use(indent) + .use(trailing) + .use(clipboard) + + let cancelled = false + + editor.create().then((created) => { + if (cancelled) { + // 组件已卸载,立即销毁以避免内存泄漏 + created.destroy() + return + } + editorRef.current = created + }).catch((err) => { + // eslint-disable-next-line no-console -- editor creation failure must be reported + console.error('Milkdown editor creation failed:', err) + }) + + return () => { + cancelled = true + editorRef.current?.destroy() + editorRef.current = null + } + }, []) + + // Dark mode: update CSS custom properties on the container + useEffect(() => { + const container = containerRef.current + if (!container) return + if (darkMode) { + container.classList.add('milkdown-dark') + } else { + container.classList.remove('milkdown-dark') + } + }, [darkMode]) + + // External content update (tab switch) + const setContent = useCallback((newContent: string) => { + const editor = editorRef.current + if (!editor) return + + isExternalUpdate.current = true + try { + editor.action(milkdownReplaceAll(newContent)) + } catch { + // replaceAll may fail if editor is not fully ready + } finally { + isExternalUpdate.current = false + } + }, []) + + // Scroll position management + const getScrollTop = useCallback((): number => { + const container = containerRef.current + if (!container) return 0 + const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null + return scroller?.scrollTop ?? container.scrollTop ?? 0 + }, []) + + const setScrollTop = useCallback((top: number) => { + const container = containerRef.current + if (!container) return + const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null + if (scroller) { + scroller.scrollTop = top + } else { + container.scrollTop = top + } + }, []) + + // Selection management + const getSelection = useCallback((): { from: number; to: number } => { + const editor = editorRef.current + if (!editor) return { from: 0, to: 0 } + try { + return editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + if (view?.state?.selection) { + return { from: view.state.selection.from, to: view.state.selection.to } + } + return { from: 0, to: 0 } + }) + } catch { + return { from: 0, to: 0 } + } + }, []) + + const setSelection = useCallback(() => { + // ProseMirror selection setting requires the view instance + // which we access lazily; for now we focus the editor + const container = containerRef.current + if (!container) return + const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null + pmEditor?.focus() + }, []) + + // Get ProseMirror EditorView instance + const getView = useCallback(() => { + const editor = editorRef.current + if (!editor) return null + try { + return editor.action((ctx) => ctx.get(editorViewCtx)) + } catch { + return null + } + }, []) + + // Execute a Milkdown action (for toolbar commands) + const action = useCallback((fn: (editor: Editor) => void) => { + const editor = editorRef.current + if (!editor) return + fn(editor) + }, []) + + return { + containerRef, + editorRef, + action, + setContent, + getScrollTop, + setScrollTop, + getSelection, + setSelection, + getView + } +} diff --git a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx index 5200d73..3d5724e 100644 --- a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,88 +1,88 @@ -import { Component, ErrorInfo, ReactNode } from 'react' - -interface Props { - children: ReactNode - fallback?: ReactNode -} - -interface State { - hasError: boolean - error: Error | null -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - // eslint-disable-next-line no-console -- React error boundary standard pattern - console.error('ErrorBoundary caught an error:', error, errorInfo) - } - - handleReset = (): void => { - this.setState({ hasError: false, error: null }) - } - - render(): ReactNode { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback - } - - return ( -
-

- 应用遇到了错误 -

-
-            {this.state.error?.message}
-          
- -
- ) - } - - return this.props.children - } -} +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // eslint-disable-next-line no-console -- React error boundary standard pattern + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + handleReset = (): void => { + this.setState({ hasError: false, error: null }) + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+

+ 应用遇到了错误 +

+
+            {this.state.error?.message}
+          
+ +
+ ) + } + + return this.props.children + } +} diff --git a/src/renderer/components/OutlinePanel/OutlinePanel.tsx b/src/renderer/components/OutlinePanel/OutlinePanel.tsx new file mode 100644 index 0000000..e545150 --- /dev/null +++ b/src/renderer/components/OutlinePanel/OutlinePanel.tsx @@ -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 ( + + ) +}) + +export const OutlinePanel = memo(function OutlinePanel({ + headings, + onNavigate, + activeHeadingIndex +}: OutlinePanelProps) { + if (headings.length === 0) { + return ( +
+
文档大纲
+
当前文档无标题
+
+ ) + } + + return ( +
+
文档大纲
+
+ {headings.map((h, i) => ( + + ))} +
+
+ ) +}) + +OutlinePanel.displayName = 'OutlinePanel' diff --git a/src/renderer/components/OutlinePanel/index.ts b/src/renderer/components/OutlinePanel/index.ts new file mode 100644 index 0000000..e1b3017 --- /dev/null +++ b/src/renderer/components/OutlinePanel/index.ts @@ -0,0 +1,2 @@ +export { OutlinePanel, parseHeadings } from './OutlinePanel' +export type { Heading } from './OutlinePanel' diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx index b07f01a..9ec5c10 100644 --- a/src/renderer/components/Preview/Preview.tsx +++ b/src/renderer/components/Preview/Preview.tsx @@ -1,102 +1,102 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useEditorStore } from '../../stores/editorStore' -import { renderMarkdown } from '../../lib/markdown' -import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner' - -// PF-07: 防抖延迟常量 -const PREVIEW_DEBOUNCE_MS = 150 - -export const Preview = React.memo(function Preview() { - const [html, setHtml] = useState('') - const [rendering, setRendering] = useState(false) - const previewRef = useRef(null) - const requestIdRef = useRef(0) - const debounceTimerRef = useRef | null>(null) - - const activeTab = useTabStore(s => s.getActiveTab()) - const activeTabId = useTabStore(s => s.activeTabId) - const setLoading = useEditorStore(s => s.setLoading) - - // PF-07: 响应式渲染 + 防抖 - useEffect(() => { - if (!activeTab) { - setHtml('') - setRendering(false) - setLoading('markdown-render', false) - return - } - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - - setRendering(true) - setLoading('markdown-render', true) - - debounceTimerRef.current = setTimeout(() => { - const requestId = ++requestIdRef.current - renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => { - if (requestId === requestIdRef.current) { - setHtml(result) - setRendering(false) - setLoading('markdown-render', false) - } - }) - }, PREVIEW_DEBOUNCE_MS) - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining - }, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading]) - - // 拦截链接点击 - const handleClick = useCallback((e: React.MouseEvent) => { - const link = (e.target as HTMLElement).closest('a') - if (!link) return - e.preventDefault() - const href: string | null = link.getAttribute('href') - if (!href) return - if (href.startsWith('#')) { - const target = previewRef.current?.querySelector(href) - if (target) target.scrollIntoView({ behavior: 'smooth' }) - return - } - try { - const url = new URL(href) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return - } catch { - return - } - if (window.electronAPI?.openExternal) { - window.electronAPI.openExternal(href) - } else { - window.open(href, '_blank', 'noopener') - } - }, []) - - return ( -
- {rendering && html === '' && ( -
- -
- )} -
-
- ) -}) - -Preview.displayName = 'Preview' +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { useEditorStore } from '../../stores/editorStore' +import { renderMarkdown } from '../../lib/markdown' +import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner' + +// PF-07: 防抖延迟常量 +const PREVIEW_DEBOUNCE_MS = 150 + +export const Preview = React.memo(function Preview() { + const [html, setHtml] = useState('') + const [rendering, setRendering] = useState(false) + const previewRef = useRef(null) + const requestIdRef = useRef(0) + const debounceTimerRef = useRef | null>(null) + + const activeTab = useTabStore(s => s.getActiveTab()) + const activeTabId = useTabStore(s => s.activeTabId) + const setLoading = useEditorStore(s => s.setLoading) + + // PF-07: 响应式渲染 + 防抖 + useEffect(() => { + if (!activeTab) { + setHtml('') + setRendering(false) + setLoading('markdown-render', false) + return + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + setRendering(true) + setLoading('markdown-render', true) + + debounceTimerRef.current = setTimeout(() => { + const requestId = ++requestIdRef.current + renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => { + if (requestId === requestIdRef.current) { + setHtml(result) + setRendering(false) + setLoading('markdown-render', false) + } + }) + }, PREVIEW_DEBOUNCE_MS) + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining + }, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading]) + + // 拦截链接点击 + const handleClick = useCallback((e: React.MouseEvent) => { + const link = (e.target as HTMLElement).closest('a') + if (!link) return + e.preventDefault() + const href: string | null = link.getAttribute('href') + if (!href) return + if (href.startsWith('#')) { + const target = previewRef.current?.querySelector(href) + if (target) target.scrollIntoView({ behavior: 'smooth' }) + return + } + try { + const url = new URL(href) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return + } catch { + return + } + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal(href) + } else { + window.open(href, '_blank', 'noopener') + } + }, []) + + return ( +
+ {rendering && html === '' && ( +
+ +
+ )} +
+
+ ) +}) + +Preview.displayName = 'Preview' diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx index 710a9cf..85d7ec4 100644 --- a/src/renderer/components/SearchReplace/SearchReplace.tsx +++ b/src/renderer/components/SearchReplace/SearchReplace.tsx @@ -205,17 +205,21 @@ export const SearchReplace = memo(function SearchReplace({ const view = getView() if (!view) return - const matches = matchesRef.current - if (matches.length === 0) return + // 重新搜索以获取匹配的最新位置 + const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current) + if (freshMatches.length === 0) return - // Process matches in reverse order to preserve positions + // 从后往前替换以保持位置正确 const tr = view.state.tr - for (let i = matches.length - 1; i >= 0; i--) { - tr.insertText(replacement, matches[i].from, matches[i].to) + for (let i = freshMatches.length - 1; i >= 0; i--) { + tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to) } view.dispatch(tr) - // Re-search after replacement + // 更新ref + matchesRef.current = [] + + // 替换后重新搜索 requestAnimationFrame(() => { doSearch(queryRef.current, caseSensitiveRef.current) }) diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 9c2d1b7..6993401 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,108 +1,139 @@ -import React, { useCallback } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useSidebarStore } from '../../stores/sidebarStore' -import { getFileName } from '../../lib/fileUtils' -import { recentFilesRepository } from '../../db/recentFilesRepository' -import { FolderPlus, File } from '../Icons' -import { FileTree } from '../FileTree' -import { useSidebarResize } from '../../hooks/useSidebarResize' -import { useFolderOperations } from '../../hooks/useFolderOperations' -import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' - -const norm = (p: string) => p.replace(/\\\\/g, '/') - -export const Sidebar = React.memo(function Sidebar() { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - const activeTab = useTabStore(s => s.getActiveTab()) - const switchToTab = useTabStore(s => s.switchToTab) - const createTab = useTabStore(s => s.createTab) - const rootPath = useSidebarStore(s => s.rootPath) - const tree = useSidebarStore(s => s.tree) - const expandedDirs = useSidebarStore(s => s.expandedDirs) - const toggleDir = useSidebarStore(s => s.toggleDir) - const isVisible = useSidebarStore(s => s.isVisible) - - const activeFilePath = activeTab?.filePath ?? null - const { sidebarRef, startResize } = useSidebarResize() - const { handleOpenFolder } = useFolderOperations() - useAutoExpandDir(activeFilePath) - - const handleFileClick = useCallback(async (path: string) => { - const existing = tabs.find(t => t.filePath === path) - if (existing) { switchToTab(existing.id); return } - if (!window.electronAPI) return - const result = await window.electronAPI.readFile(path) - if (result.success && result.content) { - createTab(path, result.content) - recentFilesRepository.add(path) - } - }, [tabs, switchToTab, createTab]) - - const independentFiles = tabs.filter(t => { - if (!t.filePath) return false - if (!rootPath) return true - return !norm(t.filePath).startsWith(norm(rootPath)) - }) - - if (!isVisible) return null - - return ( - - ) -}) - -Sidebar.displayName = 'Sidebar' +import React, { useCallback, useMemo } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { useSidebarStore } from '../../stores/sidebarStore' +import { getFileName } from '../../lib/fileUtils' +import { recentFilesRepository } from '../../db/recentFilesRepository' +import { FolderPlus, File } from '../Icons' +import { FileTree } from '../FileTree' +import { useSidebarResize } from '../../hooks/useSidebarResize' +import { useFolderOperations } from '../../hooks/useFolderOperations' +import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' +import { OutlinePanel, parseHeadings } from '../OutlinePanel' +import { getEditorView } from '../../stores/editorStore' +import { TextSelection } from '@milkdown/prose/state' + +const norm = (p: string) => p.replace(/\\/g, '/') + +export const Sidebar = React.memo(function Sidebar() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const activeTab = useTabStore(s => s.getActiveTab()) + const switchToTab = useTabStore(s => s.switchToTab) + const createTab = useTabStore(s => s.createTab) + const rootPath = useSidebarStore(s => s.rootPath) + const tree = useSidebarStore(s => s.tree) + const expandedDirs = useSidebarStore(s => s.expandedDirs) + const toggleDir = useSidebarStore(s => s.toggleDir) + const isVisible = useSidebarStore(s => s.isVisible) + + const activeFilePath = activeTab?.filePath ?? null + const { sidebarRef, startResize } = useSidebarResize() + const { handleOpenFolder } = useFolderOperations() + useAutoExpandDir(activeFilePath) + + // Parse headings from active tab content + const headings = useMemo(() => { + if (!activeTab?.content) return [] + return parseHeadings(activeTab.content) + }, [activeTab?.content]) + + // Navigate to heading position in editor + const handleHeadingNavigate = useCallback((pos: number) => { + const view = getEditorView() + if (!view) return + try { + const tr = view.state.tr.setSelection( + TextSelection.create(view.state.doc, pos, pos) + ) + view.dispatch(tr) + view.focus() + } catch { + // Position may be invalid if content has changed + } + }, []) + + const handleFileClick = useCallback(async (path: string) => { + const existing = tabs.find(t => t.filePath === path) + if (existing) { switchToTab(existing.id); return } + if (!window.electronAPI) return + const result = await window.electronAPI.readFile(path) + if (result.success && result.content) { + createTab(path, result.content) + recentFilesRepository.add(path) + } + }, [tabs, switchToTab, createTab]) + + const independentFiles = tabs.filter(t => { + if (!t.filePath) return false + if (!rootPath) return true + return !norm(t.filePath).startsWith(norm(rootPath)) + }) + + if (!isVisible) return null + + return ( + + ) +}) + +Sidebar.displayName = 'Sidebar' diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index c0dfbbf..a63cf34 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -1,253 +1,253 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useConfirm } from '../../hooks/useConfirm' -import { getFileName } from '../../lib/fileUtils' -import { Close, Plus } from '../Icons' -import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog' - -interface ContextMenuState { - visible: boolean - x: number - y: number - tabId: string -} - -export const TabBar = React.memo(function TabBar() { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - const switchToTab = useTabStore(s => s.switchToTab) - const closeTab = useTabStore(s => s.closeTab) - const createTab = useTabStore(s => s.createTab) - const closeOtherTabs = useTabStore(s => s.closeOtherTabs) - const closeAllTabs = useTabStore(s => s.closeAllTabs) - const closeTabsToRight = useTabStore(s => s.closeTabsToRight) - - const tabListRef = useRef(null) - const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) - const { confirm, confirmDialogProps } = useConfirm() - - // 滚动到活动标签 - const scrollToActiveTab = useCallback(() => { - const tabList = tabListRef.current - if (!tabList) return - const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement - if (!activeTab) return - - const listRect = tabList.getBoundingClientRect() - const tabRect = activeTab.getBoundingClientRect() - - // 如果标签在可视区域左侧之外 - if (tabRect.left < listRect.left) { - tabList.scrollLeft -= (listRect.left - tabRect.left + 20) - } - // 如果标签在可视区域右侧之外 - else if (tabRect.right > listRect.right) { - tabList.scrollLeft += (tabRect.right - listRect.right + 20) - } - }, []) - - // 自动滚动到活动标签 - useEffect(() => { - requestAnimationFrame(scrollToActiveTab) - }, [activeTabId, scrollToActiveTab]) - - // 支持鼠标滚轮水平滚动标签栏 - useEffect(() => { - const tabList = tabListRef.current - if (!tabList) return - - const handleWheel = (e: WheelEvent) => { - // 检查是否有水平溢出 - if (tabList.scrollWidth <= tabList.clientWidth) return - - // 阻止默认滚动 - e.preventDefault() - - // 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY - const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY - tabList.scrollLeft += delta - } - - // 直接绑定到 tabList,使用 passive: false 允许 preventDefault - tabList.addEventListener('wheel', handleWheel, { passive: false }) - return () => tabList.removeEventListener('wheel', handleWheel) - }, []) - - const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => { - e.stopPropagation() - const tab = tabs.find(t => t.id === tabId) - if (tab?.isModified) { - const name = tab.filePath ? getFileName(tab.filePath) : '未命名' - const confirmed = await confirm({ - title: '关闭标签', - message: `"${name}" 尚未保存,确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTab(tabId) - }, [tabs, closeTab, confirm]) - - // C-06: 右键菜单(带边界修正) - const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { - e.preventDefault() - e.stopPropagation() - const MENU_WIDTH = 170 - const MENU_HEIGHT = 140 - const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) - const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) - setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) - }, []) - - useEffect(() => { - if (!menu.visible) return - const handleClick = () => setMenu(prev => ({ ...prev, visible: false })) - document.addEventListener('click', handleClick) - return () => document.removeEventListener('click', handleClick) - }, [menu.visible]) - - const handleMenuClose = useCallback(async () => { - const tab = tabs.find(t => t.id === menu.tabId) - if (tab?.isModified) { - const name = tab.filePath ? getFileName(tab.filePath) : '未命名' - const confirmed = await confirm({ - title: '关闭标签', - message: `"${name}" 尚未保存,确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTab(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeTab, confirm]) - - const handleMenuCloseOthers = useCallback(async () => { - const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) - if (otherModified.length > 0) { - const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭其他标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeOtherTabs(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeOtherTabs, confirm]) - - const handleMenuCloseAll = useCallback(async () => { - const modified = tabs.filter(t => t.isModified) - if (modified.length > 0) { - const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭全部标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeAllTabs() - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, closeAllTabs, confirm]) - - const handleMenuCloseRight = useCallback(async () => { - const index = tabs.findIndex(t => t.id === menu.tabId) - const rightTabs = tabs.slice(index + 1) - const modified = rightTabs.filter(t => t.isModified) - if (modified.length > 0) { - const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭右侧标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTabsToRight(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeTabsToRight, confirm]) - - const hasRightTabs = menu.visible && (() => { - const index = tabs.findIndex(t => t.id === menu.tabId) - return index < tabs.length - 1 - })() - - if (tabs.length === 0) return null - - return ( - <> -
-
- {tabs.map(tab => ( -
switchToTab(tab.id)} - onContextMenu={(e) => handleContextMenu(e, tab.id)} - > - - {tab.filePath ? getFileName(tab.filePath) : '未命名'} - - -
- ))} -
- - - {/* 右键菜单 */} - {menu.visible && ( -
e.stopPropagation()} - > -
- 关闭 -
- {tabs.length > 1 && ( -
- 关闭其他标签 -
- )} - {hasRightTabs && ( -
- 关闭右侧标签 -
- )} -
-
- 关闭全部标签 -
-
- )} -
- - - ) -}) - -TabBar.displayName = 'TabBar' +import React, { useCallback, useState, useEffect, useRef } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { useConfirm } from '../../hooks/useConfirm' +import { getFileName } from '../../lib/fileUtils' +import { Close, Plus } from '../Icons' +import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog' + +interface ContextMenuState { + visible: boolean + x: number + y: number + tabId: string +} + +export const TabBar = React.memo(function TabBar() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const switchToTab = useTabStore(s => s.switchToTab) + const closeTab = useTabStore(s => s.closeTab) + const createTab = useTabStore(s => s.createTab) + const closeOtherTabs = useTabStore(s => s.closeOtherTabs) + const closeAllTabs = useTabStore(s => s.closeAllTabs) + const closeTabsToRight = useTabStore(s => s.closeTabsToRight) + + const tabListRef = useRef(null) + const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) + const { confirm, confirmDialogProps } = useConfirm() + + // 滚动到活动标签 + const scrollToActiveTab = useCallback(() => { + const tabList = tabListRef.current + if (!tabList) return + const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement + if (!activeTab) return + + const listRect = tabList.getBoundingClientRect() + const tabRect = activeTab.getBoundingClientRect() + + // 如果标签在可视区域左侧之外 + if (tabRect.left < listRect.left) { + tabList.scrollLeft -= (listRect.left - tabRect.left + 20) + } + // 如果标签在可视区域右侧之外 + else if (tabRect.right > listRect.right) { + tabList.scrollLeft += (tabRect.right - listRect.right + 20) + } + }, []) + + // 自动滚动到活动标签 + useEffect(() => { + requestAnimationFrame(scrollToActiveTab) + }, [activeTabId, scrollToActiveTab]) + + // 支持鼠标滚轮水平滚动标签栏 + useEffect(() => { + const tabList = tabListRef.current + if (!tabList) return + + const handleWheel = (e: WheelEvent) => { + // 检查是否有水平溢出 + if (tabList.scrollWidth <= tabList.clientWidth) return + + // 阻止默认滚动 + e.preventDefault() + + // 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY + const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY + tabList.scrollLeft += delta + } + + // 直接绑定到 tabList,使用 passive: false 允许 preventDefault + tabList.addEventListener('wheel', handleWheel, { passive: false }) + return () => tabList.removeEventListener('wheel', handleWheel) + }, []) + + const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => { + e.stopPropagation() + const tab = tabs.find(t => t.id === tabId) + if (tab?.isModified) { + const name = tab.filePath ? getFileName(tab.filePath) : '未命名' + const confirmed = await confirm({ + title: '关闭标签', + message: `"${name}" 尚未保存,确定要关闭吗?`, + variant: 'warning', + confirmLabel: '关闭' + }) + if (!confirmed) return + } + closeTab(tabId) + }, [tabs, closeTab, confirm]) + + // C-06: 右键菜单(带边界修正) + const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { + e.preventDefault() + e.stopPropagation() + const MENU_WIDTH = 170 + const MENU_HEIGHT = 140 + const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) + const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) + setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) + }, []) + + useEffect(() => { + if (!menu.visible) return + const handleClick = () => setMenu(prev => ({ ...prev, visible: false })) + document.addEventListener('click', handleClick) + return () => document.removeEventListener('click', handleClick) + }, [menu.visible]) + + const handleMenuClose = useCallback(async () => { + const tab = tabs.find(t => t.id === menu.tabId) + if (tab?.isModified) { + const name = tab.filePath ? getFileName(tab.filePath) : '未命名' + const confirmed = await confirm({ + title: '关闭标签', + message: `"${name}" 尚未保存,确定要关闭吗?`, + variant: 'warning', + confirmLabel: '关闭' + }) + if (!confirmed) return + } + closeTab(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeTab, confirm]) + + const handleMenuCloseOthers = useCallback(async () => { + const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) + if (otherModified.length > 0) { + const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') + const confirmed = await confirm({ + title: '关闭其他标签', + message: `以下文件尚未保存:${names},确定要关闭吗?`, + variant: 'warning', + confirmLabel: '关闭' + }) + if (!confirmed) return + } + closeOtherTabs(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeOtherTabs, confirm]) + + const handleMenuCloseAll = useCallback(async () => { + const modified = tabs.filter(t => t.isModified) + if (modified.length > 0) { + const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') + const confirmed = await confirm({ + title: '关闭全部标签', + message: `以下文件尚未保存:${names},确定要关闭吗?`, + variant: 'warning', + confirmLabel: '关闭' + }) + if (!confirmed) return + } + closeAllTabs() + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, closeAllTabs, confirm]) + + const handleMenuCloseRight = useCallback(async () => { + const index = tabs.findIndex(t => t.id === menu.tabId) + const rightTabs = tabs.slice(index + 1) + const modified = rightTabs.filter(t => t.isModified) + if (modified.length > 0) { + const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') + const confirmed = await confirm({ + title: '关闭右侧标签', + message: `以下文件尚未保存:${names},确定要关闭吗?`, + variant: 'warning', + confirmLabel: '关闭' + }) + if (!confirmed) return + } + closeTabsToRight(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeTabsToRight, confirm]) + + const hasRightTabs = menu.visible && (() => { + const index = tabs.findIndex(t => t.id === menu.tabId) + return index < tabs.length - 1 + })() + + if (tabs.length === 0) return null + + return ( + <> +
+
+ {tabs.map(tab => ( +
switchToTab(tab.id)} + onContextMenu={(e) => handleContextMenu(e, tab.id)} + > + + {tab.filePath ? getFileName(tab.filePath) : '未命名'} + + +
+ ))} +
+ + + {/* 右键菜单 */} + {menu.visible && ( +
e.stopPropagation()} + > +
+ 关闭 +
+ {tabs.length > 1 && ( +
+ 关闭其他标签 +
+ )} + {hasRightTabs && ( +
+ 关闭右侧标签 +
+ )} +
+
+ 关闭全部标签 +
+
+ )} +
+ + + ) +}) + +TabBar.displayName = 'TabBar' diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts index 73ec21d..9087329 100644 --- a/src/renderer/hooks/useFileOperations.ts +++ b/src/renderer/hooks/useFileOperations.ts @@ -1,84 +1,84 @@ -import { useCallback } from 'react' -import { useTabStore } from '../stores/tabStore' -import { useEditorStore } from '../stores/editorStore' -import { recentFilesRepository } from '../db/recentFilesRepository' -import { logError } from '../lib/errorHandler' -import type { ToastType } from '../components/Toast/Toast' - -/** - * AR-01: 从 App.tsx 提取的文件操作逻辑 - * UX-02: 添加 loading 状态指示 - */ -export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) { - const createTab = useTabStore(s => s.createTab) - const getActiveTab = useTabStore(s => s.getActiveTab) - const setLoading = useEditorStore(s => s.setLoading) - - const handleOpenFile = useCallback(async (): Promise => { - try { - if (!window.electronAPI) return - setLoading('file-open', true) - const result = await window.electronAPI.openFile() - if (result && 'filePath' in result) { - createTab(result.filePath, result.content) - if (result.filePath) recentFilesRepository.add(result.filePath) - setTimeout(() => useTabStore.getState().saveToDB(), 100) - } - } catch (error) { - logError('打开文件失败', error) - showToast('打开文件失败', 'error') - } finally { - setLoading('file-open', false) - } - }, [createTab, showToast, setLoading]) - - const handleSave = useCallback(async (): Promise => { - try { - const tab = getActiveTab() - if (!tab || !window.electronAPI) return - const result = await window.electronAPI.saveFile({ - filePath: tab.filePath, - content: tab.content - }) - if (result.success) { - useTabStore.getState().setModified(tab.id, false) - showToast('已保存', 'success') - } - } catch (error) { - logError('保存文件失败', error) - showToast('保存失败', 'error') - } - }, [getActiveTab, showToast]) - - const handleSaveAs = useCallback(async (): Promise => { - try { - const tab = getActiveTab() - if (!tab || !window.electronAPI) return - const result = await window.electronAPI.saveFileAs({ content: tab.content }) - if (result.success) { - useTabStore.getState().setModified(tab.id, false) - showToast('已保存', 'success') - } - } catch (error) { - logError('另存为失败', error) - showToast('另存为失败', 'error') - } - }, [getActiveTab, showToast]) - - const handleOpenRecent = useCallback(async (filePath: string): Promise => { - if (!window.electronAPI) return - setLoading('file-open', true) - try { - const result = await window.electronAPI.readFile(filePath) - if (result.success) { - createTab(filePath, result.content) - recentFilesRepository.add(filePath) - setTimeout(() => useTabStore.getState().saveToDB(), 100) - } - } finally { - setLoading('file-open', false) - } - }, [createTab, setLoading]) - - return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } -} +import { useCallback } from 'react' +import { useTabStore } from '../stores/tabStore' +import { useEditorStore } from '../stores/editorStore' +import { recentFilesRepository } from '../db/recentFilesRepository' +import { logError } from '../lib/errorHandler' +import type { ToastType } from '../components/Toast/Toast' + +/** + * AR-01: 从 App.tsx 提取的文件操作逻辑 + * UX-02: 添加 loading 状态指示 + */ +export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) { + const createTab = useTabStore(s => s.createTab) + const getActiveTab = useTabStore(s => s.getActiveTab) + const setLoading = useEditorStore(s => s.setLoading) + + const handleOpenFile = useCallback(async (): Promise => { + try { + if (!window.electronAPI) return + setLoading('file-open', true) + const result = await window.electronAPI.openFile() + if (result && 'filePath' in result) { + createTab(result.filePath, result.content) + if (result.filePath) recentFilesRepository.add(result.filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + } catch (error) { + logError('打开文件失败', error) + showToast('打开文件失败', 'error') + } finally { + setLoading('file-open', false) + } + }, [createTab, showToast, setLoading]) + + const handleSave = useCallback(async (): Promise => { + try { + const tab = getActiveTab() + if (!tab || !window.electronAPI) return + const result = await window.electronAPI.saveFile({ + filePath: tab.filePath, + content: tab.content + }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存', 'success') + } + } catch (error) { + logError('保存文件失败', error) + showToast('保存失败', 'error') + } + }, [getActiveTab, showToast]) + + const handleSaveAs = useCallback(async (): Promise => { + try { + const tab = getActiveTab() + if (!tab || !window.electronAPI) return + const result = await window.electronAPI.saveFileAs({ content: tab.content }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存', 'success') + } + } catch (error) { + logError('另存为失败', error) + showToast('另存为失败', 'error') + } + }, [getActiveTab, showToast]) + + const handleOpenRecent = useCallback(async (filePath: string): Promise => { + if (!window.electronAPI) return + setLoading('file-open', true) + try { + const result = await window.electronAPI.readFile(filePath) + if (result.success) { + createTab(filePath, result.content) + recentFilesRepository.add(filePath) + setTimeout(() => useTabStore.getState().saveToDB(), 100) + } + } finally { + setLoading('file-open', false) + } + }, [createTab, setLoading]) + + return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } +} diff --git a/src/renderer/hooks/useFolderOperations.ts b/src/renderer/hooks/useFolderOperations.ts index 8cb77f0..df6cedb 100644 --- a/src/renderer/hooks/useFolderOperations.ts +++ b/src/renderer/hooks/useFolderOperations.ts @@ -16,6 +16,7 @@ export function useFolderOperations() { const handleOpenFolder = useCallback(async () => { if (!window.electronAPI) return const result = await window.electronAPI.openFolderDialog() + if (!result) return if (!result.canceled && result.filePaths[0]) { const dirPath = result.filePaths[0] setRootPath(dirPath) diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts index f22357b..b2555fe 100644 --- a/src/renderer/lib/constants.ts +++ b/src/renderer/lib/constants.ts @@ -1,8 +1,2 @@ -// 常量定义 -export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB -export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const -export const SKIP_DIRS = new Set([ - 'node_modules', '.git', '.svn', '.hg', 'dist', 'out', - '.next', '.nuxt', '__pycache__', '.DS_Store' -]) - +// 重新导出共享常量 +export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants' diff --git a/src/renderer/lib/errorHandler.ts b/src/renderer/lib/errorHandler.ts index 254b8cb..51786af 100644 --- a/src/renderer/lib/errorHandler.ts +++ b/src/renderer/lib/errorHandler.ts @@ -1,43 +1,43 @@ -/** - * CQ-05: 统一错误处理模块 - * 提供一致的错误处理模式,替换分散的 try-catch - */ - -export class AppError extends Error { - constructor( - message: string, - public readonly code: string, - public readonly cause?: unknown - ) { - super(message) - this.name = 'AppError' - } -} - -/** 日志级别的错误处理(仅 console.error,不中断流程) */ -export function logError(context: string, error: unknown): void { - const message = error instanceof Error ? error.message : String(error) - // eslint-disable-next-line no-console -- intentional logging utility - console.error(`[${context}] ${message}`, error) -} - -/** 用户操作级错误处理(返回友好的错误信息给 showToast) */ -export function getErrorMessage(error: unknown, fallback: string): string { - if (error instanceof AppError) return error.message - if (error instanceof Error) return `${fallback}: ${error.message}` - return fallback -} - -/** 通用 try-catch 包装器,返回 [result, error] */ -export async function tryAsync( - fn: () => Promise, - context: string -): Promise<[T | null, null] | [null, AppError]> { - try { - const result = await fn() - return [result, null] - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return [null, new AppError(`${context}: ${message}`, context, error)] - } -} +/** + * CQ-05: 统一错误处理模块 + * 提供一致的错误处理模式,替换分散的 try-catch + */ + +export class AppError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly cause?: unknown + ) { + super(message) + this.name = 'AppError' + } +} + +/** 日志级别的错误处理(仅 console.error,不中断流程) */ +export function logError(context: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + // eslint-disable-next-line no-console -- intentional logging utility + console.error(`[${context}] ${message}`, error) +} + +/** 用户操作级错误处理(返回友好的错误信息给 showToast) */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof AppError) return error.message + if (error instanceof Error) return `${fallback}: ${error.message}` + return fallback +} + +/** 通用 try-catch 包装器,返回 [result, error] */ +export async function tryAsync( + fn: () => Promise, + context: string +): Promise<[T | null, null] | [null, AppError]> { + try { + const result = await fn() + return [result, null] + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return [null, new AppError(`${context}: ${message}`, context, error)] + } +} diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index 1397626..bff80f9 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -1,115 +1,123 @@ -import { unified, type Plugin } from 'unified' -import remarkParse from 'remark-parse' -import remarkGfm from 'remark-gfm' -import remarkRehype from 'remark-rehype' -import rehypeRaw from 'rehype-raw' -import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' -import rehypeStringify from 'rehype-stringify' -import rehypeHighlight from 'rehype-highlight' -import type { Element, Root } from 'hast' - -// 简单的路径解析(Electron renderer 中没有 path 模块) -function resolveRelativePath(base: string, rel: string): string { - const sep = base.includes('\\') ? '\\' : '/' - const parts = base.split(sep) - parts.pop() // 移除文件名 - const relParts = rel.split('/') - for (const p of relParts) { - if (p === '.' || p === '') continue - if (p === '..') { - if (parts.length > 0) parts.pop() - } else { - parts.push(p) - } - } - return parts.join(sep) -} - -// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 -function rehypeFixImages(filePath: string | null): Plugin<[], Root> { - return () => (tree: Root) => { - if (!filePath) return - - const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') - const sep: string = dir.includes('\\') ? '\\' : '/' - - function visit(node: Element | Root): void { - if (!node.children) return - for (const child of node.children) { - if (child.type === 'element' && child.tagName === 'img') { - const src = child.properties?.src as string | undefined - if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { - // 解析完整路径并检查是否越界到 markdown 文件所在目录之外 - const resolvedPath = resolveRelativePath(dir, src) - if (!resolvedPath.startsWith(dir + sep)) return - child.properties = { - ...child.properties, - src: 'file://' + (dir + sep + src).replace(/\\/g, '/') - } - } - } - if (child.type === 'element') { - visit(child as Element) - } - } - } - - visit(tree) - } -} - -// PF-01: Markdown处理器LRU缓存 -const MAX_CACHE_SIZE = 10 -const processorCache = new Map>() - -function buildProcessor(filePath: string | null) { - return unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw) - .use(rehypeSanitize, { - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - img: [...(defaultSchema.attributes?.img ?? []), ['src']], - } - }) - .use(rehypeFixImages(filePath ?? null)) - .use(rehypeHighlight) - .use(rehypeStringify) -} - -function getCachedProcessor(filePath: string | null): ReturnType { - const key: string = filePath ?? '__null__' - - if (processorCache.has(key)) { - const cached = processorCache.get(key)! - processorCache.delete(key) - processorCache.set(key, cached) - return cached - } - - const processor = buildProcessor(filePath) - - if (processorCache.size >= MAX_CACHE_SIZE) { - const oldestKey = processorCache.keys().next().value - if (oldestKey !== undefined) { - processorCache.delete(oldestKey) - } - } - - processorCache.set(key, processor) - return processor -} - -export async function renderMarkdown(content: string, filePath?: string | null): Promise { - 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 `

渲染错误: ${errorMsg}

` - } -} +import { unified, type Plugin } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkRehype from 'remark-rehype' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import rehypeStringify from 'rehype-stringify' +import rehypeHighlight from 'rehype-highlight' +import type { Element, Root } from 'hast' + +// 简单的路径解析(Electron renderer 中没有 path 模块) +function resolveRelativePath(base: string, rel: string): string { + const sep = base.includes('\\') ? '\\' : '/' + const parts = base.split(sep) + parts.pop() // 移除文件名 + const relParts = rel.split('/') + for (const p of relParts) { + if (p === '.' || p === '') continue + if (p === '..') { + if (parts.length > 0) parts.pop() + } else { + parts.push(p) + } + } + return parts.join(sep) +} + +// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 +function rehypeFixImages(filePath: string | null): Plugin<[], Root> { + return () => (tree: Root) => { + if (!filePath) return + + const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') + const sep: string = dir.includes('\\') ? '\\' : '/' + + function visit(node: Element | Root): void { + if (!node.children) return + for (const child of node.children) { + if (child.type === 'element' && child.tagName === 'img') { + const src = child.properties?.src as string | undefined + if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { + // 处理Unix风格绝对路径(以/开头) + if (src.startsWith('/')) { + child.properties = { + ...child.properties, + src: 'file://' + src + } + continue + } + // 解析完整路径并检查是否越界到 markdown 文件所在目录之外 + const resolvedPath = resolveRelativePath(dir, src) + if (!resolvedPath.startsWith(dir + sep)) return + child.properties = { + ...child.properties, + src: 'file://' + (dir + sep + src).replace(/\\/g, '/') + } + } + } + if (child.type === 'element') { + visit(child as Element) + } + } + } + + visit(tree) + } +} + +// PF-01: Markdown处理器LRU缓存 +const MAX_CACHE_SIZE = 10 +const processorCache = new Map>() + +function buildProcessor(filePath: string | null) { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw) + .use(rehypeSanitize, { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + img: [...(defaultSchema.attributes?.img ?? []), ['src']], + } + }) + .use(rehypeFixImages(filePath ?? null)) + .use(rehypeHighlight) + .use(rehypeStringify) +} + +function getCachedProcessor(filePath: string | null): ReturnType { + const key: string = filePath ?? '__null__' + + if (processorCache.has(key)) { + const cached = processorCache.get(key)! + processorCache.delete(key) + processorCache.set(key, cached) + return cached + } + + const processor = buildProcessor(filePath) + + if (processorCache.size >= MAX_CACHE_SIZE) { + const oldestKey = processorCache.keys().next().value + if (oldestKey !== undefined) { + processorCache.delete(oldestKey) + } + } + + processorCache.set(key, processor) + return processor +} + +export async function renderMarkdown(content: string, filePath?: string | null): Promise { + 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 `

渲染错误: ${errorMsg}

` + } +} diff --git a/src/renderer/stores/editorStore.ts b/src/renderer/stores/editorStore.ts index da51f61..592ddbe 100644 --- a/src/renderer/stores/editorStore.ts +++ b/src/renderer/stores/editorStore.ts @@ -1,6 +1,19 @@ import { create } from 'zustand' +import type { EditorView } from '@milkdown/prose/view' import type { ViewMode } from '../types/settings' +// Module-level getter/setter for the ProseMirror EditorView +// Used by OutlinePanel for heading navigation +let _getView: (() => EditorView | null) | null = null + +export function setEditorViewGetter(fn: () => EditorView | null) { + _getView = fn +} + +export function getEditorView(): EditorView | null { + return _getView ? _getView() : null +} + interface EditorState { viewMode: ViewMode darkMode: boolean diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 171bfaa..33eec4f 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -1,246 +1,246 @@ -import { create } from 'zustand' -import { nanoid } from 'nanoid' -import type { Tab } from '../types/tab' -import { tabRepository } from '../db/tabRepository' - -// PF-08: 防抖工具函数 -function debounce void>(fn: F, delay: number): F & { cancel: () => void } { - let timer: ReturnType | null = null - const debounced = ((...args: unknown[]) => { - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - fn(...args) - timer = null - }, delay) - }) as F & { cancel: () => void } - debounced.cancel = () => { - if (timer) { - clearTimeout(timer) - timer = null - } - } - return debounced -} - -interface TabState { - tabs: Tab[] - activeTabId: string | null - mruStack: string[] - _loaded: boolean - - createTab: (filePath?: string | null, content?: string) => Tab - closeTab: (tabId: string) => void - closeOtherTabs: (tabId: string) => void - closeAllTabs: () => void - closeTabsToRight: (tabId: string) => void - switchToTab: (tabId: string) => void - updateTabContent: (tabId: string, content: string) => void - setModified: (tabId: string, modified: boolean) => void - getActiveTab: () => Tab | null - updateTabScroll: (tabId: string, scroll: Partial>) => void - loadFromDB: () => Promise - saveToDB: () => Promise -} - -// PF-08: 模块级防抖保存函数(500ms延迟) -let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null - -function getActualSaveToDB(get: () => TabState) { - return async () => { - const { tabs } = get() - if (tabs.length === 0) { - await tabRepository.clearAll() - return - } - const snapshots = tabs.map(t => ({ - id: t.id, - filePath: t.filePath, - content: t.content, - isModified: t.isModified, - scrollTop: t.scrollTop, - selectionStart: t.selectionStart, - selectionEnd: t.selectionEnd, - updatedAt: Date.now() - })) - await tabRepository.saveAll(snapshots) - await tabRepository.saveActiveTabId(get().activeTabId) - } -} - -export const useTabStore = create((set, get) => { - const actualSave = getActualSaveToDB(get) - _debouncedSaveToDB = debounce(actualSave, 500) - - return { - tabs: [], - activeTabId: null, - mruStack: [], - _loaded: false, - - loadFromDB: async () => { - if (get()._loaded) return - try { - const [snapshots, savedActiveTabId] = await Promise.all([ - tabRepository.loadAll(), - tabRepository.loadActiveTabId() - ]) - if (snapshots.length > 0) { - const tabs: Tab[] = snapshots.map(s => ({ - id: s.id, - filePath: s.filePath, - content: s.content, - isModified: s.isModified, - scrollTop: s.scrollTop, - selectionStart: s.selectionStart, - selectionEnd: s.selectionEnd - })) - const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) - ? savedActiveTabId - : tabs[tabs.length - 1].id - set({ tabs, activeTabId, _loaded: true }) - } else { - set({ _loaded: true }) - } - } catch (err) { - // eslint-disable-next-line no-console -- DB load error - console.error('Failed to load tabs from DB:', err) - set({ _loaded: true }) - } - }, - - saveToDB: async () => { - if (_debouncedSaveToDB) { - _debouncedSaveToDB() - } - }, - - createTab: (filePath: string | null = null, content: string = ''): Tab => { - if (filePath) { - const existing = get().tabs.find(t => t.filePath === filePath) - if (existing) { - get().switchToTab(existing.id) - return existing - } - } - - const tab: Tab = { - id: nanoid(), - filePath, - content, - isModified: false, - scrollTop: 0, - selectionStart: 0, - selectionEnd: 0 - } - - set(state => ({ - tabs: [...state.tabs, tab], - activeTabId: tab.id - })) - - _debouncedSaveToDB?.() - return tab - }, - - closeTab: (tabId: string) => { - set(state => { - const index = state.tabs.findIndex(t => t.id === tabId) - if (index === -1) return state - - const newTabs = state.tabs.filter(t => t.id !== tabId) - const newMru = state.mruStack.filter(id => id !== tabId) - - let newActiveId = state.activeTabId - if (state.activeTabId === tabId) { - if (newTabs.length === 0) { - newActiveId = null - } else { - const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) - if (mruCandidate) { - newActiveId = mruCandidate - newMru.splice(newMru.indexOf(mruCandidate), 1) - } else { - const newIndex = Math.min(index, newTabs.length - 1) - newActiveId = newTabs[newIndex].id - } - } - } - - return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru } - }) - - _debouncedSaveToDB?.() - }, - - closeOtherTabs: (tabId: string) => { - set(state => { - const target = state.tabs.find(t => t.id === tabId) - if (!target) return state - return { tabs: [target], activeTabId: tabId, mruStack: [] } - }) - _debouncedSaveToDB?.() - }, - - closeAllTabs: () => { - set({ tabs: [], activeTabId: null, mruStack: [] }) - _debouncedSaveToDB?.() - }, - - closeTabsToRight: (tabId: string) => { - set(state => { - const index = state.tabs.findIndex(t => t.id === tabId) - if (index === -1) return state - const newTabs = state.tabs.slice(0, index + 1) - const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) - ? state.activeTabId - : tabId - return { - tabs: newTabs, - activeTabId: newActiveId, - mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) - } - }) - _debouncedSaveToDB?.() - }, - - switchToTab: (tabId: string) => { - set(state => { - if (state.activeTabId === tabId) return state - const newMru = state.activeTabId - ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] - : state.mruStack - return { activeTabId: tabId, mruStack: newMru } - }) - setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) - }, - - updateTabContent: (tabId: string, content: string) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, content, isModified: true } : t - ) - })) - }, - - setModified: (tabId: string, modified: boolean) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, isModified: modified } : t - ) - })) - }, - - getActiveTab: (): Tab | null => { - const { tabs, activeTabId } = get() - return tabs.find(t => t.id === activeTabId) ?? null - }, - - updateTabScroll: (tabId: string, scroll: Partial>) => { - set(state => ({ - tabs: state.tabs.map(t => - t.id === tabId ? { ...t, ...scroll } : t - ) - })) - } - } -}) +import { create } from 'zustand' +import { nanoid } from 'nanoid' +import type { Tab } from '../types/tab' +import { tabRepository } from '../db/tabRepository' + +// PF-08: 防抖工具函数 +function debounce void>(fn: F, delay: number): F & { cancel: () => void } { + let timer: ReturnType | null = null + const debounced = ((...args: unknown[]) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn(...args) + timer = null + }, delay) + }) as F & { cancel: () => void } + debounced.cancel = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + return debounced +} + +interface TabState { + tabs: Tab[] + activeTabId: string | null + mruStack: string[] + _loaded: boolean + + createTab: (filePath?: string | null, content?: string) => Tab + closeTab: (tabId: string) => void + closeOtherTabs: (tabId: string) => void + closeAllTabs: () => void + closeTabsToRight: (tabId: string) => void + switchToTab: (tabId: string) => void + updateTabContent: (tabId: string, content: string) => void + setModified: (tabId: string, modified: boolean) => void + getActiveTab: () => Tab | null + updateTabScroll: (tabId: string, scroll: Partial>) => void + loadFromDB: () => Promise + saveToDB: () => Promise +} + +// PF-08: 模块级防抖保存函数(500ms延迟) +let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null + +function getActualSaveToDB(get: () => TabState) { + return async () => { + const { tabs } = get() + if (tabs.length === 0) { + await tabRepository.clearAll() + return + } + const snapshots = tabs.map(t => ({ + id: t.id, + filePath: t.filePath, + content: t.content, + isModified: t.isModified, + scrollTop: t.scrollTop, + selectionStart: t.selectionStart, + selectionEnd: t.selectionEnd, + updatedAt: Date.now() + })) + await tabRepository.saveAll(snapshots) + await tabRepository.saveActiveTabId(get().activeTabId) + } +} + +export const useTabStore = create((set, get) => { + const actualSave = getActualSaveToDB(get) + _debouncedSaveToDB = debounce(actualSave, 500) + + return { + tabs: [], + activeTabId: null, + mruStack: [], + _loaded: false, + + loadFromDB: async () => { + if (get()._loaded) return + try { + const [snapshots, savedActiveTabId] = await Promise.all([ + tabRepository.loadAll(), + tabRepository.loadActiveTabId() + ]) + if (snapshots.length > 0) { + const tabs: Tab[] = snapshots.map(s => ({ + id: s.id, + filePath: s.filePath, + content: s.content, + isModified: s.isModified, + scrollTop: s.scrollTop, + selectionStart: s.selectionStart, + selectionEnd: s.selectionEnd + })) + const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) + ? savedActiveTabId + : tabs[tabs.length - 1].id + set({ tabs, activeTabId, _loaded: true }) + } else { + set({ _loaded: true }) + } + } catch (err) { + // eslint-disable-next-line no-console -- DB load error + console.error('Failed to load tabs from DB:', err) + set({ _loaded: true }) + } + }, + + saveToDB: async () => { + if (_debouncedSaveToDB) { + _debouncedSaveToDB() + } + }, + + createTab: (filePath: string | null = null, content: string = ''): Tab => { + if (filePath) { + const existing = get().tabs.find(t => t.filePath === filePath) + if (existing) { + get().switchToTab(existing.id) + return existing + } + } + + const tab: Tab = { + id: nanoid(), + filePath, + content, + isModified: false, + scrollTop: 0, + selectionStart: 0, + selectionEnd: 0 + } + + set(state => ({ + tabs: [...state.tabs, tab], + activeTabId: tab.id + })) + + _debouncedSaveToDB?.() + return tab + }, + + closeTab: (tabId: string) => { + set(state => { + const index = state.tabs.findIndex(t => t.id === tabId) + if (index === -1) return state + + const newTabs = state.tabs.filter(t => t.id !== tabId) + const newMru = state.mruStack.filter(id => id !== tabId) + + let newActiveId = state.activeTabId + if (state.activeTabId === tabId) { + if (newTabs.length === 0) { + newActiveId = null + } else { + const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) + if (mruCandidate) { + newActiveId = mruCandidate + newMru.splice(newMru.indexOf(mruCandidate), 1) + } else { + const newIndex = Math.min(index, newTabs.length - 1) + newActiveId = newTabs[newIndex].id + } + } + } + + return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru } + }) + + _debouncedSaveToDB?.() + }, + + closeOtherTabs: (tabId: string) => { + set(state => { + const target = state.tabs.find(t => t.id === tabId) + if (!target) return state + return { tabs: [target], activeTabId: tabId, mruStack: [] } + }) + _debouncedSaveToDB?.() + }, + + closeAllTabs: () => { + set({ tabs: [], activeTabId: null, mruStack: [] }) + _debouncedSaveToDB?.() + }, + + closeTabsToRight: (tabId: string) => { + set(state => { + const index = state.tabs.findIndex(t => t.id === tabId) + if (index === -1) return state + const newTabs = state.tabs.slice(0, index + 1) + const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) + ? state.activeTabId + : tabId + return { + tabs: newTabs, + activeTabId: newActiveId, + mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) + } + }) + _debouncedSaveToDB?.() + }, + + switchToTab: (tabId: string) => { + set(state => { + if (state.activeTabId === tabId) return state + const newMru = state.activeTabId + ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] + : state.mruStack + return { activeTabId: tabId, mruStack: newMru } + }) + setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) + }, + + updateTabContent: (tabId: string, content: string) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, content, isModified: true } : t + ) + })) + }, + + setModified: (tabId: string, modified: boolean) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, isModified: modified } : t + ) + })) + }, + + getActiveTab: (): Tab | null => { + const { tabs, activeTabId } = get() + return tabs.find(t => t.id === activeTabId) ?? null + }, + + updateTabScroll: (tabId: string, scroll: Partial>) => { + set(state => ({ + tabs: state.tabs.map(t => + t.id === tabId ? { ...t, ...scroll } : t + ) + })) + } + } +}) diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index 0839c2d..81c4782 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -1600,3 +1600,107 @@ button:focus-visible, background: rgba(255, 180, 0, 0.4); outline-color: rgba(255, 180, 0, 0.6); } + +/* ===== Outline Panel (Table of Contents) ===== */ + +.sidebar-outline-section { + flex-shrink: 0; + max-height: 40%; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.outline-panel { + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.outline-header { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-light); +} + +.outline-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.outline-list::-webkit-scrollbar { + width: 4px; +} + +.outline-list::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 2px; +} + +.outline-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 3px 8px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + text-align: left; + transition: all 0.1s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.outline-item:hover { + background: var(--bg-tertiary); + color: var(--text); +} + +.outline-item.active { + color: var(--primary); + background: var(--primary-light); +} + +.outline-item:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +.outline-level-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--text-tertiary); + flex-shrink: 0; +} + +.outline-item:hover .outline-level-dot, +.outline-item.active .outline-level-dot { + background: var(--primary); +} + +.outline-item-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.outline-empty { + padding: 13px 12px; + font-size: 11px; + color: var(--text-tertiary); + text-align: center; +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..d357981 --- /dev/null +++ b/src/shared/constants.ts @@ -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' +])