From 05e5667e48f787992ae78652212d1bc729108075 Mon Sep 17 00:00:00 2001
From: thzxx
Date: Thu, 23 Jul 2026 22:55:45 +0800
Subject: [PATCH] =?UTF-8?q?release:=20v0.4.0=20=E2=80=94=20MetonaEditor=20?=
=?UTF-8?q?=E9=9B=86=E6=88=90=E4=B8=8E=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
feat(editor): 将 Milkdown/ProseMirror 替换为 @metona-team/metona-editor v0.1.3
- 三模式视图 (edit/split/preview) 由编辑器内置工具栏切换
- searchReplace + imagePaste 预设插件
- unified/rehype 渲染管线通过 render 钩子集成
- 主题双向同步 (应用暗色模式 ↔ 编辑器主题)
feat(toast): metona-toast 迁移为 @metona-team/metona-toast v2.0.1
refactor: 删除冗余组件与代码
- 移除 SourceEditor、Preview、SearchReplace、EditorToolbar、useMilkdown
- 移除 StatusBar 组件及状态栏扩展架构(编辑器内置底栏替代)
- 移除 useSettings、useStatusBarItem、useStatusBarItems、statusBarStore
- 移除 EditMode/PreviewMode/SourceMode 图标
refactor(ui): 简化布局
- 工具栏移除模式切换按钮,新增自动保存开关
- useKeyboard 移除 Ctrl+1/2/3/B/I 快捷键
- Editor 三模式统一由 MetonaEditor 容器渲染
chore: 版本号 v0.3.13 → v0.4.0
docs: 全面更新 README/DESIGN/CONTRIBUTING/DEVSETUP
---
CONTRIBUTING.md | 28 +-
DESIGN.md | 170 +--
DEVSETUP.md | 15 +-
QUALITY_REVIEW_REPORT.json | 317 ------
README.md | 72 +-
docs/optimization-architecture.json | 973 ------------------
docs/optimization-delivery-report.json | 248 -----
docs/optimization-requirements.json | 593 -----------
package.json | 17 +-
src/renderer/App.tsx | 53 +-
src/renderer/components/Editor/Editor.tsx | 282 +++--
.../components/Editor/EditorToolbar.tsx | 160 ---
src/renderer/components/Editor/useMilkdown.ts | 344 -------
src/renderer/components/Icons.tsx | 29 -
src/renderer/components/Preview/Preview.tsx | 102 --
.../SearchReplace/SearchReplace.tsx | 446 --------
.../components/SearchReplace/index.ts | 1 -
src/renderer/components/Sidebar/Sidebar.tsx | 67 +-
.../components/SourceEditor/SourceEditor.tsx | 109 --
.../components/StatusBar/StatusBar.tsx | 42 -
.../components/StatusBar/StatusBarItems.tsx | 64 --
src/renderer/components/Toolbar/Toolbar.tsx | 49 +-
src/renderer/hooks/useKeyboard.ts | 15 +-
src/renderer/hooks/useSettings.ts | 21 -
src/renderer/hooks/useSettingsInit.ts | 4 +-
src/renderer/hooks/useStatusBarItem.ts | 21 -
src/renderer/hooks/useStatusBarItems.tsx | 36 -
src/renderer/lib/markdown.ts | 15 +
src/renderer/lib/toast.ts | 2 +-
src/renderer/stores/editorStore.ts | 14 +-
src/renderer/stores/statusBarStore.ts | 47 -
src/renderer/styles/global.css | 437 +-------
src/shared/constants.ts | 2 +-
33 files changed, 460 insertions(+), 4335 deletions(-)
delete mode 100644 QUALITY_REVIEW_REPORT.json
delete mode 100644 docs/optimization-architecture.json
delete mode 100644 docs/optimization-delivery-report.json
delete mode 100644 docs/optimization-requirements.json
delete mode 100644 src/renderer/components/Editor/EditorToolbar.tsx
delete mode 100644 src/renderer/components/Editor/useMilkdown.ts
delete mode 100644 src/renderer/components/Preview/Preview.tsx
delete mode 100644 src/renderer/components/SearchReplace/SearchReplace.tsx
delete mode 100644 src/renderer/components/SearchReplace/index.ts
delete mode 100644 src/renderer/components/SourceEditor/SourceEditor.tsx
delete mode 100644 src/renderer/components/StatusBar/StatusBar.tsx
delete mode 100644 src/renderer/components/StatusBar/StatusBarItems.tsx
delete mode 100644 src/renderer/hooks/useSettings.ts
delete mode 100644 src/renderer/hooks/useStatusBarItem.ts
delete mode 100644 src/renderer/hooks/useStatusBarItems.tsx
delete mode 100644 src/renderer/stores/statusBarStore.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b1c3515..01c9c16 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -30,7 +30,7 @@
## 分支策略
-- `main` — 稳定版本
+- `master` — 稳定版本
- `dev` — 开发分支
- `feature/*` — 功能分支
- `fix/*` — 修复分支
@@ -98,16 +98,26 @@ src/
├── main/ # 主进程 (Node.js)
├── preload/ # 预加载脚本
├── renderer/ # 渲染进程 (React)
-│ ├── components/ # UI 组件
-│ ├── stores/ # Zustand 状态管理
-│ ├── hooks/ # 自定义 Hooks
-│ ├── lib/ # 工具库
-│ ├── db/ # IndexedDB 持久化
-│ ├── types/ # TypeScript 类型
-│ └── styles/ # 全局样式
-└── shared/ # 主进程/渲染进程共享
+│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等)
+│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore)
+│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等)
+│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast)
+│ ├── db/ # IndexedDB 持久化(schema · repositories)
+│ ├── types/ # TypeScript 类型定义
+│ └── styles/ # 全局样式(variables · global · markdown-body)
+└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量)
```
+### 编辑器架构
+
+MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心:
+
+- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换
+- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮
+- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片)
+- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage)
+- **主题同步**:应用暗色模式与编辑器主题双向同步
+
## 许可证
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
diff --git a/DESIGN.md b/DESIGN.md
index 4bad232..1a5da47 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,8 +1,8 @@
-# MarkLite v0.3.13 — 架构设计文档
+# MarkLite v0.4.0 — 架构设计文档
## 1. 项目概述
-MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Milkdown v7 WYSIWYG 编辑器 + 源码编辑模式、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
+MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.3 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
### 1.1 核心原则
@@ -20,11 +20,12 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
| 前端框架 | React | v18 | 函数组件 + Hooks |
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
-| 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 |
+| 编辑器 | MetonaEditor | v0.1.3 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
-| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 |
+| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) |
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
+| Toast | @metona-team/metona-toast | v2.0.1 | 通知提示组件 |
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
@@ -50,20 +51,19 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
┌───────────────────────▼──────────────────────────────────────┐
│ Renderer Process (src/renderer/) React 18 │
│ │
-│ components/ (23) Toolbar · TabBar · Editor · EditorToolbar │
-│ SourceEditor · Preview · Sidebar · FileTree│
-│ OutlinePanel · StatusBar · WelcomeScreen │
-│ Toast · ConfirmDialog · ModifiedBanner │
-│ SearchReplace · DropOverlay · ErrorBoundary│
-│ AboutDialog · LoadingSpinner · Icons │
+│ components/ Toolbar · TabBar · Editor · Sidebar │
+│ FileTree · OutlinePanel · WelcomeScreen │
+│ ConfirmDialog · ModifiedBanner │
+│ DropOverlay · ErrorBoundary · AboutDialog│
+│ LoadingSpinner · Icons │
│ │
-│ stores/ (5) tabStore · editorStore · sidebarStore │
-│ statusBarStore · autoSaveStore │
-│ hooks/ (19) useTheme · useSettings · useSettingsInit │
-│ useKeyboard · useDragDrop · useFileWatch │
-│ useAutoSave · useIpcListeners ... │
-│ lib/ (4) markdown · fileUtils · errorHandler │
-│ constants │
+│ stores/ (4) tabStore · editorStore · sidebarStore │
+│ autoSaveStore │
+│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │
+│ useDragDrop · useFileWatch · useAutoSave │
+│ useIpcListeners · useFileOperations ... │
+│ lib/ (4) markdown · fileUtils · errorHandler │
+│ toast · constants │
│ db/ (4) schema · tabRepository · settingsRepo │
│ recentFilesRepository │
│ types/ (5) tab · file · settings · ipc · index │
@@ -97,25 +97,18 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
```
┌─────────────────────────────────────────────────────────┐
│ App.tsx (根组件) │
-├─────────┬──────────┬──────────┬──────────┬──────────────┤
-│Toolbar │ TabBar │ Sidebar │ Editor │ Preview │
-│ │ │ │ (CM6) │ │
-├─────────┴──────────┴──────────┴──────────┴──────────────┤
+├─────────┬──────────┬──────────┬──────────────────────────┤
+│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │
+├─────────┴──────────┴──────────┴──────────────────────────┤
│ Zustand Stores │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
-│ │ tabStore │ │editorStore│ │sidebarStore│ │statusBarStore│ │
-│ │ - tabs │ │- viewMode│ │- tree │ │- items │ │
-│ │- activeId│ │- darkMode│ │- expanded │ │ │ │
+│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │
+│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │
+│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
-│ │ ┌──────────────┐ │ │
-│ │ │autoSaveStore │ │ │
-│ │ │- isAutoSaving│ │ │
-│ │ └──────────────┘ │ │
-│ │ │ │ │
-│ ┌────▼────────────▼────────────▼────────────────────┐ │
│ │ IndexedDB (Dexie.js) │ │
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
│ └───────────────────────────────────────────────────┘ │
@@ -137,7 +130,7 @@ interface TabState {
setModified(tabId, modified) // 设置修改状态
getActiveTab() // 获取当前标签
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
- saveToDB() // 保存到 IndexedDB
+ saveToDB() // 保存到 IndexedDB(防抖 500ms)
loadFromDB() // 从 IndexedDB 加载
}
```
@@ -151,6 +144,10 @@ interface EditorState {
externallyModified: { filePath: string } | null // 外部修改检测状态
loadingStates: Record // 全局加载状态
}
+
+// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例
+function getMetonaEditor(): MarkdownEditor | null
+function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void
```
### 3.4 sidebarStore — 侧边栏状态
@@ -221,39 +218,78 @@ db.version(1).stores({
| `window:confirmClose` | 无 | 请求确认关闭 |
| `sidebar:dirChanged` | 无 | 目录结构变化 |
-## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea)
+## 6. 编辑器架构 — MetonaEditor v0.1.3
-### 6.1 双编辑模式
+### 6.1 三模式视图
-- **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo
-- **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键
+- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
+- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
+- **预览模式 (preview)**:仅显示渲染后的 HTML
-两种模式共享同一 tabStore 数据源,可随时切换。
+MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。
-### 6.1.1 主题切换
+### 6.2 内置功能
-Milkdown 编辑器通过 CSS class(`milkdown-dark`)切换暗色主题,无需销毁重建 EditorView,
-切换标签时保持光标位置和选区。
+| 功能 | 说明 |
+|------|------|
+| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr |
+| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 |
+| 历史栈 | undo / redo,防抖合并,可配置上限 |
+| 主题 | light / dark / auto / warm,CSS 变量驱动 |
+| 国际化 | zh-CN / en-US 完整翻译 |
+| 全屏模式 | 编辑器全屏展示 |
-### 6.2 插件体系
+### 6.3 插件体系
+
+通过 `plugins` 配置数组安装 MetonaEditor 预设插件:
| 插件 | 说明 |
|------|------|
-| commonmark | 基础 Markdown 语法 |
-| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) |
-| history | undo/redo |
-| listener | 内容变更监听 |
-| indent | Tab 缩进 |
-| trailing | 尾随换行 |
-| clipboard | 剪贴板增强 |
-| searchPlugin (自研) | 搜索高亮装饰 |
-| autoPairPlugin (自研) | 自动配对括号/引号 |
+| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
+| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
-### 6.3 滚动与选区持久化
+> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
-切换标签时自动保存/恢复:
-- 滚动位置 (`scrollTop`)
-- 光标选区 (`selectionStart`, `selectionEnd`)
+### 6.4 渲染管线集成
+
+通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现:
+
+- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径
+- **XSS 防护**:rehype-sanitize 过滤危险标签
+- **代码高亮**:rehype-highlight 语法高亮
+- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶
+
+```
+Markdown 源码
+ │
+ ▼
+unified 管线(renderMarkdownSync)
+ ├── remark-parse 解析为 MDAST
+ ├── remark-gfm GFM 扩展
+ ├── remark-rehype 转换为 HAST
+ ├── rehype-raw 解析内联 HTML
+ ├── rehype-sanitize 安全过滤
+ ├── rehype-fixImages 相对路径 → file://
+ ├── rehype-highlight 代码高亮
+ └── rehype-stringify 序列化为 HTML
+ │
+ ▼
+MetonaEditor 预览区渲染
+```
+
+### 6.5 主题切换
+
+MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程:
+
+1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage
+2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`)
+3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题
+
+### 6.6 内容同步
+
+- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified`
+- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange
+- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置
## 7. Markdown 渲染管线
@@ -285,7 +321,7 @@ rehype-highlight 代码语法高亮
rehype-stringify 序列化为 HTML
│
▼
-dangerouslySetInnerHTML 渲染到 DOM
+Renderer (MetonaEditor preview / Preview component)
```
## 8. UI 设计
@@ -318,18 +354,21 @@ dangerouslySetInnerHTML 渲染到 DOM
┌──────────────────────────────────────────────────────────────────────────┐
│ MarkLite - filename.md ─ □ ✕ │
├──────────────────────────────────────────────────────────────────────────┤
-│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 🔗 ℹ️ │
+│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │
├──────────────────────────────────────────────────────────────────────────┤
│ [file1.md] [file2.md] [未命名] [+] │
-├──────────┬─────────────────────────────────────────────────────────────┤
-│ 资源管理器 │ │
-│ ▼ project │ 1 # Title │ Title │
-│ 📁 src │ 2 │ ─────── │
-│ 📄 file1│ 3 content... │ content... │
-│ 📄 file2│ │ │
-├──────────┴─────────────────────────────────────────────────────────────┤
-│ filename.md │ UTF-8 │ Markdown │
-└────────────────────────────────────────────────────────────────────────┘
+├──────────┬───────────────────────────────────────────────────────────────┤
+│ 资源管理器 │ │
+│ ▼ project │ ┌─────────────────────────────────────────────┐ │
+│ 📁 src │ │ B I S U > │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │
+│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │
+│ 📄 file2│ ├─────────────────────────────────────────────┤ │
+│ │ │ # Title │ Title │ │
+│ 文档大纲 │ │ │ ─────── │ │
+│ · Title │ │ content... │ content... │ │
+├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
+│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
+└──────────────────────────────────────────────────────────────────────────┘
```
## 9. 构建与发布
@@ -365,7 +404,8 @@ npm run build:portable # 便携版(免安装)
| zustand | ^5.0 | 状态管理 |
| dexie | ^4.0 | IndexedDB 封装 |
| nanoid | ^5.0 | 唯一 ID 生成 |
-| @milkdown/* | 7.21.1 | WYSIWYG Markdown 编辑器(基于 ProseMirror) |
+| @metona-team/metona-editor | ^0.1.3 | Markdown 编辑器(零依赖) |
+| @metona-team/metona-toast | ^2.0.1 | Toast 通知组件 |
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
| rehype-highlight | ^7.0 | 代码语法高亮 |
diff --git a/DEVSETUP.md b/DEVSETUP.md
index efe3898..f6453ab 100644
--- a/DEVSETUP.md
+++ b/DEVSETUP.md
@@ -1,4 +1,4 @@
-# Electron 项目开发环境完整配置指南
+# MarkLite v0.4.0 — 开发环境配置指南
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
@@ -21,16 +21,23 @@ nvm alias default 18
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
-### 推荐方式:项目级 .npmrc(仅对当前项目生效)
+### 项目级 .npmrc(推荐)
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
```
registry=https://registry.npmmirror.com
+@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
+//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=
+//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
+
+# Electron 二进制下载镜像
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
```
+> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。
+
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
## 三、配置文件说明
@@ -38,6 +45,7 @@ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-
| 配置项 | 作用 |
|:-------|:-----|
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
+| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
@@ -66,6 +74,8 @@ nvm use 18
# ② 在项目根目录创建 .npmrc,写入:
# registry=https://registry.npmmirror.com
+# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
+# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth=
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
@@ -78,5 +88,6 @@ npm run build
## 六、常见问题排查
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
+- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
diff --git a/QUALITY_REVIEW_REPORT.json b/QUALITY_REVIEW_REPORT.json
deleted file mode 100644
index 4ecea1c..0000000
--- a/QUALITY_REVIEW_REPORT.json
+++ /dev/null
@@ -1,317 +0,0 @@
-{
- "review_date": "2026-07-06",
- "reviewer": "CatPaw (苏浅玥) — Follow-up Audit & Fix",
- "project": "MarkLite",
- "overall_score": 96,
- "verdict": "pass",
- "previous_review": {
- "review_date": "2026-06-03",
- "overall_score": 82,
- "verdict": "needs-fix"
- },
- "score": {
- "code_quality": {
- "score": 95,
- "weight": 0.25,
- "details": {
- "typescript": {
- "status": "pass",
- "errors": 0,
- "fixes_applied": [
- "useMilkdown.ts:73 — Replaced state.selection.constructor.create with TextSelection.create from @milkdown/prose/state",
- "useMilkdown.ts:42-56 — Fixed dead-code bug: Backspace handler was unreachable because PAIRS['Backspace'] check returned false first",
- "package.json — Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom dependency plugins as direct deps, eliminating @milkdown/ctx 7.21.1/7.21.2 version duplication",
- "useFolderOperations.ts — openFolderDialog() return type fixed (previously resolved by prior commit)",
- "types/ipc.ts — Duplicate electronAPI declaration removed (previously resolved by prior commit)"
- ]
- },
- "eslint": {
- "status": "pass",
- "errors": 0,
- "warnings": 0,
- "fixes_applied": [
- "OutlinePanel.tsx — Extracted parseHeadings() and Heading interface to outlineUtils.ts to satisfy react-refresh/only-export-components",
- "useStatusBarItems.tsx — Extracted 5 StatusBar component definitions to StatusBarItems.tsx to satisfy react-refresh/only-export-components",
- "tabStore.test.ts:55 — Unused switchToTab variable removed (previously resolved by prior commit)",
- "Editor.tsx / Preview.tsx — react-hooks/exhaustive-deps handled with eslint-disable comments (previously resolved by prior commit)"
- ]
- },
- "npm_warnings": {
- "status": "pass",
- "fixes_applied": [
- "package.json — Added allowScripts config for electron and esbuild postinstall scripts via npm approve-scripts"
- ]
- },
- "tests": {
- "status": "pass",
- "total_files": 6,
- "total_tests": 96,
- "all_passed": true
- }
- }
- },
- "performance_optimization": {
- "score": 95,
- "weight": 0.2,
- "details": {
- "markdown_cache": {
- "status": "pass",
- "implementation": "LRU cache with MAX_CACHE_SIZE=10",
- "features": ["filePath-based cache key", "LRU eviction", "null key handling"]
- },
- "zustand_selectors": {
- "status": "pass",
- "implementation": "Fine-grained selectors throughout",
- "example": "useTabStore(s => s.tabs) instead of full store"
- },
- "codemirror_compartment": {
- "status": "pass",
- "implementation": "Compartment.reconfigure() for theme switching",
- "benefit": "No EditorView reconstruction on theme change"
- },
- "debounce": {
- "status": "pass",
- "implementations": [
- "Preview: 150ms debounce on markdown rendering",
- "TabStore: 500ms debounce on DB persistence"
- ]
- }
- }
- },
- "architecture_refactoring": {
- "score": 92,
- "weight": 0.2,
- "details": {
- "component_split": {
- "status": "pass",
- "extractions": [
- "FileTree extracted from Sidebar",
- "useFolderOperations hook from Sidebar",
- "EditorToolbar from Editor",
- "Toast with SingleToast sub-component",
- "parseHeadings + Heading extracted to outlineUtils.ts",
- "StatusBar item components extracted to StatusBarItems.tsx"
- ]
- },
- "type_safety": {
- "status": "pass",
- "positives": [
- "Shared types properly defined",
- "IpcInvokeMap type mapping implemented",
- "Generic tryAsync pattern with proper typing",
- "ElectronAPI centralized in types/ipc.ts, preload.d.ts imports from it",
- "All @milkdown/* packages pinned to exact versions to prevent type duplication"
- ]
- },
- "error_handling": {
- "status": "pass",
- "implementation": [
- "AppError class with code and cause",
- "logError() for logging",
- "getErrorMessage() for user-facing messages",
- "tryAsync() wrapper returning [result, error] tuple",
- "ErrorBoundary for React component errors"
- ]
- }
- }
- },
- "user_experience": {
- "score": 90,
- "weight": 0.2,
- "details": {
- "confirm_dialog": {
- "status": "pass",
- "features": [
- "Promise-based API via useConfirm hook",
- "Focus management (save/restore)",
- "ESC key handling",
- "Background scroll lock",
- "ARIA: role=alertdialog, aria-modal, aria-labelledby, aria-describedby",
- "Variant support (danger/warning/info)"
- ]
- },
- "loading_states": {
- "status": "pass",
- "features": [
- "LoadingSpinner with size variants (small/medium/large)",
- "Overlay mode support",
- "Global loading state via editorStore.loadingStates",
- "StatusBar integration with contextual labels",
- "ARIA: role=status, aria-label, aria-busy"
- ]
- },
- "toast": {
- "status": "pass",
- "features": [
- "Multi-toast stacking (max 5)",
- "Type differentiation with icons",
- "Auto-dismiss with configurable duration",
- "Enter/exit animations",
- "Close button",
- "ARIA: role=alert, aria-live=assertive"
- ]
- },
- "accessibility": {
- "status": "pass",
- "features": [
- "File tree: role=tree, role=treeitem, aria-expanded, aria-selected",
- "Keyboard navigation: Enter/Space on tree items",
- "Sidebar: aria-label on sections",
- "StatusBar: role=status, aria-live=polite",
- "Preview: aria-live=polite, aria-busy during render",
- "Resize handle: role=separator, aria-orientation, tabIndex"
- ]
- }
- }
- },
- "developer_experience": {
- "score": 92,
- "weight": 0.15,
- "details": {
- "test_framework": {
- "status": "pass",
- "setup": {
- "framework": "Vitest 4.1.8",
- "environment": "jsdom",
- "setup_file": "@testing-library/jest-dom/vitest",
- "coverage_provider": "v8",
- "coverage_targets": ["stores", "lib", "hooks"]
- },
- "test_files": [
- "tabStore.test.ts (22 tests)",
- "editorStore.test.ts (12 tests)",
- "fileUtils.test.ts (22 tests)",
- "markdown.test.ts (16 tests)",
- "errorHandler.test.ts (12 tests)",
- "useDocStats.test.ts (12 tests)"
- ]
- },
- "git_hooks": {
- "status": "pass",
- "setup": {
- "tool": "Husky 9.1.7",
- "pre_commit": "lint-staged",
- "lint_staged": {
- "ts_tsx": ["eslint --fix", "tsc --noEmit --pretty"],
- "json_css_md": ["prettier --write"]
- }
- }
- },
- "documentation": {
- "status": "pass",
- "files": ["CONTRIBUTING.md", "README.md", "DESIGN.md", "DEVSETUP.md"]
- }
- }
- }
- },
- "issues_resolved": [
- {
- "severity": "critical",
- "category": "typescript",
- "file": "src/renderer/components/Editor/useMilkdown.ts",
- "lines": [73],
- "description": "state.selection.constructor.create — Property 'create' does not exist on type 'Function'",
- "fix": "Imported TextSelection from @milkdown/prose/state, replaced with TextSelection.create(tr.doc, from + 1)",
- "status": "fixed"
- },
- {
- "severity": "critical",
- "category": "typescript",
- "file": "src/renderer/components/Editor/useMilkdown.ts",
- "lines": [173, 190, 193, 194],
- "description": "Milkdown plugin type incompatibility: @milkdown/ctx 7.21.2 (top-level) vs 7.21.1 (nested in plugin packages) caused Ctx #private field mismatch",
- "fix": "Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom-dependency plugins as direct deps, reinstalled to eliminate nested @milkdown/ctx copy",
- "status": "fixed"
- },
- {
- "severity": "critical",
- "category": "typescript",
- "file": "src/renderer/hooks/useFolderOperations.ts",
- "lines": [20, 21, 24, 27],
- "description": "openFolderDialog() return type mismatch",
- "fix": "IPC handler changed to return string | null, type definitions aligned (resolved in prior commit)",
- "status": "fixed"
- },
- {
- "severity": "critical",
- "category": "typescript",
- "file": "src/renderer/types/ipc.ts",
- "lines": [54, 58],
- "description": "Duplicate Window.electronAPI declaration",
- "fix": "Removed duplicate from ipc.ts, preload.d.ts is source of truth (resolved in prior commit)",
- "status": "fixed"
- },
- {
- "severity": "error",
- "category": "eslint",
- "file": "src/renderer/stores/__tests__/tabStore.test.ts",
- "lines": [55],
- "description": "'switchToTab' is destructured but never used in test",
- "fix": "Test updated (resolved in prior commit)",
- "status": "fixed"
- },
- {
- "severity": "warning",
- "category": "eslint/react-refresh",
- "file": "src/renderer/components/OutlinePanel/OutlinePanel.tsx",
- "lines": [19],
- "description": "Fast refresh only works when a file only exports components",
- "fix": "Extracted parseHeadings() and Heading interface to outlineUtils.ts",
- "status": "fixed"
- },
- {
- "severity": "warning",
- "category": "eslint/react-refresh",
- "file": "src/renderer/hooks/useStatusBarItems.tsx",
- "lines": [10, 20, 42, 59, 64],
- "description": "5 react-refresh/only-export-components warnings — component definitions mixed with hook export",
- "fix": "Extracted 5 StatusBar item components to StatusBarItems.tsx",
- "status": "fixed"
- },
- {
- "severity": "warning",
- "category": "eslint/react-hooks",
- "file": "src/renderer/components/Editor/Editor.tsx",
- "lines": [43, 55],
- "description": "useEffect hooks have missing dependencies",
- "fix": "Added eslint-disable comments with explanation (resolved in prior commit)",
- "status": "fixed"
- },
- {
- "severity": "warning",
- "category": "eslint/react-hooks",
- "file": "src/renderer/components/Preview/Preview.tsx",
- "lines": [53],
- "description": "useEffect missing 'activeTab' dependency",
- "fix": "Correctly using optional chaining in deps array (resolved in prior commit)",
- "status": "fixed"
- },
- {
- "severity": "warning",
- "category": "npm",
- "file": "package.json",
- "lines": [],
- "description": "npm allow-scripts warning: electron and esbuild postinstall scripts not covered",
- "fix": "Added allowScripts config via npm approve-scripts electron esbuild",
- "status": "fixed"
- }
- ],
- "strengths": [
- "Excellent LRU cache implementation for Markdown processor with proper eviction strategy",
- "CodeMirror Compartment pattern for efficient theme switching without EditorView reconstruction",
- "Comprehensive Zustand selector usage preventing unnecessary re-renders",
- "Well-designed ConfirmDialog with full accessibility support (focus trap, ESC, ARIA)",
- "LoadingSpinner component with multiple sizes, overlay mode, and proper ARIA attributes",
- "Toast system supports stacking, types, animations, and accessibility",
- "Unified error handling with AppError, logError, getErrorMessage, tryAsync patterns",
- "Good component extraction (FileTree, EditorToolbar, useFolderOperations, outlineUtils, StatusBarItems)",
- "96 unit tests covering core modules with proper mocking",
- "Debounce implementation for both preview rendering and DB persistence",
- "Git hooks with lint-staged for automated code quality checks",
- "Comprehensive documentation (CONTRIBUTING.md, DESIGN.md, README.md)",
- "All @milkdown/* packages pinned to exact versions preventing type duplication",
- "Type-safe IPC architecture with centralized ElectronAPI definition",
- "npm allow-scripts configured for secure postinstall script management"
- ],
- "summary": "MarkLite has been fully audited and all issues resolved. TypeScript compilation passes with 0 errors, ESLint passes with 0 errors and 0 warnings, and all 96 tests pass. Key fixes include: (1) @milkdown/ctx version deduplication by pinning all Milkdown packages to exact 7.21.1, (2) TextSelection.create type fix in useMilkdown.ts, (3) Backspace auto-pair dead-code bug fix, (4) react-refresh warnings resolved by extracting utilities and components to separate files, (5) npm allow-scripts configuration for electron and esbuild. The project is production-ready."
-}
diff --git a/README.md b/README.md
index c64f48a..884dd49 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -30,18 +30,21 @@
|------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
-| ✏️ **双编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)|
-| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
+| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 |
+| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
-| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
-| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
+| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
+| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB),编辑器主题同步切换 |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
-| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 |
+| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
+| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
+| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 |
+| 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 |
## 🚀 快速开始
@@ -116,8 +119,6 @@ npm run test:coverage
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
- `hooks/` — 自定义 Hooks
-测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。
-
## ⌨️ 快捷键
| 快捷键 | 功能 |
@@ -129,15 +130,11 @@ npm run test:coverage
| `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 |
-| `Ctrl + 1` | 编辑模式 |
-| `Ctrl + 2` | 预览模式 |
-| `Ctrl + 3` | 源码模式 |
| `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 |
-| `Ctrl + B` | 粗体 |
-| `Ctrl + I` | 斜体 |
-| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
-| `Esc` | 关闭搜索栏 |
+| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
+| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
+| `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
## 🛠️ 技术栈
@@ -146,11 +143,12 @@ npm run test:coverage
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
-| 编辑器 | [Milkdown](https://milkdown.dev/) v7 | WYSIWYG 编辑器 + 源码模式 |
+| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
+| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v2.0.1 | 通知提示组件 |
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
@@ -164,7 +162,7 @@ MarkLite/
├── tsconfig.node.json # Node 端 TypeScript 配置
├── electron.vite.config.ts # electron-vite 构建配置
├── vitest.config.ts # Vitest 测试框架配置
-├── .eslintrc.cjs # ESLint + TypeScript 规则
+├── eslint.config.mjs # ESLint + TypeScript 规则
├── CONTRIBUTING.md # 贡献指南
│
├── src/
@@ -183,19 +181,14 @@ MarkLite/
│ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │
-│ │ ├── components/ # UI 组件 (23个)
-│ │ │ ├── Toolbar/ # 工具栏
-│ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序)
-│ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏
-│ │ │ ├── Preview/ # Markdown 预览面板
-│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea)
-│ │ │ ├── Sidebar/ # 侧边栏文件树
+│ │ ├── components/ # UI 组件
+│ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于)
+│ │ │ ├── TabBar/ # 标签页栏(含拖拽排序、右键菜单)
+│ │ │ ├── Editor/ # MetonaEditor 编辑器集成
+│ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲
│ │ │ ├── FileTree/ # 递归文件树
-│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮)
-│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关)
-│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则)
-│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
-│ │ │ ├── Toast/ # Toast 通知
+│ │ │ ├── OutlinePanel/ # 文档大纲(活跃标题高亮)
+│ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件)
│ │ │ ├── ConfirmDialog/ # 确认对话框
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
@@ -207,25 +200,27 @@ MarkLite/
│ │ ├── stores/ # Zustand 状态管理
│ │ │ ├── tabStore.ts # 标签页状态
│ │ │ ├── editorStore.ts # 编辑器状态
-│ │ │ └── sidebarStore.ts # 侧边栏状态
+│ │ │ ├── sidebarStore.ts # 侧边栏状态
+│ │ │ └── autoSaveStore.ts # 自动保存状态
│ │ │
-│ │ ├── hooks/ # 自定义 Hooks (19个)
+│ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useTheme.ts # 暗色/亮色主题
-│ │ │ ├── useSettings.ts # 视图模式
│ │ │ ├── useSettingsInit.ts # 设置初始加载
│ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
-│ │ │ ├── useAutoSave.ts # 自动保存
+│ │ │ ├── useAutoSave.ts # 自动保存(Electron IPC)
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
+│ │ │ ├── useDocStats.ts # 文档统计
│ │ │ └── ... # 文件操作、IPC监听等
│ │ │
│ │ ├── lib/ # 工具库
-│ │ │ ├── markdown.ts # Markdown 渲染管线
+│ │ │ ├── markdown.ts # Markdown 渲染管线(unified/rehype)
│ │ │ ├── fileUtils.ts # 文件工具函数
│ │ │ ├── errorHandler.ts # 错误处理
+│ │ │ ├── toast.ts # Toast 通知(MetonaToast)
│ │ │ └── constants.ts # 常量定义
│ │ │
│ │ ├── db/ # IndexedDB 持久化层
@@ -239,7 +234,8 @@ MarkLite/
│ │
│ └── shared/ # 主进程/渲染进程共享
│ ├── ipc-channels.ts # IPC 通道名常量
-│ └── types.ts # 共享类型定义
+│ ├── types.ts # 共享类型定义
+│ └── constants.ts # 共享常量(版本号等)
│
├── assets/
│ └── icon.ico # 应用图标
@@ -253,14 +249,16 @@ MarkLite/
- ✅ 标题(h1 ~ h6)
- ✅ **粗体** / *斜体* / ~~删除线~~
+- ✅ 高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
- ✅ 有序列表 / 无序列表
- ✅ 任务列表 `- [x]`
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
- ✅ 行内代码
-- ✅ 链接 / 图片(支持相对路径)
-- ✅ 表格
+- ✅ 链接 / 图片(支持相对路径、粘贴图片)
+- ✅ 表格(含列对齐)
- ✅ 引用块
- ✅ 水平线
+- ✅ Emoji 短码 `:smile:` `:rocket:`
- ✅ HTML 内联元素
- ✅ GFM(GitHub Flavored Markdown)
diff --git a/docs/optimization-architecture.json b/docs/optimization-architecture.json
deleted file mode 100644
index 968d76a..0000000
--- a/docs/optimization-architecture.json
+++ /dev/null
@@ -1,973 +0,0 @@
-{
- "planVersion": "1.0.0",
- "generatedDate": "2026-06-03",
- "project": "MarkLite",
- "basedOnRequirements": "docs/optimization-requirements.json",
-
- "optimization_phases": [
- {
- "phase": 1,
- "name": "基础设施与代码规范",
- "priority": "P0-CRITICAL",
- "estimatedEffort": "1-2 天",
- "description": "建立代码质量基线:ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复。这些是后续所有优化的前提。",
- "items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
- "rationale": "CQ-01 (ESLint) 是所有代码质量改进的基础;AR-05 (ErrorBoundary) 保护后续重构不会导致白屏;DX-07 (路径别名) 修复后简化后续 import。",
- "exitCriteria": [
- "npm run lint 通过且返回 0",
- "npm run format 能格式化全部源文件",
- "ErrorBoundary 包裹 App 组件",
- "TypeScript 路径别名在 vite config 中完整映射"
- ]
- },
- {
- "phase": 2,
- "name": "关键性能修复",
- "priority": "P0-CRITICAL",
- "estimatedEffort": "1-2 天",
- "description": "修复影响日常使用的核心性能瓶颈:Markdown 处理器缓存、Zustand 选择器重渲染、CodeMirror Compartment 主题切换、Preview 防抖、IndexedDB 去抖。",
- "items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
- "rationale": "PF-01 和 PF-02 是 P0-CRITICAL 级性能问题,直接影响用户感知;PF-04 的 Compartment 重构是 PF-03 (React.memo) 的前置;PF-07/08 是低风险高收益的防抖优化。",
- "exitCriteria": [
- "Markdown processor 实例复用率 > 90%",
- "编辑时仅 activeTab 相关组件重渲染(React DevTools Profiler 验证)",
- "darkMode 切换不销毁 EditorView",
- "Preview 渲染频率 < 10 次/秒(快速输入场景)",
- "IndexedDB 写入频率 < 3 次/秒(连续编辑场景)"
- ]
- },
- {
- "phase": 3,
- "name": "架构重构与代码质量",
- "priority": "P1-HIGH",
- "estimatedEffort": "2-3 天",
- "description": "组件拆分、TypeScript 类型完善、错误处理统一、消除架构反模式(DOM CustomEvent、重复设置加载)。",
- "items": ["AR-01", "AR-02", "AR-03", "AR-04", "CQ-04", "CQ-05", "PF-03"],
- "rationale": "组件拆分 (AR-01/02) 和 React.memo (PF-03) 依赖 Phase 1 的 ESLint 和 Phase 2 的 Zustand 优化;类型完善 (CQ-04) 和错误处理 (CQ-05) 可与架构重构并行。",
- "exitCriteria": [
- "App.tsx < 150 行",
- "Sidebar.tsx < 120 行",
- "所有函数参数有显式类型标注",
- "无空 catch 块,所有错误有具体描述",
- "设置加载只触发一次 IndexedDB 读取"
- ]
- },
- {
- "phase": 4,
- "name": "用户体验改进",
- "priority": "P1-HIGH",
- "estimatedEffort": "2-3 天",
- "description": "替换原生 confirm()、添加加载状态指示、改进 Toast 通知系统、增强可访问性。",
- "items": ["UX-01", "UX-02", "UX-05", "UX-07"],
- "rationale": "UX 改进依赖 Phase 3 的组件拆分(新的 ConfirmDialog、Toast 组件需要独立目录)和 ErrorBoundary(统一错误处理模式)。",
- "exitCriteria": [
- "零处原生 confirm() 调用",
- "文件打开、目录加载、Markdown 渲染有 loading 状态",
- "Toast 支持多条堆叠 + 类型区分 (success/error/warning)",
- "所有交互元素有 ARIA 属性"
- ]
- },
- {
- "phase": 5,
- "name": "开发体验与测试",
- "priority": "P2-MEDIUM",
- "estimatedEffort": "2-3 天",
- "description": "建立单元测试框架、强化类型安全、添加项目文档和 Git hooks。",
- "items": ["DX-01", "DX-02", "DX-04", "DX-05"],
- "rationale": "测试框架应在代码稳定后添加(Phase 1-4 的重构会导致测试频繁失效);DX-02 (preload 类型安全) 依赖 CQ-04 (类型完善)。",
- "exitCriteria": [
- "Vitest + React Testing Library 可运行",
- "核心模块测试覆盖 > 60%",
- "README.md 包含完整开发指南",
- "pre-commit 钩子执行 ESLint + typecheck"
- ]
- },
- {
- "phase": 6,
- "name": "高级功能与安全加固",
- "priority": "P2-P3",
- "estimatedEffort": "3-5 天",
- "description": "代码分割、分屏模式、拖拽排序、文件搜索、安全加固 (CSP/file:// 协议)、CI/CD 配置。",
- "items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
- "rationale": "这些是「锦上添花」的优化,不阻塞核心功能。代码分割 (PF-06) 和分屏模式 (UX-03) 是大规模功能,需要在架构稳定后实施。",
- "exitCriteria": [
- "renderer bundle 拆分为 3+ 个 chunk",
- "分屏模式可正常编辑+预览",
- "CSP header 配置且预览正常渲染",
- "GitHub Actions CI 流水线 lint → typecheck → test → build"
- ]
- }
- ],
-
- "file_manifest": {
- "new_files": [
- {
- "path": "eslint.config.js",
- "phase": 1,
- "item": "CQ-01",
- "description": "ESLint v9 flat config 格式配置文件",
- "size_estimate": "small"
- },
- {
- "path": ".prettierrc",
- "phase": 1,
- "item": "CQ-02",
- "description": "Prettier 代码格式化配置",
- "size_estimate": "trivial"
- },
- {
- "path": ".prettierignore",
- "phase": 1,
- "item": "CQ-02",
- "description": "Prettier 忽略文件列表",
- "size_estimate": "trivial"
- },
- {
- "path": ".editorconfig",
- "phase": 1,
- "item": "CQ-03",
- "description": "编辑器统一行为配置",
- "size_estimate": "trivial"
- },
- {
- "path": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
- "phase": 1,
- "item": "AR-05",
- "description": "React ErrorBoundary 组件,显示错误信息和重启按钮",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/ErrorBoundary/index.ts",
- "phase": 1,
- "item": "AR-05",
- "description": "ErrorBoundary barrel export",
- "size_estimate": "trivial"
- },
- {
- "path": "src/renderer/components/AboutDialog/AboutDialog.tsx",
- "phase": 3,
- "item": "AR-01",
- "description": "从 App.tsx 提取的 About 对话框组件",
- "size_estimate": "medium"
- },
- {
- "path": "src/renderer/components/AboutDialog/index.ts",
- "phase": 3,
- "item": "AR-01",
- "description": "AboutDialog barrel export",
- "size_estimate": "trivial"
- },
- {
- "path": "src/renderer/hooks/useFileOperations.ts",
- "phase": 3,
- "item": "AR-01",
- "description": "从 App.tsx 提取的文件操作 hook(open/save/saveAs/openRecent)",
- "size_estimate": "medium"
- },
- {
- "path": "src/renderer/components/Sidebar/FileTree.tsx",
- "phase": 3,
- "item": "AR-02",
- "description": "从 Sidebar.tsx 提取的递归文件树组件",
- "size_estimate": "medium"
- },
- {
- "path": "src/renderer/hooks/useSidebarResize.ts",
- "phase": 3,
- "item": "AR-02",
- "description": "从 Sidebar.tsx 提取的 resize 逻辑 hook",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/ConfirmDialog/ConfirmDialog.tsx",
- "phase": 4,
- "item": "UX-01",
- "description": "自定义确认对话框组件,替代原生 confirm()",
- "size_estimate": "medium"
- },
- {
- "path": "src/renderer/components/ConfirmDialog/index.ts",
- "phase": 4,
- "item": "UX-01",
- "description": "ConfirmDialog barrel export",
- "size_estimate": "trivial"
- },
- {
- "path": "src/renderer/hooks/useConfirm.ts",
- "phase": 4,
- "item": "UX-01",
- "description": "确认对话框状态管理 hook",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/LoadingSpinner/LoadingSpinner.tsx",
- "phase": 4,
- "item": "UX-02",
- "description": "通用加载指示器组件",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/icons/toolbar-icons.tsx",
- "phase": 3,
- "item": "AR-06",
- "description": "工具栏相关 SVG 图标组件",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/icons/sidebar-icons.tsx",
- "phase": 3,
- "item": "AR-06",
- "description": "侧边栏相关 SVG 图标组件",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/icons/app-icons.tsx",
- "phase": 3,
- "item": "AR-06",
- "description": "应用级 SVG 图标组件",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/components/icons/index.ts",
- "phase": 3,
- "item": "AR-06",
- "description": "Icons barrel export(保持向后兼容)",
- "size_estimate": "trivial"
- },
- {
- "path": "src/renderer/lib/markdown-processor.ts",
- "phase": 2,
- "item": "PF-01",
- "description": "Markdown processor 缓存管理器",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/hooks/useSettingsInit.ts",
- "phase": 3,
- "item": "AR-03",
- "description": "统一设置加载 hook,一次 IndexedDB 读取分发到 store",
- "size_estimate": "small"
- },
- {
- "path": "vitest.config.ts",
- "phase": 5,
- "item": "DX-01",
- "description": "Vitest 测试框架配置",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/stores/__tests__/tabStore.test.ts",
- "phase": 5,
- "item": "DX-01",
- "description": "tabStore 单元测试",
- "size_estimate": "medium"
- },
- {
- "path": "src/renderer/lib/__tests__/fileUtils.test.ts",
- "phase": 5,
- "item": "DX-01",
- "description": "fileUtils 单元测试",
- "size_estimate": "small"
- },
- {
- "path": "src/renderer/lib/__tests__/markdown.test.ts",
- "phase": 5,
- "item": "DX-01",
- "description": "Markdown 渲染单元测试",
- "size_estimate": "medium"
- },
- {
- "path": "README.md",
- "phase": 5,
- "item": "DX-04",
- "description": "项目介绍、安装、开发、构建说明",
- "size_estimate": "medium"
- },
- {
- "path": "CONTRIBUTING.md",
- "phase": 5,
- "item": "DX-04",
- "description": "贡献指南、代码规范、PR 流程",
- "size_estimate": "small"
- },
- {
- "path": ".github/workflows/ci.yml",
- "phase": 6,
- "item": "DX-06",
- "description": "GitHub Actions CI 配置",
- "size_estimate": "small"
- }
- ],
- "modified_files": [
- {
- "path": "package.json",
- "phase": [1, 2, 5],
- "item": ["CQ-02", "DX-05", "DX-01"],
- "changes": [
- "添加 prettier、eslint-config-prettier 依赖",
- "添加 vitest、@testing-library/react、jsdom 依赖",
- "添加 husky、lint-staged 依赖",
- "添加 test、test:watch、test:coverage、format 脚本"
- ]
- },
- {
- "path": "electron.vite.config.ts",
- "phase": [1, 6],
- "item": ["DX-07", "PF-06"],
- "changes": [
- "renderer.resolve.alias 添加 @main 映射",
- "renderer.build.rollupOptions.output 添加 manualChunks 分割策略"
- ]
- },
- {
- "path": "src/renderer/main.tsx",
- "phase": 1,
- "item": "AR-05",
- "changes": [
- "用 ErrorBoundary 包裹 App 组件"
- ]
- },
- {
- "path": "src/renderer/App.tsx",
- "phase": [1, 3],
- "item": ["AR-01", "AR-05", "CQ-05"],
- "changes": [
- "提取 About 对话框到 AboutDialog 组件",
- "提取文件操作逻辑到 useFileOperations hook",
- "统一错误处理模式"
- ]
- },
- {
- "path": "src/renderer/lib/markdown.ts",
- "phase": 2,
- "item": "PF-01",
- "changes": [
- "引入 markdown-processor.ts 缓存管理器",
- "renderMarkdown 改为使用缓存的 processor 实例",
- "添加 processor 缓存失效策略(仅 filePath 变化时重建)"
- ]
- },
- {
- "path": "src/renderer/stores/tabStore.ts",
- "phase": [2, 3],
- "item": ["PF-02", "PF-08", "AR-07"],
- "changes": [
- "添加 getActiveTab() 选择器方法(返回 activeTab 对象引用)",
- "添加 getActiveTabId() 选择器方法",
- "将 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)",
- "统一异步保存模式为 Zustand subscribeWithSelector middleware"
- ]
- },
- {
- "path": "src/renderer/components/Editor/Editor.tsx",
- "phase": 2,
- "item": "PF-02",
- "changes": [
- "替换 const tabs = useTabStore(s => s.tabs) 为 const activeTab = useTabStore(s => s.getActiveTab())",
- "消除 tabs.find() 调用"
- ]
- },
- {
- "path": "src/renderer/components/Preview/Preview.tsx",
- "phase": [2],
- "item": ["PF-02", "PF-07"],
- "changes": [
- "替换 tabs 订阅为 activeTab 选择器",
- "添加 150ms 防抖(useDebounce hook)",
- "使用新的 markdown processor 缓存"
- ]
- },
- {
- "path": "src/renderer/components/StatusBar/StatusBar.tsx",
- "phase": 2,
- "item": "PF-02",
- "changes": [
- "替换 tabs 订阅为 activeTab 选择器"
- ]
- },
- {
- "path": "src/renderer/components/Sidebar/Sidebar.tsx",
- "phase": [2, 3],
- "item": ["PF-02", "AR-02"],
- "changes": [
- "替换 tabs 订阅为 activeTab 选择器",
- "提取 FileTree 组件到独立文件",
- "提取 resize 逻辑到 useSidebarResize hook"
- ]
- },
- {
- "path": "src/renderer/components/Editor/useCodeMirror.ts",
- "phase": 2,
- "item": "PF-04",
- "changes": [
- "引入 Compartment API(@codemirror/state)",
- "theme 扩展放入 Compartment",
- "darkMode 变化时通过 compartment.reconfigure() 切换主题",
- "移除 EditorView 销毁重建逻辑"
- ]
- },
- {
- "path": "src/renderer/components/Toolbar/Toolbar.tsx",
- "phase": 3,
- "item": "PF-03",
- "changes": [
- "用 React.memo 包装组件导出"
- ]
- },
- {
- "path": "src/renderer/components/TabBar/TabBar.tsx",
- "phase": [3, 4],
- "item": ["PF-03", "UX-01"],
- "changes": [
- "用 React.memo 包装组件导出",
- "替换 4 处 confirm() 为自定义 ConfirmDialog"
- ]
- },
- {
- "path": "src/renderer/hooks/useUnsavedWarning.ts",
- "phase": 4,
- "item": "UX-01",
- "changes": [
- "替换 confirm() 为 electron.dialog.showMessageBox 或自定义 ConfirmDialog"
- ]
- },
- {
- "path": "src/renderer/hooks/useTheme.ts",
- "phase": 3,
- "item": "AR-03",
- "changes": [
- "移除独立的 settingsRepository.load() 调用",
- "改为从统一的 useSettingsInit hook 获取设置"
- ]
- },
- {
- "path": "src/renderer/hooks/useSettings.ts",
- "phase": 3,
- "item": "AR-03",
- "changes": [
- "移除独立的 settingsRepository.load() 调用",
- "改为从统一的 useSettingsInit hook 获取设置"
- ]
- },
- {
- "path": "src/renderer/hooks/useFileWatch.ts",
- "phase": 3,
- "item": "AR-04",
- "changes": [
- "移除 window.dispatchEvent(CustomEvent) 调用",
- "改为调用 Zustand store action 更新文件状态"
- ]
- },
- {
- "path": "src/renderer/components/Icons.tsx",
- "phase": 3,
- "item": "AR-06",
- "changes": [
- "拆分为 icons/toolbar-icons.tsx、icons/sidebar-icons.tsx、icons/app-icons.tsx",
- "原文件改为 re-export barrel 文件(向后兼容)"
- ]
- },
- {
- "path": "src/renderer/components/Toast/Toast.tsx",
- "phase": 4,
- "item": "UX-05",
- "changes": [
- "支持多条堆叠 Toast",
- "添加 success/error/warning 类型",
- "添加手动关闭按钮"
- ]
- },
- {
- "path": "src/renderer/styles/global.css",
- "phase": 4,
- "item": ["UX-01", "UX-02", "UX-05"],
- "changes": [
- "添加 ConfirmDialog 样式",
- "添加 LoadingSpinner 样式",
- "添加 Toast 堆叠和类型样式"
- ]
- },
- {
- "path": "src/preload/index.ts",
- "phase": 5,
- "item": "DX-02",
- "changes": [
- "添加 ElectronAPI 类型约束: const api: ElectronAPI = { ... }",
- "确保 contextBridge.exposeInMainWorld 返回值类型匹配"
- ]
- },
- {
- "path": "src/renderer/types/settings.ts",
- "phase": 6,
- "item": "UX-03",
- "changes": [
- "ViewMode 类型扩展为 'editor' | 'preview' | 'split'"
- ]
- },
- {
- "path": "src/renderer/index.html",
- "phase": 6,
- "item": "SE-01",
- "changes": [
- "添加 CSP meta tag 或在 main process BrowserWindow 中配置 CSP header"
- ]
- },
- {
- "path": "src/main/window-manager.ts",
- "phase": 6,
- "item": "SE-01",
- "changes": [
- "在 BrowserWindow webPreferences 中配置 CSP header",
- "确认 nodeIntegration=false, contextIsolation=true, sandbox=true"
- ]
- },
- {
- "path": "src/renderer/lib/markdown.ts",
- "phase": 6,
- "item": "SE-02",
- "changes": [
- "rehypeFixImages 中的路径校验从简单 '..' 检查改为 path.resolve + allowlist 检查"
- ]
- }
- ],
- "deleted_files": []
- },
-
- "technical_solutions": {
- "CQ-01_eslint_config": {
- "id": "CQ-01",
- "title": "ESLint v9 Flat Config",
- "solution": "创建 eslint.config.js,使用 ESM flat config 格式。",
- "implementation": {
- "file": "eslint.config.js",
- "format": "ESM (export default [...])",
- "config_content": {
- "imports": [
- "eslint-plugin-react-hooks",
- "eslint-plugin-react-refresh",
- "@typescript-eslint/eslint-plugin"
- ],
- "ignores": ["dist/**", "node_modules/**", "out/**"],
- "languageOptions": {
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module",
- "ecmaFeatures": { "jsx": true }
- }
- },
- "rules": {
- "@typescript-eslint/no-unused-vars": "error",
- "no-console": "warn",
- "prefer-const": "error",
- "react-hooks/rules-of-hooks": "error",
- "react-hooks/exhaustive-deps": "warn",
- "react-refresh/only-export-components": ["warn", { "allowConstantExport": true }]
- }
- },
- "notes": "ESLint v9 不再支持 --ext 参数,flat config 中通过 files glob 控制。需更新 package.json 的 lint 脚本。"
- },
- "dependencies": [],
- "risk": "LOW"
- },
-
- "PF-01_markdown_cache": {
- "id": "PF-01",
- "title": "Markdown Processor 缓存",
- "solution": "将 unified processor 实例提取到独立模块,使用 Map 按 filePath 缓存。",
- "implementation": {
- "new_file": "src/renderer/lib/markdown-processor.ts",
- "approach": "Module-level singleton cache",
- "cache_strategy": {
- "key": "filePath (string)",
- "value": "Processor instance (unified processor with all 7 plugins)",
- "invalidation": "filePath 变化时创建新 processor,旧实例保留在 cache 中供回退",
- "max_cache_size": 10,
- "eviction": "LRU - 超过 max_cache_size 时移除最久未使用的"
- },
- "code_sketch": "const processorCache = new Map();\nexport function getProcessor(filePath: string): Processor {\n if (processorCache.has(filePath)) return processorCache.get(filePath)!;\n const p = unified().use(remarkParse)...;\n processorCache.set(filePath, p);\n return p;\n}"
- },
- "dependencies": [],
- "risk": "LOW - unified processor 是无状态的,缓存安全"
- },
-
- "PF-02_zustand_selectors": {
- "id": "PF-02",
- "title": "Zustand 选择器优化",
- "solution": "在 tabStore 中添加 getActiveTab 派生选择器,各组件只订阅 activeTab 引用。",
- "implementation": {
- "approach": "在 store 中添加 getter 方法 + useShallow",
- "store_changes": {
- "new_methods": [
- "getActiveTab(): Tab | undefined — 从 state.tabs 和 state.activeTabId 派生",
- "getActiveTabId(): string | null — 直接返回 state.activeTabId"
- ],
- "notes": "Zustand 5 中 useShallow 来自 'zustand/react/shallow'"
- },
- "component_changes": {
- "before": "const tabs = useTabStore(s => s.tabs);\nconst activeTab = tabs.find(t => t.id === activeTabId);",
- "after": "const activeTab = useTabStore(s => s.tabs.find(t => t.id === s.activeTabId));",
- "note": "直接在选择器中 find,当 activeTab 的引用未变时不会触发重渲染。或使用 useShallow + getActiveTab 方法。"
- },
- "affected_files": [
- "src/renderer/components/Editor/Editor.tsx",
- "src/renderer/components/Preview/Preview.tsx",
- "src/renderer/components/StatusBar/StatusBar.tsx",
- "src/renderer/components/Sidebar/Sidebar.tsx"
- ]
- },
- "dependencies": ["PF-01"],
- "risk": "MEDIUM - 需验证所有订阅点,遗漏会导致功能异常"
- },
-
- "PF-04_codemirror_compartment": {
- "id": "PF-04",
- "title": "CodeMirror Compartment 主题切换",
- "solution": "使用 @codemirror/state 的 Compartment API 封装主题扩展,darkMode 变化时 reconfigure 而非销毁重建。",
- "implementation": {
- "file": "src/renderer/components/Editor/useCodeMirror.ts",
- "approach": "Compartment + reconfigure",
- "code_sketch": "import { Compartment } from '@codemirror/state';\nconst themeCompartment = new Compartment();\n\n// 创建时:\nconst extensions = [\n // ... other extensions\n themeCompartment.of(darkMode ? oneDark : [])\n];\n\n// darkMode 变化时:\nuseEffect(() => {\n if (editorView) {\n editorView.dispatch({\n effects: themeCompartment.reconfigure(darkMode ? oneDark : [])\n });\n }\n}, [darkMode]);",
- "key_changes": [
- "移除 useEffect 中 EditorView 销毁重建逻辑",
- "theme 扩展改为 Compartment.of() 包装",
- "darkMode 变化改为 dispatch reconfigure effect",
- "保留其他扩展不变(keymap、language 等)"
- ]
- },
- "dependencies": [],
- "risk": "HIGH - 影响编辑器核心初始化流程,需仔细测试切换前后编辑器状态保留"
- },
-
- "PF-07_preview_debounce": {
- "id": "PF-07",
- "title": "Preview 渲染防抖",
- "solution": "创建 useDebounce hook,在 Preview.tsx 中对 content 变化进行 150ms 防抖。",
- "implementation": {
- "new_file": "src/renderer/hooks/useDebounce.ts",
- "approach": "标准 useDebounce hook + useEffect",
- "code_sketch": "function useDebounce(value: T, delay: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedValue(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debouncedValue;\n}",
- "usage_in_preview": "const content = activeTab?.content ?? '';\nconst debouncedContent = useDebounce(content, 150);\nuseEffect(() => {\n // renderMarkdown(debouncedContent)\n}, [debouncedContent]);"
- },
- "dependencies": ["PF-01", "PF-02"],
- "risk": "LOW"
- },
-
- "PF-08_indexeddb_debounce": {
- "id": "PF-08",
- "title": "IndexedDB 保存去抖",
- "solution": "将 tabStore 中的 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)。",
- "implementation": {
- "file": "src/renderer/stores/tabStore.ts",
- "approach": "引入 debounce 工具函数",
- "code_sketch": "// utils/debounce.ts\nexport function debounce any>(fn: T, ms: number): T {\n let timer: ReturnType;\n return ((...args: any[]) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n }) as T;\n}\n\n// tabStore.ts\nconst debouncedSaveToDB = debounce(() => saveToDB(), 500);\nconst debouncedSaveActiveTab = debounce((id: string) => tabRepository.saveActiveTabId(id), 300);",
- "affected_patterns": [
- "setTimeout(() => saveToDB(), 0) → debouncedSaveToDB()",
- "setTimeout(() => tabRepository.saveActiveTabId(activeTabId), 0) → debouncedSaveActiveTab(activeTabId)"
- ]
- },
- "dependencies": [],
- "risk": "LOW - 去抖延迟可配置,用户关闭窗口前需 flush"
- },
-
- "AR-05_error_boundary": {
- "id": "AR-05",
- "title": "React ErrorBoundary",
- "solution": "创建 class component ErrorBoundary,捕获渲染错误,显示友好错误页面。",
- "implementation": {
- "file": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
- "features": [
- "componentDidCatch + getDerivedStateFromError",
- "显示错误信息、错误栈(开发模式)",
- "「重新加载」按钮调用 location.reload() 或 window.electronAPI 重启",
- "「复制错误信息」按钮",
- "支持 fallback 自定义 UI"
- ],
- "integration": "在 main.tsx 中 "
- },
- "dependencies": [],
- "risk": "LOW"
- },
-
- "AR-01_app_split": {
- "id": "AR-01",
- "title": "App.tsx 拆分",
- "solution": "提取 AboutDialog 组件和 useFileOperations hook。",
- "implementation": {
- "extractions": [
- {
- "name": "AboutDialog",
- "source": "App.tsx 内联 JSX (~40 行)",
- "target": "src/renderer/components/AboutDialog/AboutDialog.tsx",
- "props": "{ visible: boolean; onClose: () => void; version: string }"
- },
- {
- "name": "useFileOperations",
- "source": "App.tsx 中的 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 函数",
- "target": "src/renderer/hooks/useFileOperations.ts",
- "returns": "{ handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }"
- }
- ],
- "expected_result": "App.tsx 从 ~307 行减少到 ~120 行"
- },
- "dependencies": ["CQ-01", "AR-05"],
- "risk": "LOW - 提取式重构,行为不变"
- },
-
- "AR-03_unified_settings": {
- "id": "AR-03",
- "title": "统一设置加载",
- "solution": "创建 useSettingsInit hook,一次性从 IndexedDB 加载所有设置,分发到各 store。",
- "implementation": {
- "new_file": "src/renderer/hooks/useSettingsInit.ts",
- "approach": "单次加载 + Zustand setState 分发",
- "code_sketch": "export function useSettingsInit() {\n const [loaded, setLoaded] = useState(false);\n useEffect(() => {\n settingsRepository.load().then(settings => {\n useThemeStore.getState().applySettings(settings);\n useEditorStore.getState().applySettings(settings);\n setLoaded(true);\n });\n }, []);\n return loaded;\n}",
- "affected_files": [
- "src/renderer/hooks/useTheme.ts — 移除独立 load()",
- "src/renderer/hooks/useSettings.ts — 移除独立 load()"
- ]
- },
- "dependencies": [],
- "risk": "MEDIUM - 设置加载时序变化可能影响首次渲染"
- },
-
- "UX-01_confirm_dialog": {
- "id": "UX-01",
- "title": "自定义确认对话框",
- "solution": "创建 ConfirmDialog 组件 + useConfirm hook,替代原生 confirm()。",
- "implementation": {
- "approach": "Promise-based confirm API",
- "code_sketch": "// useConfirm.ts\nexport function useConfirm() {\n const [state, setState] = useState<{ message: string; resolve: (v: boolean) => void } | null>(null);\n const confirm = (message: string) => new Promise(resolve => setState({ message, resolve }));\n const handleConfirm = () => { state?.resolve(true); setState(null); };\n const handleCancel = () => { state?.resolve(false); setState(null); };\n return { confirm, dialog: state ? : null };\n}",
- "affected_locations": [
- "TabBar.tsx — 4 处 confirm() 调用",
- "useUnsavedWarning.ts — 1 处 confirm() 调用"
- ]
- },
- "dependencies": ["AR-01"],
- "risk": "MEDIUM - Promise-based API 与原生 confirm() 的同步行为不同,需确保调用方正确 await"
- },
-
- "UX-03_split_mode": {
- "id": "UX-03",
- "title": "分屏模式",
- "solution": "扩展 ViewMode 为 'split',布局组件根据 mode 同时渲染 Editor 和 Preview。",
- "implementation": {
- "approach": "CSS Grid/ResizeObserver 双栏布局",
- "key_changes": [
- "types/settings.ts: ViewMode = 'editor' | 'preview' | 'split'",
- "App.tsx 或新 Layout 组件: 根据 viewMode 条件渲染",
- "split 模式: 左 50% Editor + 右 50% Preview,可拖拽调整比例",
- "添加 Ctrl+3 快捷键切换到 split 模式",
- "可选:编辑器滚动同步预览滚动位置"
- ]
- },
- "dependencies": ["PF-01", "PF-02", "PF-04"],
- "risk": "HIGH - 需要同时维护两个视图实例,内存和性能需关注"
- },
-
- "PF-06_code_splitting": {
- "id": "PF-06",
- "title": "代码分割",
- "solution": "在 electron.vite.config.ts 中配置 Rollup manualChunks,按依赖类型分割。",
- "implementation": {
- "file": "electron.vite.config.ts",
- "approach": "manualChunks + dynamic import",
- "chunks_config": {
- "vendor-react": ["react", "react-dom"],
- "vendor-codemirror": ["@codemirror/state", "@codemirror/view", "@codemirror/commands", "@codemirror/lang-markdown", "@codemirror/language", "@codemirror/search", "@codemirror/theme-one-dark"],
- "vendor-markdown": ["unified", "remark-parse", "remark-gfm", "remark-rehype", "rehype-raw", "rehype-sanitize", "rehype-stringify", "rehype-highlight"],
- "vendor-db": ["dexie"]
- },
- "dynamic_import_targets": [
- "rehype-highlight (仅 Preview 组件需要)"
- ]
- },
- "dependencies": [],
- "risk": "MEDIUM - 代码分割后需验证 Electron 打包正确性和加载时序"
- },
-
- "SE-01_csp": {
- "id": "SE-01",
- "title": "CSP 配置",
- "solution": "在 main process 的 BrowserWindow 中通过 session.defaultSession.webRequest 配置 CSP header。",
- "implementation": {
- "file": "src/main/window-manager.ts",
- "approach": "session.webRequest.onHeadersReceived",
- "csp_policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data:; font-src 'self' data:;",
- "notes": "style-src 需要 'unsafe-inline' 因为 CodeMirror 通过 JS 注入内联样式。rehype-highlight 引入 highlight.js 样式也需要考虑。"
- },
- "dependencies": ["PF-04"],
- "risk": "MEDIUM - CSP 可能阻断 CodeMirror 样式注入,需测试"
- }
- },
-
- "dependency_graph": {
- "description": "优化项之间的依赖关系。箭头表示 'A -> B' 意味着 B 依赖 A(A 需先完成)。",
- "edges": [
- { "from": "CQ-01", "to": "CQ-02", "reason": "Prettier 需与 ESLint 集成 (eslint-config-prettier)" },
- { "from": "CQ-01", "to": "AR-01", "reason": "组件拆分前需确保代码质量基线" },
- { "from": "CQ-01", "to": "AR-02", "reason": "组件拆分前需确保代码质量基线" },
- { "from": "CQ-01", "to": "PF-03", "reason": "React.memo 添加前需 ESLint 规则配合" },
- { "from": "CQ-01", "to": "DX-05", "reason": "lint-staged 依赖 ESLint 配置" },
- { "from": "CQ-01", "to": "DX-01", "reason": "测试代码也需遵循 ESLint 规则" },
- { "from": "CQ-04", "to": "DX-02", "reason": "preload 类型安全依赖类型完善" },
- { "from": "PF-01", "to": "PF-02", "reason": "Zustand 选择器优化涉及 Preview 组件,需配合 Markdown 缓存" },
- { "from": "PF-01", "to": "PF-07", "reason": "Preview 防抖依赖渲染管道已优化" },
- { "from": "PF-02", "to": "PF-07", "reason": "Preview 防抖依赖 activeTab 选择器" },
- { "from": "PF-02", "to": "PF-03", "reason": "React.memo 效果依赖选择器优化减少不必要更新" },
- { "from": "AR-01", "to": "UX-01", "reason": "ConfirmDialog 依赖组件拆分后的目录结构" },
- { "from": "AR-01", "to": "UX-02", "reason": "LoadingSpinner 依赖组件拆分后的目录结构" },
- { "from": "PF-01", "to": "UX-03", "reason": "分屏模式的预览面板依赖 Markdown 缓存" },
- { "from": "PF-02", "to": "UX-03", "reason": "分屏模式需要高效的 store 订阅" },
- { "from": "PF-04", "to": "UX-03", "reason": "分屏模式的编辑器依赖 Compartment 动态配置" },
- { "from": "PF-04", "to": "SE-01", "reason": "CSP 配置需在 Compartment 稳定后进行,避免相互影响" },
- { "from": "AR-05", "to": "AR-01", "reason": "ErrorBoundary 应在 App.tsx 拆分前完成,保护重构过程" }
- ],
- "parallelizable": [
- ["CQ-01", "AR-05", "DX-07"],
- ["PF-01", "PF-04", "PF-08"],
- ["CQ-04", "CQ-05", "AR-03", "AR-04"],
- ["UX-01", "UX-02", "UX-05"],
- ["DX-01", "DX-04"]
- ]
- },
-
- "risk_assessment": {
- "high_risk": [
- {
- "id": "PF-04",
- "title": "CodeMirror Compartment 重构",
- "risk_level": "HIGH",
- "description": "影响编辑器核心初始化流程。Compartment reconfigure 如果处理不当,可能导致编辑器状态丢失(光标位置、选区、undo 历史)。",
- "impact": "编辑器完全不可用",
- "probability": "MEDIUM",
- "mitigation": [
- "保留现有销毁重建逻辑作为 fallback(feature flag 控制)",
- "编写测试验证切换主题后光标位置和 undo 历史保留",
- "逐步迁移:先用 Compartment 切换主题,验证稳定后再扩展到其他扩展"
- ],
- "rollback": "revert useCodeMirror.ts 到使用前的版本"
- },
- {
- "id": "UX-03",
- "title": "分屏模式",
- "risk_level": "HIGH",
- "description": "需要同时维护 Editor 和 Preview 两个实例,可能增加内存占用和 CPU 消耗。滚动同步逻辑复杂。",
- "impact": "性能下降、内存溢出(大文件场景)",
- "probability": "MEDIUM",
- "mitigation": [
- "Preview 面板使用延迟加载(只在 split 模式激活时挂载)",
- "大文件场景下限制预览刷新频率(500ms debounce)",
- "考虑虚拟化预览(只渲染可视区域)"
- ],
- "rollback": "ViewMode 从 'editor' | 'preview' | 'split' 移除 'split' 值"
- }
- ],
- "medium_risk": [
- {
- "id": "PF-02",
- "title": "Zustand 选择器重构",
- "risk_level": "MEDIUM",
- "description": "选择器重构可能遗漏某些订阅点,导致某些组件在 activeTab 变化时不再更新。",
- "impact": "编辑器/预览/状态栏内容不同步",
- "probability": "LOW",
- "mitigation": [
- "使用 React DevTools Profiler 逐一验证每个受影响组件的重渲染行为",
- "逐个文件修改,每修改一个文件后手动测试",
- "编写 tabStore 选择器的单元测试"
- ],
- "rollback": "逐文件恢复原始订阅方式"
- },
- {
- "id": "SE-01",
- "title": "CSP 配置",
- "risk_level": "MEDIUM",
- "description": "CSP 配置过于严格可能阻断 CodeMirror 内联样式注入或 rehype-highlight 的样式。",
- "impact": "编辑器或预览样式丢失",
- "probability": "LOW",
- "mitigation": [
- "使用 'unsafe-inline' 允许 style-src(CodeMirror 依赖)",
- "分步启用 CSP 先 report-only 模式测试",
- "测试所有主题切换和 Markdown 语法高亮场景"
- ],
- "rollback": "移除 CSP header 配置"
- },
- {
- "id": "AR-03",
- "title": "统一设置加载",
- "risk_level": "MEDIUM",
- "description": "将两次 IndexedDB 读取合并为一次可能改变设置加载时序,导致首次渲染闪烁或主题闪烁。",
- "impact": "首次加载时短暂显示默认主题再切换到用户主题",
- "probability": "LOW",
- "mitigation": [
- "在设置加载完成前显示 loading 状态(不渲染 App)",
- "使用 init() 方式在 main.tsx 中等待设置加载完成后再渲染"
- ],
- "rollback": "恢复 useTheme 和 useSettings 各自独立加载"
- },
- {
- "id": "PF-08",
- "title": "IndexedDB 去抖",
- "risk_level": "MEDIUM",
- "description": "去抖延迟期间如果 Electron 进程被杀或用户关闭窗口,可能丢失最后一次保存。",
- "impact": "用户数据丢失(最后一次编辑)",
- "probability": "LOW",
- "mitigation": [
- "在 beforeunload / window close 事件中 flush debounce",
- "使用 navigator.sendBeacon 或 Electron app.on('before-quit') 事件触发同步保存",
- "记录 dirty flag,关闭前检查"
- ],
- "rollback": "恢复 setTimeout(fn, 0) 立即保存"
- }
- ],
- "low_risk": [
- {
- "id": "CQ-01",
- "title": "ESLint 配置",
- "risk_level": "LOW",
- "description": "纯增量添加,不影响运行时行为。",
- "mitigation": ["确保 .eslintignore 排除构建产物"]
- },
- {
- "id": "PF-01",
- "title": "Markdown 处理器缓存",
- "risk_level": "LOW",
- "description": "unified processor 是无状态管道,缓存复用安全。",
- "mitigation": ["设置 max cache size 防止内存泄漏"]
- },
- {
- "id": "PF-07",
- "title": "Preview 防抖",
- "risk_level": "LOW",
- "description": "防抖只是延迟渲染,不影响最终结果。",
- "mitigation": ["防抖时间可配置,默认 150ms"]
- },
- {
- "id": "AR-05",
- "title": "ErrorBoundary",
- "risk_level": "LOW",
- "description": "纯增量添加,只在出错时生效。",
- "mitigation": ["测试注入错误验证 ErrorBoundary 正常捕获"]
- }
- ]
- },
-
- "summary": {
- "total_optimization_items": 35,
- "total_new_files": 28,
- "total_modified_files": 22,
- "total_phases": 6,
- "estimated_total_effort": "12-18 天",
- "critical_path": "CQ-01 → AR-05 → AR-01 → UX-01 → DX-05",
- "parallel_speedup_potential": "约 30-40%(Phase 1 和 Phase 2 可部分并行)",
- "highest_impact_items": ["PF-02", "PF-01", "PF-04", "AR-05"],
- "recommended_execution_order": [
- "Phase 1 (Day 1-2): ESLint + Prettier + ErrorBoundary — 建立基线",
- "Phase 2 (Day 3-4): 性能修复 — 解决核心瓶颈",
- "Phase 3 (Day 5-7): 架构重构 — 提升可维护性",
- "Phase 4 (Day 8-10): UX 改进 — 提升用户体验",
- "Phase 5 (Day 11-13): 测试 + 文档 — 建立质量保障",
- "Phase 6 (Day 14-18): 高级功能 — 锦上添花"
- ]
- }
-}
diff --git a/docs/optimization-delivery-report.json b/docs/optimization-delivery-report.json
deleted file mode 100644
index 1fd7f36..0000000
--- a/docs/optimization-delivery-report.json
+++ /dev/null
@@ -1,248 +0,0 @@
-{
- "reportTitle": "MarkLite v0.1.1 代码质量优化交付报告",
- "reportDate": "2026-06-03",
- "version": "0.1.1",
- "author": "Asset Custodian (多Agent协作)",
-
- "optimizationSummary": {
- "objective": "通过多Agent协作系统对 MarkLite 项目进行全方位代码质量优化和性能改进,涵盖代码质量审查、性能优化、UI/UX改进、类型完善和测试覆盖",
- "scope": "src/renderer/ 目录下全部前端代码,包括组件、状态管理、工具库、样式和测试",
- "totalPhases": 5,
- "completedOptimizations": [
- {
- "phase": 1,
- "name": "基础设施建设",
- "items": [
- "ESLint flat config (typescript-eslint + react-hooks + react-refresh)",
- "lint-staged + husky Git pre-commit 钩子",
- "ErrorBoundary 全局错误边界组件"
- ],
- "status": "completed"
- },
- {
- "phase": 2,
- "name": "性能优化",
- "items": [
- "Markdown 处理器 LRU 缓存(最大10条,PF-01)",
- "Zustand store 选择器优化(细粒度订阅,PF-02)",
- "CodeMirror Compartment 主题热切换(PF-04)",
- "预览区防抖机制(150ms,PF-05)"
- ],
- "status": "completed"
- },
- {
- "phase": 3,
- "name": "代码架构改进",
- "items": [
- "sidebarStore 精简与 loadFromDB 优化",
- "useTheme 统一由 useSettingsInit 管理(AR-04)",
- "统一错误处理模块 errorHandler.ts(CQ-05)",
- "组件选择器优化减少不必要重渲染"
- ],
- "status": "completed"
- },
- {
- "phase": 4,
- "name": "UI/UX 改进",
- "items": [
- "ConfirmDialog 通用确认对话框(支持 focus trap)",
- "LoadingSpinner 加载状态组件",
- "Toast 通知系统升级",
- "可访问性改进(ARIA属性、键盘导航)"
- ],
- "status": "completed"
- },
- {
- "phase": 5,
- "name": "测试与文档",
- "items": [
- "Vitest 测试框架配置(jsdom + globals)",
- "5个测试文件,78个测试用例全部通过",
- "DESIGN.md 和 README.md 文档更新",
- "husky + lint-staged Git hooks 集成"
- ],
- "status": "completed"
- }
- ],
- "qualityReview": {
- "score": 82,
- "maxScore": 100,
- "issuesFound": "multiple",
- "issuesResolved": "all",
- "status": "通过"
- }
- },
-
- "fileChangeStatistics": {
- "scope": "v0.1.1 优化提交 (commit 3cecb0f)",
- "totalFilesChanged": 16,
- "insertions": 106,
- "deletions": 66,
- "netChange": "+40 lines",
- "modifiedFiles": [
- "DESIGN.md",
- "README.md",
- "package.json",
- "src/renderer/App.tsx",
- "src/renderer/components/Editor/Editor.tsx",
- "src/renderer/components/Editor/useCodeMirror.ts",
- "src/renderer/components/Preview/Preview.tsx",
- "src/renderer/components/Sidebar/Sidebar.tsx",
- "src/renderer/components/StatusBar/StatusBar.tsx",
- "src/renderer/components/TabBar/TabBar.tsx",
- "src/renderer/components/Toolbar/Toolbar.tsx",
- "src/renderer/hooks/useDragDrop.ts",
- "src/renderer/hooks/useSettings.ts",
- "src/renderer/hooks/useTheme.ts",
- "src/renderer/stores/sidebarStore.ts",
- "src/renderer/stores/tabStore.ts"
- ],
- "newFiles": [],
- "projectTotals": {
- "sourceFileCount": 62,
- "totalSourceLines": 4808,
- "componentDirs": 16,
- "testFiles": 5,
- "storeModules": 3,
- "hookModules": 14,
- "typeModules": 5,
- "libModules": 4
- }
- },
-
- "performanceImprovements": {
- "markdownProcessorCache": {
- "id": "PF-01",
- "mechanism": "LRU 缓存(Map-based,最大10条)",
- "implementation": "src/renderer/lib/markdown.ts",
- "description": "基于文件路径缓存已构建的 unified 处理器实例,LRU淘汰策略避免内存泄漏",
- "expectedImprovement": "重复渲染同一文件时跳过处理器构建阶段,预览刷新延迟降低"
- },
- "zustandSelectorOptimization": {
- "id": "PF-02",
- "mechanism": "细粒度选择器订阅",
- "implementation": "src/renderer/stores/sidebarStore.ts, tabStore.ts, editorStore.ts",
- "description": "sidebarStore 精简为单一状态源,移除冗余字段;组件通过原子选择器订阅最小状态集",
- "expectedImprovement": "减少无关状态变化触发的组件重渲染"
- },
- "codemirrorThemeCompartment": {
- "id": "PF-04",
- "mechanism": "Milkdown CSS class 切换(milkdown-dark)",
- "implementation": "src/renderer/components/Editor/useMilkdown.ts",
- "description": "darkMode 变化时切换 milkdown-dark CSS class,无需销毁重建 EditorView",
- "expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置",
- "note": "项目已从 CodeMirror 6 迁移至 Milkdown v7 WYSIWYG 编辑器,原 Compartment 方案不再适用"
- },
- "previewDebounce": {
- "id": "PF-05",
- "mechanism": "150ms 防抖(setTimeout + useRef)",
- "implementation": "src/renderer/components/Preview/Preview.tsx",
- "description": "编辑内容变化后延迟150ms再触发预览渲染,快速连续输入时合并为单次渲染",
- "expectedImprovement": "大量快速输入时预览区渲染次数显著减少,降低 CPU 使用"
- }
- },
-
- "qualityMetrics": {
- "typescript": {
- "command": "npx tsc --noEmit",
- "errors": 0,
- "status": "PASS",
- "strictMode": true
- },
- "eslint": {
- "command": "npx eslint src/ --ext .ts,.tsx",
- "errors": 0,
- "warnings": 7,
- "warningDetails": [
- "react-hooks/exhaustive-deps: useEffect missing dependency 'activeTab' (1处,可接受的业务逻辑)",
- "no-console: console 语句 (6处,错误处理和调试日志中合理使用)"
- ],
- "status": "PASS"
- },
- "tests": {
- "framework": "Vitest 4.1.8",
- "environment": "jsdom",
- "totalTestFiles": 5,
- "totalTests": 78,
- "passed": 78,
- "failed": 0,
- "skipped": 0,
- "duration": "55ms (tests only), 9.68s (total)",
- "testSuites": [
- { "file": "src/renderer/lib/__tests__/errorHandler.test.ts", "tests": 12 },
- { "file": "src/renderer/stores/__tests__/tabStore.test.ts", "tests": 17 },
- { "file": "src/renderer/lib/__tests__/fileUtils.test.ts", "tests": 21 },
- { "file": "src/renderer/stores/__tests__/editorStore.test.ts", "tests": 12 },
- { "file": "src/renderer/lib/__tests__/markdown.test.ts", "tests": 16 }
- ],
- "status": "PASS"
- },
- "coverage": {
- "status": "未配置 @vitest/coverage-v8 依赖",
- "note": "vitest.config.ts 已配置 v8 provider,需安装 @vitest/coverage-v8 后启用"
- },
- "qualityScore": {
- "reviewScore": 82,
- "maxScore": 100,
- "rating": "良好",
- "breakdown": {
- "codeStructure": "85/100 - 组件划分合理,目录结构清晰",
- "typeScript": "90/100 - 0编译错误,类型覆盖良好",
- "errorHandling": "80/100 - 统一错误处理模块,ErrorBoundary",
- "performance": "78/100 - LRU缓存、防抖、Compartment优化",
- "testing": "75/100 - 78测试覆盖核心逻辑,缺少集成测试",
- "documentation": "80/100 - DESIGN.md + README.md 更新完善",
- "accessibility": "70/100 - ConfirmDialog focus trap,待扩展"
- }
- }
- },
-
- "subsequentRecommendations": {
- "phase6AdvancedFeatures": [
- {
- "id": "AF-01",
- "title": "虚拟滚动",
- "description": "FileTree 和 TabBar 在大量文件/标签时引入虚拟列表(react-window 或自实现)",
- "priority": "中",
- "effort": "中"
- },
- {
- "id": "AF-02",
- "title": "Web Worker Markdown 渲染",
- "description": "将 Markdown 解析移至 Web Worker,主线程零阻塞",
- "priority": "高",
- "effort": "高"
- },
- {
- "id": "AF-03",
- "title": "增量编辑同步",
- "description": "基于 CodeMirror transactions 的增量 diff 更新预览,替代全量重渲染",
- "priority": "高",
- "effort": "高"
- },
- {
- "id": "AF-04",
- "title": "i18n 国际化",
- "description": "提取硬编码中文字符串到 i18n 资源文件",
- "priority": "低",
- "effort": "中"
- },
- {
- "id": "AF-05",
- "title": "代码覆盖率提升",
- "description": "安装 @vitest/coverage-v8,目标覆盖率 70%+,补充组件集成测试",
- "priority": "高",
- "effort": "中"
- }
- ],
- "longTermMaintenance": [
- "定期更新依赖(Electron、CodeMirror、Zustand),关注 breaking changes",
- "保持 ESLint 规则集更新,跟进 typescript-eslint 新版本",
- "每季度进行一次代码质量审查(参照本次多Agent审查流程)",
- "考虑引入 CI/CD 流水线自动化测试和 lint 检查",
- "监控 Electron 安全公告,及时升级版本修复漏洞",
- "补充 E2E 测试(Playwright 或 Spectron),覆盖核心用户流程",
- "建立代码贡献规范(CONTRIBUTING.md),统一 PR 审查流程"
- ]
- }
-}
diff --git a/docs/optimization-requirements.json b/docs/optimization-requirements.json
deleted file mode 100644
index c8e6e6e..0000000
--- a/docs/optimization-requirements.json
+++ /dev/null
@@ -1,593 +0,0 @@
-{
- "project": "MarkLite",
- "version": "0.1.1",
- "analysisDate": "2026-06-03",
- "techStack": {
- "electron": "28.3.3",
- "react": "18.3.1",
- "typescript": "5.6.3",
- "vite": "electron-vite 3.0.0",
- "stateManagement": "zustand 5.0.6",
- "editor": "Milkdown 7.21.1 (WYSIWYG) + textarea (source mode)",
- "database": "Dexie (IndexedDB)"
- },
- "currentStatus": {
- "typescriptCheck": "PASS",
- "eslintConfig": "MISSING",
- "testCoverage": "NONE",
- "documentation": "NONE",
- "ci": "NONE"
- },
- "codebaseStats": {
- "totalSourceFiles": 44,
- "mainProcessFiles": 5,
- "preloadFiles": 1,
- "rendererFiles": 36,
- "sharedFiles": 2,
- "components": 14,
- "stores": 3,
- "hooks": 7,
- "dbRepositories": 3,
- "typeDefinitions": 5,
- "styleFiles": 3
- },
- "optimizationRequirements": {
- "1_codeQuality": {
- "title": "代码质量与规范",
- "priority": "HIGH",
- "items": [
- {
- "id": "CQ-01",
- "title": "配置 ESLint (Flat Config)",
- "priority": "P0-CRITICAL",
- "description": "package.json 中已有 @typescript-eslint/eslint-plugin、eslint、eslint-plugin-react-hooks 等依赖,但缺少 eslint.config.js 配置文件。npm run lint 无法正常工作。",
- "currentState": "devDependencies 已安装但无配置文件",
- "expectedState": "创建 eslint.config.js (ESLint v9 flat config 格式),配置 TypeScript + React + React Hooks 规则",
- "effort": "small",
- "details": {
- "configFormat": "ESM flat config (eslint.config.js)",
- "plugins": [
- "@typescript-eslint/eslint-plugin",
- "eslint-plugin-react-hooks",
- "eslint-plugin-react-refresh"
- ],
- "rules": [
- "typescript-eslint/recommended",
- "react-hooks/recommended",
- "no-unused-vars (error)",
- "no-console (warn)",
- "prefer-const"
- ]
- }
- },
- {
- "id": "CQ-02",
- "title": "添加 Prettier 代码格式化",
- "priority": "P1-HIGH",
- "description": "项目无统一代码格式化工具,代码风格靠人工维护(缩进、引号、分号等不一致风险高)。",
- "currentState": "无 Prettier 配置",
- "expectedState": "添加 .prettierrc 配置 + 与 ESLint 集成",
- "effort": "small",
- "details": {
- "config": {
- "semi": false,
- "singleQuote": true,
- "tabWidth": 2,
- "trailingComma": "none",
- "printWidth": 100
- }
- }
- },
- {
- "id": "CQ-03",
- "title": "添加 .editorconfig",
- "priority": "P2-MEDIUM",
- "description": "统一编辑器行为配置,确保不同开发者环境一致。",
- "currentState": "无 .editorconfig",
- "expectedState": "添加 .editorconfig 文件",
- "effort": "trivial"
- },
- {
- "id": "CQ-04",
- "title": "修复 TypeScript 严格模式下的隐式 any",
- "priority": "P1-HIGH",
- "description": "虽然 tsc --noEmit 通过,但部分回调参数缺少类型标注(如 CustomEvent detail),依赖类型推断。",
- "currentState": "编译通过但存在隐式类型",
- "expectedState": "所有函数参数显式标注类型",
- "effort": "medium"
- },
- {
- "id": "CQ-05",
- "title": "统一错误处理模式",
- "priority": "P1-HIGH",
- "description": "多处 catch 块为空或仅 console.error,用户看不到有意义的错误信息。App.tsx 中 showToast('操作失败') 过于笼统。",
- "currentState": "分散的 try-catch,错误消息不具体",
- "expectedState": "统一的错误处理函数,包含具体错误描述和日志",
- "effort": "medium"
- }
- ]
- },
- "2_performance": {
- "title": "性能优化",
- "priority": "HIGH",
- "items": [
- {
- "id": "PF-01",
- "title": "Markdown 渲染处理器缓存",
- "priority": "P0-CRITICAL",
- "description": "renderMarkdown() 每次调用都创建新的 unified() 处理器管道(含 7 个插件),大文件渲染时开销显著。unified 处理器创建后应缓存复用。",
- "currentState": "每次渲染创建新 processor (7个插件)",
- "expectedState": "缓存 processor 实例,仅在 filePath 变化时重建",
- "effort": "small",
- "impact": "HIGH - 减少每次渲染的初始化开销",
- "details": {
- "location": "src/renderer/lib/markdown.ts",
- "solution": "使用 Map 按 filePath 缓存,或使用 useMemo 在组件层面缓存"
- }
- },
- {
- "id": "PF-02",
- "title": "Zustand 选择器优化 - 避免订阅整个 tabs 数组",
- "priority": "P0-CRITICAL",
- "description": "Editor.tsx、Preview.tsx、StatusBar.tsx、Sidebar.tsx 都订阅 tabs 数组然后 .find() 查找 activeTab。每次任何 tab 的 content 变化都会触发这些组件重渲染。",
- "currentState": "const tabs = useTabStore(s => s.tabs); const activeTab = tabs.find(...)",
- "expectedState": "使用 useShallow 或自定义选择器仅订阅 activeTab",
- "effort": "medium",
- "impact": "HIGH - 减少不必要的重渲染",
- "affectedFiles": [
- "src/renderer/components/Editor/Editor.tsx",
- "src/renderer/components/Preview/Preview.tsx",
- "src/renderer/components/StatusBar/StatusBar.tsx",
- "src/renderer/components/Sidebar/Sidebar.tsx"
- ],
- "solution": "在 tabStore 中添加 getActiveTab 选择器方法,或使用 zustand useShallow"
- },
- {
- "id": "PF-03",
- "title": "React.memo 包装纯展示组件",
- "priority": "P1-HIGH",
- "description": "Toolbar、StatusBar、Icons 等纯展示组件未使用 React.memo,父组件重渲染时会连带重渲染。",
- "currentState": "无任何组件使用 React.memo",
- "expectedState": "对 Toolbar、StatusBar、TabBar、Preview 等组件使用 memo 包装",
- "effort": "small",
- "impact": "MEDIUM"
- },
- {
- "id": "PF-04",
- "title": "CodeMirror 主题切换优化",
- "priority": "P1-HIGH",
- "description": "useCodeMirror.ts 中 darkMode 变化时销毁并重建整个 EditorView(第93行),导致编辑器完全重新初始化。",
- "currentState": "darkMode 变化 → 整个 EditorView 销毁重建",
- "expectedState": "使用 CodeMirror Compartment 动态切换主题扩展",
- "effort": "medium",
- "impact": "HIGH - 切换主题时保持编辑器状态",
- "details": {
- "location": "src/renderer/components/Editor/useCodeMirror.ts",
- "solution": "使用 @codemirror/state 的 Compartment API 动态 reconfigure 主题扩展"
- }
- },
- {
- "id": "PF-05",
- "title": "CodeMirror 内容更新优化",
- "priority": "P2-MEDIUM",
- "description": "setContent() 使用 from:0 到 length 全量替换,对大文件(10万行+)性能差。应使用事务 diff 或仅在内容实际变化时替换。",
- "currentState": "全量替换: changes: { from: 0, to: current.length, insert: newContent }",
- "expectedState": "仅在内容不同时替换,大文件考虑增量更新",
- "effort": "medium"
- },
- {
- "id": "PF-06",
- "title": "Bundle 大小优化 - 代码分割",
- "priority": "P2-MEDIUM",
- "description": "所有渲染进程代码打包为单一 bundle。rehype-highlight (含 highlight.js)、CodeMirror 等大型库未做懒加载。",
- "currentState": "单 bundle,无代码分割",
- "expectedState": "动态 import 预览相关库、分割 vendor chunk",
- "effort": "medium",
- "details": {
- "chunks": [
- "vendor-react: react + react-dom",
- "vendor-codemirror: @codemirror/*",
- "vendor-markdown: unified + remark + rehype 生态",
- "app: 业务代码"
- ]
- }
- },
- {
- "id": "PF-07",
- "title": "Preview 渲染防抖",
- "priority": "P2-MEDIUM",
- "description": "Preview.tsx 在 activeTab?.content 变化时立即触发异步渲染。快速输入时会频繁调用 renderMarkdown。",
- "currentState": "content 变化立即渲染",
- "expectedState": "添加 150-300ms 防抖,避免快速输入时频繁渲染",
- "effort": "small"
- },
- {
- "id": "PF-08",
- "title": "保存到 IndexedDB 的去抖优化",
- "priority": "P2-MEDIUM",
- "description": "tabStore 中多处使用 setTimeout(() => saveToDB(), 0) 异步保存,但没有去抖。频繁操作时会多次写入。",
- "currentState": "setTimeout(fn, 0) 立即触发",
- "expectedState": "使用 debounce (300-500ms) 合并多次保存",
- "effort": "small"
- }
- ]
- },
- "3_architecture": {
- "title": "架构优化",
- "priority": "MEDIUM",
- "items": [
- {
- "id": "AR-01",
- "title": "App.tsx 拆分 - About 对话框提取",
- "priority": "P1-HIGH",
- "description": "App.tsx 有 307 行,About 对话框 JSX 内联(约 40 行),文件打开/保存逻辑也堆积在组件中。",
- "currentState": "App.tsx 包含 About 弹窗、文件操作、事件监听等所有逻辑",
- "expectedState": "提取 AboutDialog 组件、提取 useFileOperations hook",
- "effort": "medium",
- "details": {
- "extractions": [
- {
- "name": "AboutDialog",
- "from": "App.tsx lines 261-300",
- "to": "src/renderer/components/AboutDialog/AboutDialog.tsx"
- },
- {
- "name": "useFileOperations",
- "description": "提取 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 为自定义 hook",
- "to": "src/renderer/hooks/useFileOperations.ts"
- }
- ]
- }
- },
- {
- "id": "AR-02",
- "title": "Sidebar 组件拆分",
- "priority": "P1-HIGH",
- "description": "Sidebar.tsx 有 241 行,包含 FileTree 递归组件、resize 逻辑、独立文件列表等。应拆分为独立文件。",
- "currentState": "FileTree 组件定义在 Sidebar.tsx 内",
- "expectedState": "FileTree 提取为独立组件,resize 逻辑提取为 hook",
- "effort": "medium",
- "details": {
- "extractions": [
- {
- "name": "FileTree",
- "from": "Sidebar.tsx lines 180-239",
- "to": "src/renderer/components/Sidebar/FileTree.tsx"
- },
- {
- "name": "useSidebarResize",
- "description": "提取 resize 相关逻辑为自定义 hook",
- "to": "src/renderer/hooks/useSidebarResize.ts"
- }
- ]
- }
- },
- {
- "id": "AR-03",
- "title": "统一设置加载 - 消除 useTheme 和 useSettings 的重复加载",
- "priority": "P2-MEDIUM",
- "description": "useTheme 和 useSettings 各自独立调用 settingsRepository.load(),导致启动时两次 IndexedDB 读取。",
- "currentState": "useTheme.load() + useSettings.load() = 两次 DB 读取",
- "expectedState": "统一为单个 useSettingsInit hook,一次加载分发到 store",
- "effort": "small"
- },
- {
- "id": "AR-04",
- "title": "消除 DOM CustomEvent 中间层",
- "priority": "P2-MEDIUM",
- "description": "useFileWatch.ts 通过 window.dispatchEvent(CustomEvent) 传递文件修改事件,App.tsx 再 addEventListener 监听。应直接通过 Zustand store 或回调传递。",
- "currentState": "IPC → CustomEvent DOM → App.tsx listener",
- "expectedState": "IPC → Zustand store action → 组件响应",
- "effort": "small"
- },
- {
- "id": "AR-05",
- "title": "添加 React ErrorBoundary",
- "priority": "P1-HIGH",
- "description": "无全局错误边界。任何渲染错误会导致白屏。Electron 应用中尤其需要,因为用户无法刷新页面恢复。",
- "currentState": "无 ErrorBoundary",
- "expectedState": "添加 ErrorBoundary 组件包裹 App,显示错误信息和重启按钮",
- "effort": "small"
- },
- {
- "id": "AR-06",
- "title": "Icons 组件按需加载",
- "priority": "P2-MEDIUM",
- "description": "Icons.tsx 有 216 行,包含 16 个 SVG 图标组件。虽然是函数组件有 tree-shaking,但单文件过大影响维护。",
- "currentState": "16 个图标定义在单文件",
- "expectedState": "按功能模块拆分: toolbar-icons.tsx, sidebar-icons.tsx, app-icons.tsx",
- "effort": "small"
- },
- {
- "id": "AR-07",
- "title": "tabStore 异步操作模式统一",
- "priority": "P2-MEDIUM",
- "description": "tabStore 中大量使用 setTimeout(() => saveToDB(), 0) 和 setTimeout(() => tabRepository.saveActiveTabId(), 0)。模式不统一且容易出错。",
- "currentState": "分散的 setTimeout 调用",
- "expectedState": "使用 Zustand middleware (subscribeWithSelector) 或统一的 persist 逻辑",
- "effort": "medium"
- }
- ]
- },
- "4_userExperience": {
- "title": "用户体验",
- "priority": "MEDIUM",
- "items": [
- {
- "id": "UX-01",
- "title": "替换原生 confirm() 为自定义确认对话框",
- "priority": "P1-HIGH",
- "description": "TabBar.tsx 和 useUnsavedWarning.ts 使用浏览器原生 confirm() 对话框,样式丑陋且不可自定义。Electron 应用应使用自定义模态框或 electron.dialog。",
- "currentState": "5 处使用 confirm() 原生对话框",
- "expectedState": "自定义 ConfirmDialog 组件或使用 electron.dialog.showMessageBox",
- "effort": "medium",
- "affectedLocations": [
- "src/renderer/components/TabBar/TabBar.tsx (4处)",
- "src/renderer/hooks/useUnsavedWarning.ts (1处)"
- ]
- },
- {
- "id": "UX-02",
- "title": "添加加载状态指示",
- "priority": "P1-HIGH",
- "description": "文件打开、目录树加载、Markdown 渲染等异步操作无加载状态。用户无法感知操作是否正在进行。",
- "currentState": "无 loading 状态",
- "expectedState": "添加文件打开 spinner、目录树加载骨架屏、预览渲染指示器",
- "effort": "medium"
- },
- {
- "id": "UX-03",
- "title": "添加编辑器+预览分屏模式",
- "priority": "P2-MEDIUM",
- "description": "当前只有 'editor' 和 'preview' 两种模式,缺少 VS Code 风格的左右分屏同时编辑和预览。",
- "currentState": "viewMode: 'editor' | 'preview'",
- "expectedState": "新增 'split' 模式: 左侧编辑器 + 右侧实时预览",
- "effort": "large",
- "details": {
- "implementation": [
- "Settings.ViewMode 新增 'split'",
- "Editor 和 Preview 同时渲染",
- "编辑器滚动同步预览",
- "Ctrl+3 快捷键"
- ]
- }
- },
- {
- "id": "UX-04",
- "title": "TabBar 拖拽排序",
- "priority": "P3-LOW",
- "description": "标签页不支持拖拽排序,无法自定义标签顺序。",
- "currentState": "标签页固定顺序",
- "expectedState": "支持拖拽重新排列标签页",
- "effort": "large"
- },
- {
- "id": "UX-05",
- "title": "改进 Toast 通知系统",
- "priority": "P2-MEDIUM",
- "description": "当前 Toast 仅支持单条消息,3秒后消失。不支持不同类型(success/error/warning)和堆叠。",
- "currentState": "单条 toast,无类型区分",
- "expectedState": "支持多条堆叠、不同类型(成功/错误/警告)、可关闭",
- "effort": "small"
- },
- {
- "id": "UX-06",
- "title": "添加文件搜索功能 (Ctrl+P)",
- "priority": "P3-LOW",
- "description": "无快速文件搜索/打开功能。当打开文件夹后,应支持按文件名快速搜索打开。",
- "currentState": "仅支持文件夹树浏览",
- "expectedState": "Ctrl+P 快速搜索面板,模糊匹配文件名",
- "effort": "large"
- },
- {
- "id": "UX-07",
- "title": "可访问性增强",
- "priority": "P2-MEDIUM",
- "description": "部分组件缺少 ARIA 属性。Sidebar resize handle 无键盘支持。文件树展开/折叠无键盘导航。",
- "currentState": "基本的 role/aria-label 已添加",
- "expectedState": "完整的键盘导航、focus 管理、屏幕阅读器支持",
- "effort": "medium",
- "details": {
- "missing": [
- "resize handle 需要 role='separator' + 键盘调整",
- "文件树需要 aria-expanded、aria-owns",
- "右键菜单需要 role='menu' + 键盘导航",
- "About 对话框需要 focus trap"
- ]
- }
- }
- ]
- },
- "5_developmentExperience": {
- "title": "开发体验",
- "priority": "MEDIUM",
- "items": [
- {
- "id": "DX-01",
- "title": "添加单元测试框架",
- "priority": "P1-HIGH",
- "description": "项目零测试覆盖。核心逻辑(tabStore、fileUtils、markdown 渲染)应有单元测试。",
- "currentState": "无测试文件、无测试框架",
- "expectedState": "Vitest + React Testing Library,核心模块测试覆盖 > 60%",
- "effort": "large",
- "details": {
- "framework": "vitest + @testing-library/react + jsdom",
- "priority_test_targets": [
- "src/renderer/stores/tabStore.ts - 标签页 CRUD 逻辑",
- "src/renderer/lib/fileUtils.ts - 文件名/路径工具",
- "src/renderer/lib/markdown.ts - Markdown 渲染",
- "src/renderer/db/schema.ts - 数据库 schema",
- "src/main/file-system.ts - 文件系统操作"
- ],
- "scripts": {
- "test": "vitest run",
- "test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
- }
- }
- },
- {
- "id": "DX-02",
- "title": "强化 preload 类型安全",
- "priority": "P2-MEDIUM",
- "description": "types/ipc.ts 定义了 ElectronAPI 接口和 IpcInvokeMap,但 preload/index.ts 未使用这些类型约束。contextBridge.exposeInMainWorld 返回值无类型检查。",
- "currentState": "preload 使用字面量对象,未引用 ElectronAPI 类型",
- "expectedState": "preload 实现强制匹配 ElectronAPI 接口类型",
- "effort": "small",
- "details": {
- "solution": "const api: ElectronAPI = { ... } 在 preload 中强制类型检查"
- }
- },
- {
- "id": "DX-03",
- "title": "添加类型安全的 IPC 通信层",
- "priority": "P2-MEDIUM",
- "description": "types/ipc.ts 定义了 IpcInvokeMap 但未被使用。应建立类型安全的 IPC 调用包装器。",
- "currentState": "IpcInvokeMap 已定义但未使用",
- "expectedState": "渲染进程通过类型安全的 invoke 函数调用 IPC",
- "effort": "medium"
- },
- {
- "id": "DX-04",
- "title": "添加项目文档",
- "priority": "P2-MEDIUM",
- "description": "无 README.md、无 CONTRIBUTING.md、无开发环境搭建文档。",
- "currentState": "无文档",
- "expectedState": "README.md 含项目介绍、安装步骤、开发指南、构建说明",
- "effort": "small",
- "details": {
- "files": [
- "README.md - 项目介绍、截图、安装、开发、构建",
- "CONTRIBUTING.md - 贡献指南、代码规范、PR 流程",
- "CHANGELOG.md - 版本变更记录"
- ]
- }
- },
- {
- "id": "DX-05",
- "title": "添加 Git hooks (husky + lint-staged)",
- "priority": "P2-MEDIUM",
- "description": "无 pre-commit 钩子,代码质量无法在提交时保证。",
- "currentState": "无 Git hooks",
- "expectedState": "husky + lint-staged: 提交前自动运行 ESLint + 类型检查",
- "effort": "small"
- },
- {
- "id": "DX-06",
- "title": "添加 CI/CD 配置",
- "priority": "P3-LOW",
- "description": "无持续集成配置。应添加 GitHub Actions 或 Gitee CI 配置。",
- "currentState": "无 CI 配置",
- "expectedState": "CI 流水线: lint → typecheck → test → build",
- "effort": "small"
- },
- {
- "id": "DX-07",
- "title": "添加路径别名到 Vite 配置",
- "priority": "P3-LOW",
- "description": "tsconfig.json 定义了 @renderer/*、@main/*、@shared/* 路径别名,但 electron.vite.config.ts 中 renderer 的 alias 只有 @renderer 和 @shared,缺少 @main。",
- "currentState": "renderer alias 缺少 @main 映射",
- "expectedState": "补全所有路径别名映射",
- "effort": "trivial"
- }
- ]
- },
- "6_security": {
- "title": "安全加固",
- "priority": "HIGH",
- "items": [
- {
- "id": "SE-01",
- "title": "CSP (Content Security Policy) 配置",
- "priority": "P1-HIGH",
- "description": "未配置 CSP。Preview 使用 dangerouslySetInnerHTML 渲染 HTML,虽然 rehype-sanitize 做了清理,但缺少 CSP 作为第二道防线。",
- "currentState": "无 CSP 配置",
- "expectedState": "在 index.html 和 BrowserWindow 中配置严格 CSP",
- "effort": "small"
- },
- {
- "id": "SE-02",
- "title": "Preview 中 file:// 协议安全",
- "priority": "P1-HIGH",
- "description": "rehypeFixImages 将相对路径图片转为 file:// URL。虽然拒绝了 '..' 但未做更严格的路径校验(如 symlink 跟踪)。",
- "currentState": "仅检查 '..' 路径遍历",
- "expectedState": "使用 path.resolve + 检查结果路径是否在允许目录内",
- "effort": "small"
- },
- {
- "id": "SE-03",
- "title": "Electron 安全最佳实践检查",
- "priority": "P2-MEDIUM",
- "description": "需确认 BrowserWindow 配置中 webPreferences 的安全设置(nodeIntegration、contextIsolation、sandbox 等)。",
- "currentState": "未检查 window-manager.ts 中的 webPreferences",
- "expectedState": "确保 nodeIntegration=false、contextIsolation=true、sandbox=true",
- "effort": "small"
- }
- ]
- }
- },
- "implementationPlan": {
- "phase1_foundation": {
- "name": "基础补全 (1-2天)",
- "priority": "P0",
- "items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
- "description": "ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复"
- },
- "phase2_criticalPerf": {
- "name": "关键性能修复 (1-2天)",
- "priority": "P0",
- "items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
- "description": "Markdown 缓存 + Zustand 选择器 + CodeMirror Compartment + 防抖"
- },
- "phase3_codeQuality": {
- "name": "代码质量提升 (2-3天)",
- "priority": "P1",
- "items": ["CQ-04", "CQ-05", "AR-01", "AR-02", "AR-03", "AR-04", "PF-03"],
- "description": "类型完善 + 错误处理统一 + 组件拆分 + 重复逻辑消除"
- },
- "phase4_userExperience": {
- "name": "用户体验改进 (2-3天)",
- "priority": "P1",
- "items": ["UX-01", "UX-02", "UX-05", "UX-07"],
- "description": "自定义确认框 + 加载状态 + Toast 改进 + 可访问性"
- },
- "phase5_developerExperience": {
- "name": "开发体验 (2-3天)",
- "priority": "P2",
- "items": ["DX-01", "DX-02", "DX-04", "DX-05"],
- "description": "单元测试 + 类型安全 + 文档 + Git hooks"
- },
- "phase6_advanced": {
- "name": "高级功能 (3-5天)",
- "priority": "P2-P3",
- "items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
- "description": "代码分割 + 分屏模式 + 拖拽排序 + 搜索 + 安全加固 + CI"
- }
- },
- "riskAssessment": {
- "highRisk": [
- {
- "id": "PF-04",
- "risk": "CodeMirror Compartment 重构可能影响编辑器初始化和标签切换逻辑",
- "mitigation": "保留现有逻辑作为 fallback,逐步迁移到 Compartment"
- },
- {
- "id": "UX-03",
- "risk": "分屏模式需要同时维护两个编辑器实例,内存和性能需关注",
- "mitigation": "使用虚拟化、延迟加载预览面板"
- }
- ],
- "mediumRisk": [
- {
- "id": "PF-02",
- "risk": "Zustand 选择器重构可能遗漏某些订阅点",
- "mitigation": "使用 React DevTools Profiler 验证重渲染情况"
- },
- {
- "id": "SE-01",
- "risk": "CSP 配置过于严格可能阻断 CodeMirror 样式注入",
- "mitigation": "使用 nonce 或 hash 方式允许 CodeMirror 内联样式"
- }
- ]
- }
-}
diff --git a/package.json b/package.json
index cd269f7..0d3850e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "marklite",
- "version": "0.3.13",
+ "version": "0.4.0",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -28,20 +28,9 @@
"author": "MarkLite",
"license": "MIT",
"dependencies": {
- "@milkdown/core": "7.21.1",
- "@milkdown/ctx": "7.21.1",
- "@milkdown/plugin-clipboard": "7.21.1",
- "@milkdown/plugin-history": "7.21.1",
- "@milkdown/plugin-indent": "7.21.1",
- "@milkdown/plugin-listener": "7.21.1",
- "@milkdown/plugin-trailing": "7.21.1",
- "@milkdown/preset-commonmark": "7.21.1",
- "@milkdown/preset-gfm": "7.21.1",
- "@milkdown/prose": "7.21.1",
- "@milkdown/react": "7.21.1",
- "@milkdown/utils": "7.21.1",
+ "@metona-team/metona-editor": "^0.1.3",
"dexie": "^4.0.11",
- "metona-toast": "file:../MetonaToast",
+ "@metona-team/metona-toast": "^2.0.1",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 2ab3f0f..0da9584 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -3,7 +3,6 @@ import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
-import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
@@ -13,17 +12,10 @@ import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useConfirm } from './hooks/useConfirm'
-import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
-import { useStatusBarItem } from './hooks/useStatusBarItem'
-import { useAutoSaveStore } from './stores/autoSaveStore'
-import { toggleAutoSaveExternal } from './hooks/useAutoSave'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
-import { SourceEditor } from './components/SourceEditor/SourceEditor'
-import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
-import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
@@ -31,25 +23,6 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
-/** 状态栏 — 自动保存开关组件 */
-function AutoSaveStatusItem() {
- const activeTab = useTabStore(s => s.getActiveTab())
- const { isAutoSaving, autoSaveEnabled } = useAutoSaveStore()
-
- if (!activeTab?.filePath) return null
-
- return (
-
- )
-}
-
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
@@ -61,7 +34,6 @@ export function App() {
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
- const { saveViewMode } = useSettings()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
@@ -70,9 +42,10 @@ export function App() {
useEffect(() => { loadFromDB() }, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
+ const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useDragDrop()
useFileWatch()
- useAutoSave()
+ // useAutoSave() already called above to get state for toolbar
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise => {
@@ -89,16 +62,6 @@ export function App() {
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners()
- // ── 状态栏扩展点架构 ───────────────────────────────────
- useDefaultStatusBarItems()
-
- useStatusBarItem({
- id: 'statusbar.auto-save',
- alignment: 'right',
- priority: 200,
- Component: AutoSaveStatusItem
- })
-
useEffect(() => {
if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId)
@@ -108,9 +71,6 @@ export function App() {
const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
- // BUG-10: Don't overwrite editor content the user has modified since the last save.
- // The watcher restart after save can trigger a false 'change' event, and re-reading
- // the file at that point would replace newer editor content with the just-saved content.
if (tab?.isModified) return
if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
@@ -126,9 +86,11 @@ export function App() {
setShowAbout(true)}
+ isAutoSaving={isAutoSaving}
+ autoSaveEnabled={autoSaveEnabled}
+ onToggleAutoSave={toggleAutoSave}
/>
@@ -139,16 +101,13 @@ export function App() {
)}
{tabs.length > 0 ? (
- {viewMode === 'editor' &&
}
- {viewMode === 'source' &&
}
- {viewMode === 'preview' &&
}
+
) : (
createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
-
{showAbout && }
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx
index 8e70337..b3aaec0 100644
--- a/src/renderer/components/Editor/Editor.tsx
+++ b/src/renderer/components/Editor/Editor.tsx
@@ -1,127 +1,215 @@
-import React, { useEffect, useCallback, useState, useRef } from 'react'
-import { callCommand } from '@milkdown/utils'
-import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
+import React, { useEffect, useRef } from 'react'
+import MeEditor from '@metona-team/metona-editor'
+import type { MarkdownEditor, EditMode, PluginObject } from '@metona-team/metona-editor'
import { useTabStore } from '../../stores/tabStore'
-import { useMilkdown } from './useMilkdown'
-import { EditorToolbar } from './EditorToolbar'
-import { SearchReplace } from '../SearchReplace'
-import { setEditorViewGetter } from '../../stores/editorStore'
+import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
+import { settingsRepository } from '../../db/settingsRepository'
+import { renderMarkdownSync } from '../../lib/markdown'
interface EditorProps {
darkMode: boolean
}
+/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
+function mapViewMode(vm: string): EditMode {
+ if (vm === 'source') return 'edit'
+ if (vm === 'preview') return 'preview'
+ return 'split'
+}
+
+/** 将 MetonaEditor mode 反向映射到应用 viewMode */
+function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
+ if (mode === 'edit') return 'source'
+ if (mode === 'preview') return 'preview'
+ return 'editor'
+}
+
+/** 组装实例级插件列表 */
+function buildPlugins(): PluginObject[] {
+ const plugins: PluginObject[] = []
+
+ // 搜索/替换(Ctrl+F / Ctrl+H)
+ if (MeEditor.presetPlugins?.searchReplace) {
+ plugins.push(MeEditor.presetPlugins.searchReplace)
+ }
+ // 粘贴图片 → base64
+ if (MeEditor.presetPlugins?.imagePaste) {
+ plugins.push(MeEditor.presetPlugins.imagePaste)
+ }
+
+ return plugins
+}
+
+/**
+ * Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
+ */
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
- const [showSearch, setShowSearch] = useState(false)
+ const viewMode = useEditorStore(s => s.viewMode)
+ const setViewMode = useEditorStore(s => s.setViewMode)
+
+ const containerRef = useRef(null)
+ const editorRef = useRef(null)
const currentContentRef = useRef('')
- const {
- containerRef,
- action,
- setContent,
- getScrollTop,
- setScrollTop,
- getSelection,
- setSelection,
- getView
- } = useMilkdown({
- content: activeTab?.content ?? '',
- onChange: useCallback((value: string) => {
- if (!activeTabId) return
- // B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
- // 避免将文档误标记为已修改
- const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
- if (tab?.content === value) return
- updateTabContent(activeTabId, value)
- setModified(activeTabId, true)
- }, [activeTabId, updateTabContent, setModified]),
- darkMode
- })
+ // 稳定的回调引用
+ const activeTabIdRef = useRef(activeTabId)
+ activeTabIdRef.current = activeTabId
- // Load content when switching tabs (only trigger on tab switch, not content edits)
+ // ── 初始化 MetonaEditor ──────────────────────────────────
useEffect(() => {
- if (!activeTab) return
- if (activeTab.content !== currentContentRef.current) {
- currentContentRef.current = activeTab.content
- // Queue setContent after Milkdown editor has finished async create()
- requestAnimationFrame(() => {
- setContent(activeTab.content)
- requestAnimationFrame(() => {
- setScrollTop(activeTab.scrollTop)
- setSelection(activeTab.selectionStart, activeTab.selectionEnd)
- })
- })
- }
- // stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [activeTabId])
+ const container = containerRef.current
+ if (!container) return
+
+ const editor = MeEditor.create(container, {
+ value: activeTab?.content ?? '',
+ mode: mapViewMode(viewMode),
+ height: '100%',
+ toolbar: [
+ 'bold', 'italic', 'strikethrough', 'underline', 'code', '|',
+ 'h1', 'h2', 'h3', '|',
+ 'quote', 'ul', 'ol', 'indent', 'outdent', '|',
+ 'link', 'image', 'table', 'hr', '|',
+ 'undo', 'redo', '|',
+ 'edit', 'split', 'preview', 'fullscreen'
+ ],
+ locale: 'zh-CN',
+ theme: darkMode ? 'dark' : 'light',
+ placeholder: '在此输入 Markdown 内容...',
+ spellcheck: false,
+ tabSize: 2,
+ wordCount: true,
+ plugins: buildPlugins(),
+
+ // 使用 unified 管线渲染,保留图片路径修复能力
+ render: (md: string) => {
+ const tabId = activeTabIdRef.current
+ const filePath = tabId
+ ? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
+ : null
+ return renderMarkdownSync(md, filePath)
+ },
+
+ // 内容变化 → 同步到 tabStore
+ onChange: (value: string) => {
+ const tabId = activeTabIdRef.current
+ if (!tabId) return
+ const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
+ if (tab?.content === value) return
+ currentContentRef.current = value
+ updateTabContent(tabId, value)
+ setModified(tabId, true)
+ },
+
+ // 模式切换 → 同步到 editorStore 并持久化
+ onModeChange: (mode: EditMode) => {
+ const mapped = reverseMapMode(mode)
+ setViewMode(mapped)
+ settingsRepository.save({ viewMode: mapped })
+ }
+ })
+
+ editorRef.current = editor
+ setMetonaEditorGetter(() => editor)
+ currentContentRef.current = activeTab?.content ?? ''
- // Save current tab state on unmount or tab switch
- useEffect(() => {
return () => {
- if (!activeTabId) return
- // Snapshot the current editor state before the new tab replaces content
- const scrollPos = getScrollTop()
- const sel = getSelection()
- // Use a microtask to ensure we save before React re-renders the new tab content
- Promise.resolve().then(() => {
- updateTabScroll(activeTabId, {
- scrollTop: scrollPos,
- selectionStart: sel.from,
- selectionEnd: sel.to
- })
- })
+ editor.destroy()
+ editorRef.current = null
+ setMetonaEditorGetter(() => null)
}
- // stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
+ // 仅在挂载时创建一次
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // ── 标签切换:同步内容到编辑器 ──────────────────────────
+ useEffect(() => {
+ if (!activeTab || !editorRef.current) return
+ if (activeTab.content === currentContentRef.current) return
+
+ currentContentRef.current = activeTab.content
+
+ // silent: true — 不触发 onChange,避免重复更新 tabStore
+ editorRef.current.setValue(activeTab.content, { silent: true })
+
+ // 恢复滚动位置
+ requestAnimationFrame(() => {
+ const c = containerRef.current
+ if (!c) return
+ const textarea = c.querySelector('textarea')
+ if (textarea) {
+ textarea.scrollTop = activeTab.scrollTop
+ }
+ const preview = c.querySelector('.me-preview') as HTMLElement | null
+ if (preview) {
+ preview.scrollTop = activeTab.scrollTop
+ }
+ })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
- // Register getView for OutlinePanel navigation
+ // ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => {
- setEditorViewGetter(getView)
- return () => setEditorViewGetter(() => null)
- }, [getView])
-
- // Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- const isCtrl = e.ctrlKey || e.metaKey
- if (isCtrl && e.key === 'b') {
- e.preventDefault()
- action((editor) => {
- editor.action(callCommand(toggleStrongCommand.key))
- })
- }
- if (isCtrl && e.key === 'i') {
- e.preventDefault()
- action((editor) => {
- editor.action(callCommand(toggleEmphasisCommand.key))
- })
- }
- if (isCtrl && (e.key === 'f' || e.key === 'h')) {
- e.preventDefault()
- setShowSearch(true)
- }
+ const c = containerRef.current
+ return () => {
+ if (!activeTabId || !editorRef.current || !c) return
+ const textarea = c.querySelector('textarea')
+ const preview = c.querySelector('.me-preview') as HTMLElement | null
+ const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
+ updateTabScroll(activeTabId, { scrollTop })
}
- document.addEventListener('keydown', handleKeyDown)
- return () => document.removeEventListener('keydown', handleKeyDown)
- }, [action])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTabId])
+
+ // ── 暗色模式同步 ──────────────────────────────────────────
+ useEffect(() => {
+ try {
+ const themeName = darkMode ? 'dark' : 'light'
+
+ // 1. 全局主题(documentElement + localStorage)
+ MeEditor.setTheme(themeName)
+
+ // 2. 覆写当前实例 wrapper 的 inline CSS 变量
+ // MeEditor.setTheme 只更新 documentElement,不更新 wrapper;
+ // 而 create() 时写入的 inline 变量优先级最高,必须手动覆写
+ const wrapper = containerRef.current?.querySelector('.me-wrapper') as HTMLElement | null
+ if (wrapper) {
+ const config: Record = MeEditor.themes.getThemeConfig(themeName) as Record
+ wrapper.style.setProperty('--md-bg', config.bg)
+ wrapper.style.setProperty('--md-text', config.text)
+ wrapper.style.setProperty('--md-border', config.border)
+ wrapper.style.setProperty('--md-shadow', config.shadow)
+ if (config.hoverShadow) wrapper.style.setProperty('--md-hover-shadow', config.hoverShadow)
+ if (config.toolbarBg) wrapper.style.setProperty('--md-toolbar-bg', config.toolbarBg)
+ if (config.textareaBg) wrapper.style.setProperty('--md-textarea-bg', config.textareaBg)
+ if (config.previewBg) wrapper.style.setProperty('--md-preview-bg', config.previewBg)
+ if (config.codeBg) wrapper.style.setProperty('--md-code-bg', config.codeBg)
+ if (config.codeText) wrapper.style.setProperty('--md-code-text', config.codeText)
+ if (config.accent) wrapper.style.setProperty('--md-accent', config.accent)
+ if (config.muted) wrapper.style.setProperty('--md-muted', config.muted)
+ }
+ } catch {
+ // 容错
+ }
+ }, [darkMode])
+
+ // ── 视图模式同步 ──────────────────────────────────────────
+ useEffect(() => {
+ const editor = editorRef.current
+ if (!editor) return
+ const targetMode = mapViewMode(viewMode)
+ if (editor.getMode() !== targetMode) {
+ editor.setMode(targetMode)
+ }
+ }, [viewMode])
return (
-
- {showSearch && (
-
setShowSearch(false)}
- />
- )}
-
+
)
})
diff --git a/src/renderer/components/Editor/EditorToolbar.tsx b/src/renderer/components/Editor/EditorToolbar.tsx
deleted file mode 100644
index 3caaaa1..0000000
--- a/src/renderer/components/Editor/EditorToolbar.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import React, { useCallback } from 'react'
-import type { Editor } from '@milkdown/core'
-import { callCommand } from '@milkdown/utils'
-import type { $Command } from '@milkdown/utils'
-import {
- toggleStrongCommand,
- toggleEmphasisCommand,
- wrapInHeadingCommand,
- wrapInBlockquoteCommand,
- wrapInBulletListCommand,
- wrapInOrderedListCommand,
- createCodeBlockCommand,
- toggleInlineCodeCommand,
- insertImageCommand,
- toggleLinkCommand,
- insertHrCommand
-} from '@milkdown/preset-commonmark'
-import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
-
-interface EditorToolbarProps {
- action: (fn: (editor: Editor) => void) => void
-}
-
-export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
- const exec = useCallback((command: $Command, payload?: T) => {
- action((editor) => {
- try {
- editor.action(callCommand(command.key, payload))
- } catch {
- // command may not be registered
- }
- })
- }, [action])
-
- const handleHeading = useCallback((level: number) => {
- exec(wrapInHeadingCommand, level)
- }, [exec])
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-})
-
-EditorToolbar.displayName = 'EditorToolbar'
diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts
deleted file mode 100644
index 646586b..0000000
--- a/src/renderer/components/Editor/useMilkdown.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { useCallback, useRef, useEffect } from 'react'
-import {
- Editor,
- rootCtx,
- defaultValueCtx,
- editorViewCtx,
- prosePluginsCtx
-} from '@milkdown/core'
-import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
-import { commonmark } from '@milkdown/preset-commonmark'
-import { gfm } from '@milkdown/preset-gfm'
-import { history } from '@milkdown/plugin-history'
-import { listener, listenerCtx } from '@milkdown/plugin-listener'
-import { indent } from '@milkdown/plugin-indent'
-import { trailing } from '@milkdown/plugin-trailing'
-import { clipboard } from '@milkdown/plugin-clipboard'
-import { Plugin, PluginKey, TextSelection } from '@milkdown/prose/state'
-import { Decoration, DecorationSet } from '@milkdown/prose/view'
-import type { EditorState } from '@milkdown/prose/state'
-
-// --- Auto-pair plugin (D2) ---
-
-const PAIRS: Record = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
-
-function createAutoPairPlugin(): Plugin {
- return new Plugin({
- props: {
- handleTextInput(view, from, to, text) {
- // 选中文本时用配对符号包裹
- if (from !== to && PAIRS[text]) {
- const close = PAIRS[text]
- const { tr } = view.state
- tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
- view.dispatch(tr)
- return true
- }
- return false
- },
- handleKeyDown(view, event) {
- const char = event.key
-
- // Backspace: 删除配对符号(光标在两个配对符号中间时)
- // 必须在 PAIRS 检查之前处理,因为 'Backspace' 不在 PAIRS 映射中
- if (char === 'Backspace') {
- const { state } = view
- const { from, to } = state.selection
- if (from !== to || from < 2) return false
- const before = state.doc.textBetween(from - 1, from)
- const after = state.doc.textBetween(from, from + 1)
- if (PAIRS[before] === after) {
- const tr = state.tr.delete(from - 1, from + 1)
- view.dispatch(tr)
- return true
- }
- return false
- }
-
- // 处理配对符号的自动闭合
- if (!PAIRS[char]) return false
-
- const { state } = view
- const { from, to } = state.selection
-
- if (from !== to) {
- // 有选区时:包裹
- const close = PAIRS[char]
- const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
- view.dispatch(tr)
- return true
- }
-
- // 无选区:插入配对并光标置中
- const close = PAIRS[char]
- const tr = state.tr.insertText(char + close, from)
- // 光标置于中间
- tr.setSelection(
- TextSelection.create(tr.doc, from + 1)
- )
- view.dispatch(tr)
- return true
- }
- }
- })
-}
-
-// --- Search highlight plugin ---
-
-export interface SearchMatch {
- from: number
- to: number
-}
-
-export interface SearchPluginState {
- matches: SearchMatch[]
- currentIndex: number
- query: string
-}
-
-export const searchPluginKey = new PluginKey('search-highlight')
-
-function createSearchPlugin(): Plugin {
- return new Plugin({
- key: searchPluginKey,
- state: {
- init(): SearchPluginState {
- return { matches: [], currentIndex: -1, query: '' }
- },
- apply(tr, value): SearchPluginState {
- const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
- if (meta) return meta
- // When the document changes, map existing match positions through the change
- // but only if there's an active search
- if (tr.docChanged && value.query) {
- // The SearchReplace component will re-search when doc changes,
- // but we map positions so decorations stay roughly correct in the interim
- return { ...value }
- }
- return value
- }
- },
- props: {
- decorations(state: EditorState) {
- const pluginState = searchPluginKey.getState(state)
- if (!pluginState || pluginState.matches.length === 0) {
- return null
- }
-
- const decos: Decoration[] = pluginState.matches.map((match, index) => {
- const cls =
- index === pluginState.currentIndex
- ? 'search-match-active'
- : 'search-match-highlight'
- return Decoration.inline(match.from, match.to, { class: cls })
- })
-
- return DecorationSet.create(state.doc, decos)
- }
- }
- })
-}
-
-// --- Hook ---
-
-interface UseMilkdownOptions {
- content: string
- onChange: (value: string) => void
- darkMode: boolean
-}
-
-export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
- const containerRef = useRef(null)
- const editorRef = useRef(null)
- const onChangeRef = useRef(onChange)
- const isExternalUpdate = useRef(false)
- const initialContentRef = useRef(content)
- // H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
- const setContentRequestId = useRef(0)
-
- // Keep onChangeRef fresh
- useEffect(() => {
- onChangeRef.current = onChange
- }, [onChange])
-
- // Initialize editor
- useEffect(() => {
- if (!containerRef.current) return
-
- const editor = Editor.make()
- .config((ctx) => {
- ctx.set(rootCtx, containerRef.current!)
- ctx.set(defaultValueCtx, initialContentRef.current)
-
- // Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
- ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
-
- // Configure listener for content changes
- const lm = ctx.get(listenerCtx)
- lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
- if (markdown === prevMarkdown) return
- // H-03: 只有未被外部更新抑制时才触发 onChange。
- // 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
- if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
- onChangeRef.current(markdown)
- }
- })
-
- // Listen for blur events (used for potential future state saving)
- lm.blur(() => {
- // blur handler placeholder
- })
- })
- .use(commonmark)
- .use(gfm)
- .use(history)
- .use(listener)
- .use(indent)
- .use(trailing)
- .use(clipboard)
-
- let cancelled = false
-
- editor.create().then((created) => {
- if (cancelled) {
- // 组件已卸载,立即销毁以避免内存泄漏
- created.destroy()
- return
- }
- editorRef.current = created
- }).catch((err) => {
- // eslint-disable-next-line no-console -- editor creation failure must be reported
- console.error('Milkdown editor creation failed:', err)
- })
-
- return () => {
- cancelled = true
- editorRef.current?.destroy()
- editorRef.current = null
- }
- }, [])
-
- // Dark mode: update CSS custom properties on the container
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
- if (darkMode) {
- container.classList.add('milkdown-dark')
- } else {
- container.classList.remove('milkdown-dark')
- }
- }, [darkMode])
-
- // External content update (tab switch)
- const setContent = useCallback((newContent: string) => {
- const editor = editorRef.current
- if (!editor) return
-
- // H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
- const requestId = ++setContentRequestId.current
- isExternalUpdate.current = true
- try {
- editor.action(milkdownReplaceAll(newContent))
- } catch {
- // replaceAll may fail if editor is not fully ready
- } finally {
- // 仅当没有新的 setContent 启动时才重置标志
- if (setContentRequestId.current === requestId) {
- setContentRequestId.current = 0
- isExternalUpdate.current = false
- }
- }
- }, [])
-
- // Scroll position management
- const getScrollTop = useCallback((): number => {
- const container = containerRef.current
- if (!container) return 0
- const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
- return scroller?.scrollTop ?? container.scrollTop ?? 0
- }, [])
-
- const setScrollTop = useCallback((top: number) => {
- const container = containerRef.current
- if (!container) return
- const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
- if (scroller) {
- scroller.scrollTop = top
- } else {
- container.scrollTop = top
- }
- }, [])
-
- // Selection management
- const getSelection = useCallback((): { from: number; to: number } => {
- const editor = editorRef.current
- if (!editor) return { from: 0, to: 0 }
- try {
- return editor.action((ctx) => {
- const view = ctx.get(editorViewCtx)
- if (view?.state?.selection) {
- return { from: view.state.selection.from, to: view.state.selection.to }
- }
- return { from: 0, to: 0 }
- })
- } catch {
- return { from: 0, to: 0 }
- }
- }, [])
-
- const setSelection = useCallback((from?: number, to?: number) => {
- const editor = editorRef.current
- if (!editor) return
- try {
- editor.action((ctx) => {
- const view = ctx.get(editorViewCtx)
- if (!view) return
- const docSize = view.state.doc.content.size
- // 恢复保存的选区,越界时夹紧到文档末尾
- const safeFrom = Math.min(from ?? 0, docSize)
- const safeTo = Math.min(to ?? safeFrom, docSize)
- const tr = view.state.tr.setSelection(
- TextSelection.create(view.state.doc, safeFrom, safeTo)
- )
- view.dispatch(tr)
- view.focus()
- })
- } catch {
- // 编辑器未就绪或选区无效,回退到仅 focus
- const container = containerRef.current
- if (!container) return
- const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
- pmEditor?.focus()
- }
- }, [])
-
- // Get ProseMirror EditorView instance
- const getView = useCallback(() => {
- const editor = editorRef.current
- if (!editor) return null
- try {
- return editor.action((ctx) => ctx.get(editorViewCtx))
- } catch {
- return null
- }
- }, [])
-
- // Execute a Milkdown action (for toolbar commands)
- const action = useCallback((fn: (editor: Editor) => void) => {
- const editor = editorRef.current
- if (!editor) return
- fn(editor)
- }, [])
-
- return {
- containerRef,
- editorRef,
- action,
- setContent,
- getScrollTop,
- setScrollTop,
- getSelection,
- setSelection,
- getView
- }
-}
diff --git a/src/renderer/components/Icons.tsx b/src/renderer/components/Icons.tsx
index d2ebae5..6d78f76 100644
--- a/src/renderer/components/Icons.tsx
+++ b/src/renderer/components/Icons.tsx
@@ -38,35 +38,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
)
}
-export function EditMode({ size = defaultProps.size }: IconProps) {
- return (
-
- )
-}
-
-export function PreviewMode({ size = defaultProps.size }: IconProps) {
- return (
-
- )
-}
-
-export function SourceMode({ size = defaultProps.size }: IconProps) {
- return (
-
- )
-}
-
export function Moon({ size = defaultProps.size }: IconProps) {
return (