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 @@ TypeScript React License - Version + Version

@@ -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 ( diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx deleted file mode 100644 index 9ec5c10..0000000 --- a/src/renderer/components/Preview/Preview.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useEditorStore } from '../../stores/editorStore' -import { renderMarkdown } from '../../lib/markdown' -import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner' - -// PF-07: 防抖延迟常量 -const PREVIEW_DEBOUNCE_MS = 150 - -export const Preview = React.memo(function Preview() { - const [html, setHtml] = useState('') - const [rendering, setRendering] = useState(false) - const previewRef = useRef(null) - const requestIdRef = useRef(0) - const debounceTimerRef = useRef | null>(null) - - const activeTab = useTabStore(s => s.getActiveTab()) - const activeTabId = useTabStore(s => s.activeTabId) - const setLoading = useEditorStore(s => s.setLoading) - - // PF-07: 响应式渲染 + 防抖 - useEffect(() => { - if (!activeTab) { - setHtml('') - setRendering(false) - setLoading('markdown-render', false) - return - } - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - - setRendering(true) - setLoading('markdown-render', true) - - debounceTimerRef.current = setTimeout(() => { - const requestId = ++requestIdRef.current - renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => { - if (requestId === requestIdRef.current) { - setHtml(result) - setRendering(false) - setLoading('markdown-render', false) - } - }) - }, PREVIEW_DEBOUNCE_MS) - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining - }, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading]) - - // 拦截链接点击 - const handleClick = useCallback((e: React.MouseEvent) => { - const link = (e.target as HTMLElement).closest('a') - if (!link) return - e.preventDefault() - const href: string | null = link.getAttribute('href') - if (!href) return - if (href.startsWith('#')) { - const target = previewRef.current?.querySelector(href) - if (target) target.scrollIntoView({ behavior: 'smooth' }) - return - } - try { - const url = new URL(href) - if (url.protocol !== 'http:' && url.protocol !== 'https:') return - } catch { - return - } - if (window.electronAPI?.openExternal) { - window.electronAPI.openExternal(href) - } else { - window.open(href, '_blank', 'noopener') - } - }, []) - - return ( -
- {rendering && html === '' && ( -
- -
- )} -
-
- ) -}) - -Preview.displayName = 'Preview' diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx deleted file mode 100644 index 0a83a02..0000000 --- a/src/renderer/components/SearchReplace/SearchReplace.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect, memo } from 'react' -import type { EditorView } from '@milkdown/prose/view' -import { TextSelection } from '@milkdown/prose/state' -import type { Node as ProseMirrorNode } from '@milkdown/prose/model' -import { searchPluginKey, type SearchMatch, type SearchPluginState } from '../Editor/useMilkdown' - -interface SearchReplaceProps { - /** 获取 ProseMirror EditorView 实例 */ - getView: () => EditorView | null - /** 关闭面板 */ - onClose: () => void -} - -/** - * 在 ProseMirror 文档中查找所有匹配位置 - * D6: 支持正则表达式 + 大小写敏感 - */ -function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] { - const matches: SearchMatch[] = [] - if (!query) return matches - - if (useRegex) { - let regex: RegExp - try { - const flags = caseSensitive ? 'g' : 'gi' - regex = new RegExp(query, flags) - } catch { - // 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户 - return [] - } - doc.descendants((node, pos) => { - if (!node.isText) return true - const text = node.text || '' - regex.lastIndex = 0 - let result: RegExpExecArray | null - while ((result = regex.exec(text)) !== null) { - matches.push({ from: pos + result.index, to: pos + result.index + result[0].length }) - if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环 - } - return true - }) - return matches - } - - // 普通字符串匹配 - const normalizedQuery = caseSensitive ? query : query.toLowerCase() - doc.descendants((node, pos) => { - if (!node.isText) return true - const text = node.text || '' - const searchText = caseSensitive ? text : text.toLowerCase() - let offset = 0 - let idx: number - while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) { - matches.push({ from: pos + idx, to: pos + idx + query.length }) - offset = idx + 1 - } - return true - }) - - return matches -} - -/** - * 滚动到指定文档位置 - */ -function scrollToPos(view: EditorView, pos: number) { - try { - const { node } = view.domAtPos(pos) - const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement) - el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } catch { - // Position may be out of range - } -} - -/** - * 更新 ProseMirror plugin state(触发 decorations 重绘) - */ -function dispatchSearchState(view: EditorView, state: SearchPluginState) { - const tr = view.state.tr.setMeta(searchPluginKey, state) - view.dispatch(tr) -} - -/** - * 清除搜索高亮(dispatch 空状态) - */ -function clearSearchDecorations(view: EditorView) { - dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' }) -} - -/** - * SearchReplace — 编辑器内搜索/替换面板 - * - * 功能: - * - Ctrl+F 打开搜索,Ctrl+H 打开替换 - * - 大小写敏感切换 - * - Enter 下一个,Shift+Enter 上一个 - * - 替换当前匹配 / 全部替换 - * - ESC 关闭面板 - */ -export const SearchReplace = memo(function SearchReplace({ - getView, - onClose -}: SearchReplaceProps) { - const [query, setQuery] = useState('') - const [replacement, setReplacement] = useState('') - const [showReplace, setShowReplace] = useState(false) - const [caseSensitive, setCaseSensitive] = useState(false) - const [useRegex, setUseRegex] = useState(false) - const [regexError, setRegexError] = useState(false) - const [matchCount, setMatchCount] = useState(0) - const [currentMatch, setCurrentMatch] = useState(-1) - - const searchInputRef = useRef(null) - const panelRef = useRef(null) - - // Keep latest state in refs for use in callbacks without stale closures - const matchesRef = useRef([]) - const currentIndexRef = useRef(-1) - const caseSensitiveRef = useRef(false) - const useRegexRef = useRef(false) - const queryRef = useRef('') - - // Sync refs - caseSensitiveRef.current = caseSensitive - useRegexRef.current = useRegex - queryRef.current = query - - // 执行搜索 - const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => { - const view = getView() - if (!view) return - - // D6: 检查正则语法 - setRegexError(false) - if (rx && searchQuery.trim()) { - try { - new RegExp(searchQuery, 'g') - } catch { - setRegexError(true) - return - } - } - - if (!searchQuery.trim()) { - clearSearchDecorations(view) - matchesRef.current = [] - currentIndexRef.current = -1 - setMatchCount(0) - setCurrentMatch(-1) - return - } - - const matches = findMatches(view.state.doc, searchQuery, cs, rx) - matchesRef.current = matches - const newIdx = matches.length > 0 ? 0 : -1 - currentIndexRef.current = newIdx - - setMatchCount(matches.length) - setCurrentMatch(newIdx) - - // Dispatch to ProseMirror plugin to render decorations - dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery }) - - // Scroll to first match - if (matches.length > 0) { - scrollToPos(view, matches[0].from) - } - }, [getView]) - - // 搜索框输入变化 - const handleQueryChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value - setQuery(value) - doSearch(value, caseSensitiveRef.current, useRegexRef.current) - }, [doSearch]) - - // 大小写切换 - const toggleCaseSensitive = useCallback(() => { - const newCS = !caseSensitiveRef.current - setCaseSensitive(newCS) - doSearch(queryRef.current, newCS, useRegexRef.current) - }, [doSearch]) - - // 正则切换 - const toggleRegex = useCallback(() => { - const newRx = !useRegexRef.current - setUseRegex(newRx) - doSearch(queryRef.current, caseSensitiveRef.current, newRx) - }, [doSearch]) - - // 导航到下一个/上一个匹配 - const navigateMatch = useCallback((direction: 1 | -1) => { - const view = getView() - if (!view) return - - const matches = matchesRef.current - if (matches.length === 0) return - - let next = currentIndexRef.current + direction - if (next >= matches.length) next = 0 - if (next < 0) next = matches.length - 1 - - currentIndexRef.current = next - setCurrentMatch(next) - - // Update plugin state to move the active highlight - dispatchSearchState(view, { - matches, - currentIndex: next, - query: queryRef.current - }) - - // Scroll to match and select it - const match = matches[next] - try { - const tr = view.state.tr.setSelection( - TextSelection.create(view.state.doc, match.from, match.to) - ) - view.dispatch(tr) - } catch { - // Selection range might be invalid - } - scrollToPos(view, match.from) - }, [getView]) - - // 替换当前匹配 - const replaceCurrent = useCallback(() => { - const view = getView() - if (!view) return - - const matches = matchesRef.current - const idx = currentIndexRef.current - if (idx < 0 || idx >= matches.length) return - - const match = matches[idx] - - // Replace text in ProseMirror document - const tr = view.state.tr.insertText(replacement, match.from, match.to) - view.dispatch(tr) - - // Re-search after replacement (document changed) - // Use requestAnimationFrame to let ProseMirror process the transaction - requestAnimationFrame(() => { - doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current) - }) - }, [getView, replacement, doSearch]) - - // 全部替换 - const replaceAll = useCallback(() => { - const view = getView() - if (!view) return - - // 重新搜索以获取匹配的最新位置 - const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current) - if (freshMatches.length === 0) return - - // 从后往前替换以保持位置正确 - const tr = view.state.tr - for (let i = freshMatches.length - 1; i >= 0; i--) { - tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to) - } - view.dispatch(tr) - - // 更新ref - matchesRef.current = [] - - // 替换后重新搜索 - requestAnimationFrame(() => { - doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current) - }) - }, [getView, replacement, doSearch]) - - // 键盘事件 - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - const view = getView() - if (view) clearSearchDecorations(view) - onClose() - return - } - if (e.key === 'Enter') { - e.preventDefault() - if (e.shiftKey) { - navigateMatch(-1) - } else { - navigateMatch(1) - } - } - }, [onClose, navigateMatch, getView]) - - // 全局 Ctrl+F / Ctrl+H 拦截 - useEffect(() => { - const handleGlobalKey = (e: KeyboardEvent) => { - const isCtrl = e.ctrlKey || e.metaKey - if (isCtrl && e.key === 'f') { - e.preventDefault() - setShowReplace(false) - searchInputRef.current?.focus() - searchInputRef.current?.select() - } - if (isCtrl && e.key === 'h') { - e.preventDefault() - setShowReplace(true) - searchInputRef.current?.focus() - searchInputRef.current?.select() - } - } - document.addEventListener('keydown', handleGlobalKey) - return () => document.removeEventListener('keydown', handleGlobalKey) - }, []) - - // 打开时聚焦搜索框 - useEffect(() => { - searchInputRef.current?.focus() - searchInputRef.current?.select() - }, []) - - // 清理高亮(组件卸载时) - useEffect(() => { - return () => { - const view = getView() - if (view) clearSearchDecorations(view) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return ( -
-
-
- - - {regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')} - -
- - - - - - -
- {showReplace && ( -
-
- setReplacement(e.target.value)} - aria-label="替换文本" - /> -
- - -
- )} -
- ) -}) - -SearchReplace.displayName = 'SearchReplace' diff --git a/src/renderer/components/SearchReplace/index.ts b/src/renderer/components/SearchReplace/index.ts deleted file mode 100644 index ca2683a..0000000 --- a/src/renderer/components/SearchReplace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchReplace } from './SearchReplace' diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 30752fc..3350701 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -11,11 +11,14 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { useActiveHeading } from '../../hooks/useActiveHeading' import { OutlinePanel, parseHeadings } from '../OutlinePanel' import type { Heading } from '../OutlinePanel' -import { getEditorView, useEditorStore } from '../../stores/editorStore' -import { TextSelection } from '@milkdown/prose/state' +import { getMetonaEditor, useEditorStore } from '../../stores/editorStore' const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export const Sidebar = React.memo(function Sidebar() { const tabs = useTabStore(s => s.tabs) const activeTabId = useTabStore(s => s.activeTabId) @@ -43,12 +46,11 @@ export const Sidebar = React.memo(function Sidebar() { return parseHeadings(activeTab.content) }, [activeTab?.content]) - // D4: 追踪预览面板中的活跃标题 + // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview) const previewRef = useRef(null) useEffect(() => { - // 仅在预览模式时获取 preview DOM 节点 if (viewMode === 'preview') { - previewRef.current = document.getElementById('preview') + previewRef.current = document.querySelector('.me-preview') as HTMLElement | null } else { previewRef.current = null } @@ -59,37 +61,40 @@ export const Sidebar = React.memo(function Sidebar() { headings ) - // Navigate to heading in ProseMirror document tree - const handleHeadingNavigate = useCallback((heading: Heading, index: number) => { - const view = getEditorView() - if (!view) return + // Navigate to heading in MetonaEditor + const handleHeadingNavigate = useCallback((heading: Heading) => { + const editor = getMetonaEditor() + if (!editor) return + try { - let foundPos: number | null = null - let currentIndex = 0 + // 获取当前内容,查找标题文本在源代码中的位置 + const content = editor.getValue() + const headingPattern = new RegExp( + `^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, + 'm' + ) + const match = headingPattern.exec(content) + if (!match) return - view.state.doc.descendants((node, pos) => { - if (foundPos !== null) return false - if (node.type.name === 'heading' && node.attrs.level === heading.level) { - if (node.textContent.trim() === heading.text) { - if (currentIndex === index) { - foundPos = pos - return false - } - currentIndex++ - } - } - return true - }) + const pos = match.index - if (foundPos === null) return + // 通过 DOM 操作滚动 textarea 到对应位置 + const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null + if (!container) return - const tr = view.state.tr - .setSelection(TextSelection.create(view.state.doc, foundPos, foundPos)) - .scrollIntoView() - view.dispatch(tr) - view.focus() + const textarea = container.querySelector('textarea') + if (!textarea) return + + // 估算滚动位置(简单方法:按行数比例) + const linesBefore = content.substring(0, pos).split('\n').length + const lineHeight = 24 // 估算行高 + textarea.scrollTop = linesBefore * lineHeight + + // 设置光标位置 + textarea.focus() + textarea.setSelectionRange(pos, pos) } catch { - // Position may be invalid if content has changed + // 导航失败,静默忽略 } }, []) diff --git a/src/renderer/components/SourceEditor/SourceEditor.tsx b/src/renderer/components/SourceEditor/SourceEditor.tsx deleted file mode 100644 index 7b0bcda..0000000 --- a/src/renderer/components/SourceEditor/SourceEditor.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' - -interface SourceEditorProps { - darkMode: boolean -} - -/** - * 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。 - * 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。 - * - * A2: 完全受控 — 所有变更通过 updateTabContent 驱动, - * 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。 - */ -export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) { - 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 textareaRef = useRef(null) - - // A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复 - const pendingSelectionRef = useRef(null) - - useEffect(() => { - if (pendingSelectionRef.current === null) return - const textarea = textareaRef.current - if (!textarea) return - const pos = pendingSelectionRef.current - // microtask:在 React 完成 DOM 更新后恢复光标 - requestAnimationFrame(() => { - textarea.selectionStart = pos - textarea.selectionEnd = pos - }) - pendingSelectionRef.current = null - }, [activeTab?.content]) - - const handleChange = useCallback((e: React.ChangeEvent) => { - if (!activeTabId) return - updateTabContent(activeTabId, e.target.value) - setModified(activeTabId, true) - }, [activeTabId, updateTabContent, setModified]) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - const isCtrl = e.ctrlKey || e.metaKey - const textarea = textareaRef.current - if (!textarea || !activeTabId) return - - // Tab 插入两个空格而非跳转焦点 - if (e.key === 'Tab') { - e.preventDefault() - const start = textarea.selectionStart - const end = textarea.selectionEnd - const value = textarea.value - const newValue = value.substring(0, start) + ' ' + value.substring(end) - const newPos = start + 2 - updateTabContent(activeTabId, newValue) - setModified(activeTabId, true) - pendingSelectionRef.current = newPos - return - } - - // Ctrl+B: 粗体 - if (isCtrl && e.key === 'b') { - e.preventDefault() - const start = textarea.selectionStart - const end = textarea.selectionEnd - const value = textarea.value - const selected = value.substring(start, end) - const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end) - updateTabContent(activeTabId, newValue) - setModified(activeTabId, true) - pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2 - return - } - - // Ctrl+I: 斜体 - if (isCtrl && e.key === 'i') { - e.preventDefault() - const start = textarea.selectionStart - const end = textarea.selectionEnd - const value = textarea.value - const selected = value.substring(start, end) - const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end) - updateTabContent(activeTabId, newValue) - setModified(activeTabId, true) - pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1 - return - } - }, [activeTabId, updateTabContent, setModified]) - - return ( -
-