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