3 Commits
Author SHA1 Message Date
thzxx 7bd625b9ce release: v0.4.1 — MetonaEditor 0.1.10 升级与三主题系统
- chore: 升级 @metona-team/metona-editor 0.1.3 → 0.1.10
- feat: 三主题系统(亮色/暗色/暖色),循环切换
- feat: 应用颜色自动同步编辑器主题(MeEditor.themes.exportCSSVars)
- feat: 启用编辑器新功能(lineNumbers/autoBrackets/readOnly/autofocus/exportTool)
- refactor: 主题从 boolean darkMode 重构为 ThemeMode 枚举
- refactor: 插件注册改用字符串数组(与 demo 一致)
- refactor: 主题同步从手动 CSS 覆写改为 editor.setTheme() 实例 API
- refactor: Ctrl+S 双通道(编辑器 onSave + 全局兜底)+ 防重入锁
- style: 移除 variables.css 中硬编码 .dark 色值,改为 JS 动态驱动
- test: 更新 editorStore 测试覆盖新的 themeMode/cycleTheme
2026-07-24 13:58:37 +08:00
thzxx 10cfe0066b docs: 添加 MetonaEditor 和 MetonaToast 参考文档 2026-07-23 22:57:49 +08:00
thzxx 05e5667e48 release: v0.4.0 — MetonaEditor 集成与架构重构
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
2026-07-23 22:55:45 +08:00
44 changed files with 2697 additions and 4410 deletions
+19 -9
View File
@@ -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/ # 自定义 HooksuseTheme · 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)。
+105 -65
View File
@@ -1,8 +1,8 @@
# MarkLite v0.3.13 — 架构设计文档
# MarkLite v0.4.1 — 架构设计文档
## 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 + ViteHMR 热更新 |
| 打包工具 | 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<string, boolean> // 全局加载状态
}
// 模块级 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 / warmCSS 变量驱动 |
| 国际化 | 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 | 代码语法高亮 |
+13 -2
View File
@@ -1,4 +1,4 @@
# Electron 项目开发环境完整配置指南
# MarkLite v0.4.1 — 开发环境配置指南
以下涵盖从 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=<your_token>
//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=<token>
# 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 安装路径没有中文或空格。
-317
View File
@@ -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."
}
+35 -37
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.13-orange?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/Version-v0.4.1-orange?style=flat-square" alt="Version">
</p>
<p align="center">
@@ -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 内联元素
- ✅ GFMGitHub Flavored Markdown
+522
View File
@@ -0,0 +1,522 @@
<!DOCTYPE html>
<html lang="zh-CN" data-md-theme="auto">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
<title>MetonaEditor v0.1.10 — 在线演示</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root {
--app-bg: #f5f6f8;
--app-text: #1a1a2e;
--app-card: #ffffff;
--app-border: #e5e7eb;
--app-accent: #3b82f6;
--app-accent2: #8b5cf6;
/* dark overrides */
--header-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
--header-text: #f1f5f9;
--badge-bg: rgba(255,255,255,0.12);
--badge-text: #e2e8f0;
}
[data-md-theme="dark"] {
--app-bg: #0f1117;
--app-text: #e2e8f0;
--app-card: #1a1d27;
--app-border: #2d3140;
--header-bg: linear-gradient(135deg, #0a0d15 0%, #161923 100%);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--app-bg);
color: var(--app-text);
min-height: 100vh;
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
/* ====== Header ====== */
.header {
background: var(--header-bg);
color: var(--header-text);
padding: 32px 24px 24px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -60px; right: -60px;
width: 300px; height: 300px;
background: radial-gradient(circle, rgba(139,92,246,0.3) 0%, transparent 70%);
border-radius: 50%;
}
.header h1 {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.5px;
position: relative;
}
.header h1 span.grad {
background: linear-gradient(135deg, var(--app-accent), var(--app-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header .badges {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 14px;
position: relative;
}
.header .badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: var(--badge-bg);
color: var(--badge-text);
border: 1px solid rgba(255,255,255,0.1);
}
/* ====== Toolbar Strip ====== */
.toolbar-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
background: var(--app-card);
border-bottom: 1px solid var(--app-border);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 50;
transition: background 0.3s;
}
.toolbar-strip label {
font-size: 13px;
font-weight: 500;
color: var(--app-text);
opacity: 0.7;
white-space: nowrap;
}
.toolbar-strip select {
padding: 5px 10px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
background: var(--app-bg);
color: var(--app-text);
cursor: pointer;
}
.toolbar-strip button {
padding: 6px 14px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: var(--app-card);
color: var(--app-text);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-strip button:hover { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip button.accent { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip .spacer { flex: 1; }
.toggle-group { display: flex; gap: 2px; }
.toggle-group button {
border-radius: 0;
margin: 0;
}
.toggle-group button:first-child { border-radius: 6px 0 0 6px; }
.toggle-group button:last-child { border-radius: 0 6px 6px 0; }
.toggle-group button.on { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
/* ====== Editor Container ====== */
.editor-section {
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.editor-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
background: var(--app-card);
border: 1px solid var(--app-border);
transition: box-shadow 0.3s, background 0.3s;
}
.editor-card:hover { box-shadow: 0 8px 36px rgba(0,0,0,0.1); }
/* ====== Info Cards ====== */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.info-card {
background: var(--app-card);
border: 1px solid var(--app-border);
border-radius: 10px;
padding: 18px 20px;
transition: box-shadow 0.3s;
}
.info-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.info-card .icon { font-size: 24px; margin-bottom: 8px; }
.info-card h4 { font-size: 14px; font-weight: 650; margin-bottom: 4px; }
.info-card p { font-size: 12px; color: var(--app-text); opacity: 0.65; margin: 0; }
/* ====== Footer ====== */
.footer {
text-align: center;
padding: 32px 24px;
font-size: 13px;
color: var(--app-text);
opacity: 0.5;
}
.footer a { color: var(--app-accent); text-decoration: none; }
/* ====== Responsive ====== */
@media (max-width: 768px) {
.header h1 { font-size: 1.5rem; }
.toolbar-strip { padding: 8px 12px; gap: 6px; }
.toolbar-strip button { padding: 5px 10px; font-size: 12px; }
.info-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header class="header">
<h1><span class="grad">Metona</span>Editor</h1>
<div class="badges">
<span class="badge">v0.1.10</span>
<span class="badge">零依赖</span>
<span class="badge">532 tests</span>
<span class="badge">MIT</span>
<span class="badge">中文优先</span>
</div>
</header>
<div class="toolbar-strip" id="controlBar">
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:8px;" title="返回首页">🏠</a>
<label>主题</label>
<select id="themeSelect">
<option value="auto">🌓 跟随系统</option>
<option value="light">☀️ 亮色</option>
<option value="dark">🌙 暗色</option>
<option value="warm">🔥 暖色</option>
</select>
<label>语言</label>
<select id="localeSelect">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
<label>模式</label>
<div class="toggle-group" id="modeBtns">
<button data-mode="edit">编辑</button>
<button data-mode="split" class="on">分屏</button>
<button data-mode="preview">预览</button>
</div>
<div class="spacer"></div>
<label>行号</label>
<div class="toggle-group" id="lineNumBtns">
<button class="on"></button>
<button></button>
</div>
<label>大纲</label>
<div class="toggle-group" id="outlineBtns">
<button></button>
<button class="on"></button>
</div>
<button onclick="demoToast()" title="演示 Toast 通知">💬 Toast</button>
<button onclick="demoOutline()" title="生成大纲">📋 大纲</button>
</div>
<div class="editor-section">
<div class="editor-card">
<div id="editor"></div>
</div>
</div>
<div class="info-grid">
<div class="info-card">
<div class="icon">📝</div>
<h4>丰富语法</h4>
<p>数学公式、脚注、定义列表、嵌套列表、表格对齐、任务列表、Emoji</p>
</div>
<div class="info-card">
<div class="icon">⌨️</div>
<h4>快捷键</h4>
<p>Ctrl+B 粗体 · Ctrl+I 斜体 · Ctrl+S 保存 · Ctrl+F 搜索 · Ctrl+H 替换</p>
</div>
<div class="info-card">
<div class="icon">🎨</div>
<h4>4 套主题</h4>
<p>亮色 · 暗色 · 暖色 · 跟随系统,实例级隔离,外部主题跟随</p>
</div>
<div class="info-card">
<div class="icon">🔌</div>
<h4>4 个插件</h4>
<p>自动保存草稿 · 导出 MD/HTML · 搜索替换 · 图片粘贴</p>
</div>
<div class="info-card">
<div class="icon">🧩</div>
<h4>编辑增强</h4>
<p>行号装订线 · 智能 Enter · 括号自动闭合 · 拖放文件 · 大纲面板</p>
</div>
<div class="info-card">
<div class="icon">🛡️</div>
<h4>安全</h4>
<p>XSS 防护 · URL 协议过滤 · HTML 实体保护 · sanitize 钩子</p>
</div>
</div>
<footer class="footer">
MetonaEditor v0.1.10 ·
<a href="index.html">首页</a> ·
<a href="docs.html">API 文档</a> ·
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> ·
MIT License
</footer>
<script src="../dist/metona-editor.js"></script>
<script>
(function () {
const demoMd = [
'# 🚀 MetonaEditor 演示',
'',
'> 轻量、零依赖、精致美观的 **Markdown Editor** 库。',
'> 单文件,开箱即用,中文优先。',
'',
'## ✨ 核心特性',
'',
'- 零依赖 · 单文件打包 · UMD / ESM / CJS',
'- 三模式视图 · 分屏拖拽 · 滚动同步',
'- 行号装订线 · 当前行高亮',
'- 智能 Enter · 括号自动闭合',
'- 4 套主题 · 实例级隔离 · 外部跟随',
'- 插件系统 v2 · 依赖排序 · 异步加载',
'',
'## 📐 数学公式',
'',
'行内公式:爱因斯坦质能方程 $E = mc^2$',
'',
'块级公式:',
'$$',
'\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}',
'$$',
'',
'## 📋 表格与对齐',
'',
'| 版本 | 日期 | 测试数 |',
'| :--- | :---: | ---: |',
'| v0.1.10 | 2026-07 | 532+ |',
'| v0.1.6 | 2026-07 | 521 |',
'| v0.1.5 | 2026-07 | 499 |',
'',
'## ✅ 任务列表',
'',
'- [x] 解析器增强(11 项新语法)',
'- [x] 主题系统(实例隔离 + 外部跟随)',
'- [x] 插件 v2 + i18n 增强',
'- [x] 编辑体验(行号 / 自动格式化 / 括号闭合)',
'- [ ] 更多语言翻译包',
'- [ ] VS Code 插件',
'',
'## 🎯 嵌套列表',
'',
'1. 一级有序',
' 1. 二级有序',
' 2. 二级第二项',
'2. 回到一级',
' - 混杂无序',
' - 第二项',
' - 三级嵌套',
'',
'## 📖 定义列表',
'',
'MetonaEditor',
': 一款轻量、零依赖的中文优先 Markdown 编辑器库。',
': 支持 CommonMark + GFM 扩展语法。',
'',
'Markdown',
': 由 John Gruber 创建的轻量级标记语言。',
'',
'## 🔗 链接与图片',
'',
'访问 [MetonaEditor 仓库](https://git.metona.cn/MetonaTeam/MetonaEditor \'点击访问源码\')',
'',
'图片示例:![示例](https://placehold.co/400x120/3b82f6/white?text=MetonaEditor)',
'',
'## 💻 代码块',
'',
'```js',
'import MeEditor from \'@metona-team/metona-editor\';',
'',
'const editor = MeEditor.create(\'#editor\', {',
' value: \'# Hello World\',',
' mode: \'split\',',
' lineNumbers: true,',
' autoBrackets: true,',
' plugins: [\'autoSave\', \'searchReplace\'],',
' onChange: (value) => console.log(value),',
'});',
'```',
'',
'## 🎨 文本样式',
'',
'- **粗体文本** 与 __另一种粗体__',
'- *斜体文本* 与 _另一种斜体_',
'- ~~删除线~~ 与 ==高亮标记==',
'- 化学式:H~2~O 与 数学:x^2^ + y^2^ = z^2^',
'- 行内代码:`const x = 42;`',
'- Emoji:smile: :rocket: :fire: :tada: :rainbow: :coffee:',
'',
'## 📌 脚注',
'',
'这是一个带脚注的句子[^1],这是另一个脚注[^note]。',
'',
'[^1]: 第一个脚注的详细说明内容。',
'[^note]: 脚注可以包含 **格式化** 和 `代码`。',
'',
'---',
'',
'> **提示:** 试试拖放图片到编辑器、按 `Ctrl+F` 搜索、`Ctrl+H` 替换、',
'> 在列表行尾按 `Enter` 体验智能延续、选中文字后输入 `(` 体验自动包裹。',
].join('\n');
const editor = window.MeEditor.create('#editor', {
value: demoMd,
mode: 'split',
height: 620,
lineNumbers: true,
autoBrackets: true,
wordCount: true,
outline: false,
theme: 'auto',
locale: 'zh-CN',
plugins: ['autoSave', 'exportTool', 'searchReplace', 'imagePaste'],
});
// ====== Control Bar Handlers ======
// Theme
document.getElementById('themeSelect').addEventListener('change', function () {
editor.setTheme(this.value);
});
// Locale
document.getElementById('localeSelect').addEventListener('change', function () {
editor.setLocale(this.value);
});
// Mode buttons
document.querySelectorAll('#modeBtns button').forEach(function (btn) {
btn.addEventListener('click', function () {
editor.setMode(this.dataset.mode);
document.querySelectorAll('#modeBtns button').forEach(function (b) { b.classList.remove('on'); });
this.classList.add('on');
});
});
// Line numbers toggle
var lineNumBtns = document.querySelectorAll('#lineNumBtns button');
lineNumBtns[0].addEventListener('click', function () {
editor.config.lineNumbers = true;
if (editor.gutter) editor.gutter.style.display = '';
editor._renderGutter();
lineNumBtns[0].classList.add('on');
lineNumBtns[1].classList.remove('on');
});
lineNumBtns[1].addEventListener('click', function () {
editor.config.lineNumbers = false;
if (editor.gutter) editor.gutter.style.display = 'none';
lineNumBtns[1].classList.add('on');
lineNumBtns[0].classList.remove('on');
});
// Outline toggle
var outlineBtns = document.querySelectorAll('#outlineBtns button');
outlineBtns[0].addEventListener('click', function () {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
});
outlineBtns[1].addEventListener('click', function () {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[1].classList.add('on');
outlineBtns[0].classList.remove('on');
});
// Toast demo
window.demoToast = function () {
var types = ['success', 'error', 'warning', 'info'];
var type = types[Math.floor(Math.random() * types.length)];
var messages = {
success: '✅ 内容已自动保存',
error: '❌ 操作失败,请重试',
warning: '⚠️ 有未保存的更改',
info: 'ℹ️ 这是来自插件系统的通知',
};
editor.toast(messages[type], { type: type, duration: 2500 });
};
// Outline demo
window.demoOutline = function () {
if (editor.config.outline) {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[0].classList.remove('on');
outlineBtns[1].classList.add('on');
} else {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
}
};
// Register custom shortcut
editor.registerShortcut('Ctrl+Shift+T', function () {
editor.toast('快捷键 Ctrl+Shift+T 已触发!', { type: 'info' });
}, 'Toast test');
// Listen for theme change to update select
editor.on('themeChange', function (data) {
document.getElementById('themeSelect').value = data.theme;
});
// Sync editor mode button state
editor.on('modeChange', function (mode) {
document.querySelectorAll('#modeBtns button').forEach(function (b) {
b.classList.toggle('on', b.dataset.mode === mode);
});
});
})();
</script>
</body>
</html>
+642
View File
@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
<title>MetonaEditor · 文档</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--bg-soft: #161922;
--card: #1c2029;
--card-hover: #232834;
--text: #e6e8eb;
--muted: #9ca3af;
--accent: #3b82f6;
--accent-2: #8b5cf6;
--accent-3: #ec4899;
--accent-soft: rgba(59, 130, 246, 0.12);
--border: rgba(255, 255, 255, 0.08);
--gradient: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
--shadow: 0 20px 50px -20px rgba(0,0,0,0.5);
}
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
min-height: 100vh;
}
.container { max-width: 1000px; margin: 0 auto; padding: 0 24px; }
/* Nav */
nav {
position: sticky; top: 0; z-index: 100;
background: rgba(15, 17, 23, 0.85);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1000px; margin: 0 auto; padding: 12px 24px;
display: flex; align-items: center; justify-content: space-between;
}
.nav-brand {
display: inline-flex; align-items: center; gap: 10px;
text-decoration: none; color: var(--text);
font-weight: 700; font-size: 15px;
}
.nav-brand .logo-mark {
width: 26px; height: 26px; border-radius: 6px;
background: var(--gradient);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.nav-links { display: flex; gap: 22px; align-items: center; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 13.5px; transition: color .15s; }
.nav-links a:hover, .nav-links a.active { color: var(--text); }
.nav-links a.active { color: var(--accent); font-weight: 600; }
/* Header */
header.docs-header {
text-align: center; padding: 64px 24px 40px;
background: radial-gradient(ellipse 60% 50% at 50% 0%, var(--accent-soft), transparent 70%), var(--bg);
}
header h1 { font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 10px; }
header h1 .grad { background: var(--gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
header .sub { color: var(--muted); font-size: 15px; }
/* Sidebar + Content Layout */
.docs-layout { display: flex; gap: 32px; padding: 40px 0 80px; }
.sidebar {
position: sticky; top: 72px;
width: 200px; flex-shrink: 0;
max-height: calc(100vh - 100px); overflow-y: auto;
padding-right: 16px;
border-right: 1px solid var(--border);
}
.sidebar a {
display: block; padding: 6px 12px; border-radius: 6px;
color: var(--muted); text-decoration: none; font-size: 13.5px;
transition: background .15s, color .15s;
margin-bottom: 2px;
}
.sidebar a:hover, .sidebar a.active { background: var(--card); color: var(--text); }
.sidebar .toc-group { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; padding: 16px 12px 6px; font-weight: 600; }
.content { flex: 1; min-width: 0; }
.content section { margin-bottom: 48px; }
.content h2 {
font-size: 24px; font-weight: 700; letter-spacing: -0.02em;
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.content h3 { font-size: 18px; font-weight: 600; margin: 28px 0 14px; }
.content h4 { font-size: 15px; font-weight: 600; margin: 20px 0 10px; color: var(--accent); }
/* Tables */
table {
width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px;
}
th, td {
padding: 10px 14px; text-align: left;
border: 1px solid var(--border);
}
th { background: var(--card); font-weight: 600; color: var(--text); }
td { color: var(--muted); }
tr:nth-child(even) td { background: var(--bg-soft); }
/* Code */
code {
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 13px; padding: 2px 6px;
background: var(--card); border: 1px solid var(--border);
border-radius: 4px; color: var(--accent);
}
pre {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 10px; padding: 18px 20px; overflow-x: auto;
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 13px; line-height: 1.8;
color: #e6e8eb; margin: 14px 0;
}
pre .c-key { color: #c792ea; }
pre .c-str { color: #c3e88d; }
pre .c-fn { color: #82aaff; }
pre .c-com { color: #5c6370; font-style: italic; }
pre .c-kw { color: #89ddff; }
pre .c-num { color: #f78c6c; }
pre .c-punc { color: #89ddff; opacity: 0.7; }
/* Badges */
.badge {
display: inline-block; padding: 3px 10px;
background: var(--accent-soft); color: var(--accent);
border-radius: 999px; font-size: 12px; font-weight: 600;
margin-right: 6px; margin-bottom: 4px;
}
/* Footer */
footer {
border-top: 1px solid var(--border); padding: 36px 24px;
text-align: center; color: var(--muted); font-size: 13px;
background: var(--bg-soft);
}
footer a { color: var(--accent); text-decoration: none; }
@media (max-width: 768px) {
.docs-layout { flex-direction: column; }
.sidebar {
position: static; width: 100%; max-height: none;
border-right: 0; border-bottom: 1px solid var(--border);
padding-bottom: 12px;
display: flex; flex-wrap: wrap; gap: 4px;
}
.sidebar .toc-group { width: 100%; }
}
</style>
</head>
<body>
<nav>
<div class="nav-inner">
<a class="nav-brand" href="index.html">
<span class="logo-mark" title="MetonaEditor"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V6l7 8 7-8v13"/></svg></span>
MetonaEditor
</a>
<div class="nav-links">
<a href="index.html">首页</a>
<a href="demo.html">演示</a>
<a href="docs.html" class="active">文档</a>
</div>
</div>
</nav>
<header class="docs-header">
<h1><span class="grad">文档</span></h1>
<p class="sub">MetonaEditor v0.1.10 完整 API、配置与使用指南</p>
</header>
<div class="container">
<div class="docs-layout">
<div class="sidebar" id="sidebar">
<div class="toc-group">快速入门</div>
<a href="#quickstart">安装与引入</a>
<div class="toc-group">配置</div>
<a href="#config">全部配置项</a>
<a href="#toolbar">工具栏</a>
<div class="toc-group">实例 API</div>
<a href="#api-content">内容操作</a>
<a href="#api-exec">命令执行</a>
<a href="#api-history">历史栈</a>
<a href="#api-mode">模式与全屏</a>
<a href="#api-stats">统计与状态</a>
<a href="#api-readonly">启用/禁用/只读</a>
<a href="#api-events">事件系统</a>
<a href="#api-plugin">插件管理</a>
<a href="#api-lifecycle">生命周期</a>
<div class="toc-group">静态 API</div>
<a href="#static-api">顶层函数</a>
<div class="toc-group">语法参考</div>
<a href="#syntax">解析器语法</a>
<div class="toc-group">插件</div>
<a href="#plugins">插件约定</a>
<a href="#preset-plugins">预设插件</a>
<div class="toc-group">主题</div>
<a href="#themes">主题系统</a>
<div class="toc-group">国际化</div>
<a href="#i18n">i18n API</a>
</div>
<main class="content">
<!-- ======== 快速入门 ======== -->
<section id="quickstart">
<h2>安装与引入</h2>
<pre><span class="c-com"># npm 安装</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor
<span class="c-com"># ES Module</span>
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
<span class="c-fn">value</span><span class="c-punc">:</span> <span class="c-str">'# Hello World'</span><span class="c-punc">,</span>
<span class="c-fn">mode</span><span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span>
<span class="c-punc">});</span>
<span class="c-com"># 浏览器 UMD</span>
<span class="c-com">&lt;script src="dist/metona-editor.js"&gt;&lt;/script&gt;</span>
<span class="c-com">&lt;script&gt;</span>
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">);</span>
<span class="c-com">&lt;/script&gt;</span></pre>
</section>
<!-- ======== 配置项 ======== -->
<section id="config">
<h2>全部配置项</h2>
<p style="color:var(--muted);margin-bottom:14px">所有配置均为可选,以下是默认值:</p>
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
<span class="c-com">// 内容</span>
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 初始 Markdown 文本</span>
placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 占位符</span>
<span class="c-com">// 视图</span>
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> <span class="c-com">// 'edit' | 'split' | 'preview'</span>
height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 数字为 px,字符串原样使用</span>
toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> <span class="c-com">// 工具栏配置,false 隐藏</span>
wordCount<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 字数统计状态栏</span>
<span class="c-com">// 行为</span>
autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 自动聚焦</span>
spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 拼写检查</span>
readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 只读模式</span>
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> <span class="c-com">// 历史栈上限</span>
historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 历史防抖延迟(ms</span>
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 分屏模式同步滚动</span>
tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span> <span class="c-com">// 制表符空格数(0 表示 \t</span>
<span class="c-com">// 主题与国际化</span>
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> <span class="c-com">// 'light' | 'dark' | 'auto' | 'warm' | 自定义</span>
locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span> <span class="c-com">// 'zh-CN' | 'en-US' | 自定义</span>
<span class="c-com">// 自定义渲染</span>
render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (md, env) =&gt; html</span>
highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (code, lang) =&gt; html</span>
sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (html) =&gt; safeHtml</span>
<span class="c-com">// 外观</span>
className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 容器额外 class</span>
style<span class="c-punc">:</span> <span class="c-punc">{},</span> <span class="c-com">// 内联样式</span>
<span class="c-com">// 插件与回调</span>
plugins<span class="c-punc">:</span> <span class="c-punc">[],</span> <span class="c-com">// 实例级插件数组</span>
onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span>
onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span>
onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span>
onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (mode, editor) =&gt; void</span>
onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (fullscreen, editor) =&gt; void</span>
onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
<span class="c-punc">});</span></pre>
</section>
<!-- ======== 工具栏 ======== -->
<section id="toolbar">
<h2>工具栏配置</h2>
<pre><span class="c-com">// 默认工具栏(完整按钮)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">true</span> <span class="c-punc">});</span>
<span class="c-com">// 隐藏工具栏</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">false</span> <span class="c-punc">});</span>
<span class="c-com">// 自定义工具栏 — '|' 为分隔符</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
toolbar<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'h1'</span><span class="c-punc">,</span> <span class="c-str">'h2'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">,</span> <span class="c-str">'redo'</span><span class="c-punc">],</span>
<span class="c-punc">});</span></pre>
<p>可用动作:</p>
<p>
<span class="badge">bold</span><span class="badge">italic</span><span class="badge">strikethrough</span><span class="badge">underline</span><span class="badge">code</span>
<span class="badge">h1</span><span class="badge">h2</span><span class="badge">h3</span><span class="badge">quote</span><span class="badge">ul</span><span class="badge">ol</span>
<span class="badge">indent</span><span class="badge">outdent</span><span class="badge">hr</span>
<span class="badge">link</span><span class="badge">image</span><span class="badge">table</span>
<span class="badge">undo</span><span class="badge">redo</span>
<span class="badge">edit</span><span class="badge">split</span><span class="badge">preview</span><span class="badge">fullscreen</span>
</p>
</section>
<!-- ======== 实例 API ======== -->
<section id="api-content">
<h2>实例 API</h2>
<h3>内容操作</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getValue</span><span class="c-punc">();</span> <span class="c-com">// 获取 Markdown 文本</span>
editor<span class="c-punc">.</span><span class="c-fn">setValue</span><span class="c-punc">(</span>md<span class="c-punc">,</span> <span class="c-punc">{</span> silent <span class="c-punc">});</span> <span class="c-com">// 设置内容,silent 不触发 change</span>
editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// 获取渲染后的 HTML</span>
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制刷新预览(内容未变时)</span>
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// 光标处插入 / 替换选区</span>
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// 选区包裹</span>
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-com">// 聚焦</span>
editor<span class="c-punc">.</span><span class="c-fn">blur</span><span class="c-punc">();</span> <span class="c-com">// 失焦</span></pre>
</section>
<section id="api-exec">
<h3>命令执行</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">,</span> <span class="c-punc">...</span>args<span class="c-punc">);</span> <span class="c-com">// 执行命令,返回 this 链式调用</span>
editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">).</span><span class="c-fn">exec</span><span class="c-punc">(</span><span class="c-str">'h1'</span><span class="c-punc">);</span> <span class="c-com">// 链式调用</span></pre>
<table>
<tr><th>action</th><th>效果</th><th>快捷键</th></tr>
<tr><td><code>bold</code></td><td>加粗 **text**</td><td>Ctrl+B</td></tr>
<tr><td><code>italic</code></td><td>斜体 *text*</td><td>Ctrl+I</td></tr>
<tr><td><code>strikethrough</code></td><td>删除线 ~~text~~</td><td></td></tr>
<tr><td><code>underline</code></td><td>下划线 &lt;u&gt;text&lt;/u&gt;</td><td>Ctrl+U</td></tr>
<tr><td><code>code</code></td><td>行内代码 `text`</td><td>Ctrl+E</td></tr>
<tr><td><code>h1</code>/<code>h2</code>/<code>h3</code></td><td>标题 # / ## / ###</td><td>Ctrl+1/2/3</td></tr>
<tr><td><code>quote</code></td><td>引用 ></td><td>Ctrl+Q</td></tr>
<tr><td><code>ul</code>/<code>ol</code></td><td>无序/有序列表</td><td></td></tr>
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab/Shift+Tab</td></tr>
<tr><td><code>hr</code></td><td>水平线 ---</td><td></td></tr>
<tr><td><code>link</code></td><td>插入链接 [text](url)</td><td>Ctrl+K</td></tr>
<tr><td><code>image</code></td><td>插入图片 ![alt](url)</td><td></td></tr>
<tr><td><code>table</code></td><td>插入表格</td><td></td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y</td></tr>
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr>
<tr><td><code>fullscreen</code></td><td>切换全屏</td><td></td></tr>
</table>
</section>
<section id="api-history">
<h3>历史栈</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">undo</span><span class="c-punc">();</span> <span class="c-com">// 撤销</span>
editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span> <span class="c-com">// 重做</span>
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> <span class="c-com">// 是否可撤销</span>
editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span> <span class="c-com">// 是否可重做</span></pre>
</section>
<section id="api-mode">
<h3>模式与全屏</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> <span class="c-com">// 设置模式:edit | split | preview</span>
editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span> <span class="c-com">// 获取当前模式</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 切换全屏</span>
editor<span class="c-punc">.</span><span class="c-fn">exitFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 退出全屏</span>
editor<span class="c-punc">.</span><span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 是否全屏</span></pre>
</section>
<section id="api-stats">
<h3>统计与状态</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getStats</span><span class="c-punc">();</span>
<span class="c-com">// =&gt; { characters, words, chineseChars, englishWords, lines, readingTime }</span>
editor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
<span class="c-com">// =&gt; { id, mode, theme, locale, fullscreen, readOnly, disabled, destroyed, plugins }</span></pre>
</section>
<section id="api-readonly">
<h3>启用/禁用/只读</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">enable</span><span class="c-punc">();</span> <span class="c-com">// 启用</span>
editor<span class="c-punc">.</span><span class="c-fn">disable</span><span class="c-punc">();</span> <span class="c-com">// 禁用</span>
editor<span class="c-punc">.</span><span class="c-fn">isDisabled</span><span class="c-punc">();</span> <span class="c-com">// 是否禁用</span>
editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-com">// 只读模式</span>
editor<span class="c-punc">.</span><span class="c-fn">isReadOnly</span><span class="c-punc">();</span> <span class="c-com">// 是否只读</span></pre>
</section>
<section id="api-events">
<h3>事件系统</h3>
<table>
<tr><th>事件名</th><th>触发时机</th><th>回调参数</th></tr>
<tr><td><code>input</code></td><td>textarea 原生 input</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>focus</code></td><td>聚焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>blur</code></td><td>失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(value, editor)</code></td></tr>
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
<tr><td><code>fullscreen</code></td><td>全屏切换</td><td><code>(fullscreen, editor)</code></td></tr>
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
<tr><td><code>autosave</code></td><td>autoSave 保存</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>beforeRender</code></td><td>渲染前</td><td><code>(editor)</code></td></tr>
<tr><td><code>afterRender</code></td><td>渲染后</td><td><code>(editor)</code></td></tr>
</table>
<h4>全局钩子(所有实例共享)</h4>
<table>
<tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code></td><td>构造函数初始化前</td></tr>
<tr><td><code>afterCreate</code></td><td>构造函数初始化完成</td></tr>
<tr><td><code>beforeRender</code></td><td>每次渲染前(全局)</td></tr>
<tr><td><code>afterRender</code></td><td>每次渲染后(全局)</td></tr>
<tr><td><code>beforeDestroy</code></td><td>destroy 前</td></tr>
<tr><td><code>afterDestroy</code></td><td>destroy 后</td></tr>
</table>
</section>
<section id="api-plugin">
<h3>插件管理</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装插件</span>
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 获取已安装插件列表</span>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加工具栏按钮</span></pre>
</section>
<section id="api-lifecycle">
<h3>生命周期</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-com">// 销毁实例</span>
editor<span class="c-punc">.</span><span class="c-fn">isDestroyed</span><span class="c-punc">();</span> <span class="c-com">// 是否已销毁</span></pre>
</section>
<!-- ======== 静态 API ======== -->
<section id="static-api">
<h2>静态 API</h2>
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 工厂函数</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> options<span class="c-punc">);</span>
<span class="c-com">// 全局默认插件</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span> delay<span class="c-punc">:</span> <span class="c-num">2000</span> <span class="c-punc">});</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>customPlugin<span class="c-punc">);</span>
<span class="c-com">// 全局事件钩子</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> <span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">=&gt;</span> <span class="c-punc">{});</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> handler<span class="c-punc">);</span>
<span class="c-com">// 主题与语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
<span class="c-com">// 状态查询</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span> <span class="c-com">// =&gt; { version, theme, locale, globalPlugins, presetPlugins }</span>
<span class="c-com">// 销毁全局资源</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
<span class="c-com">// 内置解析器</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-fn">parseMarkdown</span><span class="c-punc">(</span><span class="c-str">'# Hello'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '&lt;h1 id="hello"&gt;Hello&lt;/h1&gt;'</span>
<span class="c-fn">safeUrl</span><span class="c-punc">(</span><span class="c-str">'javascript:alert(1)'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; ''</span>
<span class="c-fn">slugify</span><span class="c-punc">(</span><span class="c-str">'Hello World'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; 'hello-world'</span></pre>
</section>
<!-- ======== 语法参考 ======== -->
<section id="syntax">
<h2>解析器语法参考</h2>
<table>
<tr><th>语法</th><th>示例</th><th>输出</th></tr>
<tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td><code>&lt;h1&gt;</code> ~ <code>&lt;h6&gt;</code></td></tr>
<tr><td>段落</td><td>纯文本</td><td><code>&lt;p&gt;</code></td></tr>
<tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td><code>&lt;strong&gt;</code></td></tr>
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><code>&lt;em&gt;</code></td></tr>
<tr><td>删除线</td><td><code>~~text~~</code></td><td><code>&lt;del&gt;</code></td></tr>
<tr><td>高亮标记</td><td><code>==text==</code></td><td><code>&lt;mark&gt;</code></td></tr>
<tr><td>上标</td><td><code>x^2^</code></td><td><code>&lt;sup&gt;</code></td></tr>
<tr><td>下标</td><td><code>H~2~O</code></td><td><code>&lt;sub&gt;</code></td></tr>
<tr><td>行内代码</td><td><code>`code`</code></td><td><code>&lt;code&gt;</code></td></tr>
<tr><td>代码块</td><td><code>```lang</code></td><td><code>&lt;pre&gt;&lt;code&gt;</code></td></tr>
<tr><td>引用</td><td><code>&gt; quote</code></td><td><code>&lt;blockquote&gt;</code></td></tr>
<tr><td>无序列表</td><td><code>- item</code></td><td><code>&lt;ul&gt;&lt;li&gt;</code></td></tr>
<tr><td>有序列表</td><td><code>1. item</code></td><td><code>&lt;ol&gt;&lt;li&gt;</code></td></tr>
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><code>&lt;li class="me-task-item"&gt;</code></td></tr>
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><code>&lt;hr&gt;</code></td></tr>
<tr><td>表格</td><td><code>| a | b |</code></td><td><code>&lt;table&gt;</code>(支持 <code>:---:</code> 列对齐)</td></tr>
<tr><td>链接</td><td><code>[text](url)</code></td><td><code>&lt;a&gt;</code></td></tr>
<tr><td>图片</td><td><code>![alt](url)</code></td><td><code>&lt;img&gt;</code></td></tr>
<tr><td>自动链接</td><td><code>&lt;https://...&gt;</code></td><td><code>&lt;a&gt;</code></td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(80+ 常用)</td></tr>
</table>
<h3>XSS 防护</h3>
<p>所有文本经 HTML 转义。URL 自动过滤 <code>javascript:</code> <code>vbscript:</code> <code>file:</code> 及非图片 <code>data:</code> 协议。</p>
</section>
<!-- ======== 插件 ======== -->
<section id="plugins">
<h2>插件约定</h2>
<pre><span class="c-kw">const</span> myPlugin <span class="c-punc">=</span> <span class="c-punc">{</span>
name<span class="c-punc">:</span> <span class="c-str">'myPlugin'</span><span class="c-punc">,</span>
description<span class="c-punc">:</span> <span class="c-str">'我的自定义插件'</span><span class="c-punc">,</span>
<span class="c-fn">install</span><span class="c-punc">(</span>editor<span class="c-punc">,</span> options<span class="c-punc">)</span> <span class="c-punc">{</span>
<span class="c-com">// this 指向插件对象本身</span>
<span class="c-kw">this</span><span class="c-punc">.</span>_timer <span class="c-punc">=</span> <span class="c-kw">null</span><span class="c-punc">;</span>
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
<span class="c-punc">},</span>
<span class="c-fn">destroy</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span>
<span class="c-kw">if</span> <span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">)</span> <span class="c-fn">clearTimeout</span><span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
<span class="c-punc">},</span>
<span class="c-punc">};</span></pre>
</section>
<section id="preset-plugins">
<h3>预设插件</h3>
<h4>autoSave — 自动保存草稿</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span>
key<span class="c-punc">:</span> <span class="c-str">'me-draft-'</span> <span class="c-punc">+</span> editor<span class="c-punc">.</span>id<span class="c-punc">,</span> <span class="c-com">// localStorage key</span>
delay<span class="c-punc">:</span> <span class="c-num">1000</span><span class="c-punc">,</span> <span class="c-com">// 防抖延迟(ms</span>
<span class="c-punc">});</span>
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> <span class="c-com">// 恢复草稿</span>
editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span> <span class="c-com">// 清除草稿</span>
editor<span class="c-punc">.</span><span class="c-fn">getDraftKey</span><span class="c-punc">();</span> <span class="c-com">// 获取存储 key</span></pre>
<h4>exportTool — 导出文件</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>exportTool<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'my-doc.md'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'my-doc.html'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
title<span class="c-punc">:</span> <span class="c-str">'文档标题'</span><span class="c-punc">,</span>
css<span class="c-punc">:</span> <span class="c-str">'body { font-family: sans-serif; }'</span><span class="c-punc">,</span>
lang<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
<span class="c-punc">});</span></pre>
<h4>searchReplace — 查找替换</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>searchReplace<span class="c-punc">);</span>
<span class="c-com">// Ctrl+F → 查找 Ctrl+H → 替换</span>
<span class="c-com">// Enter → 下一个 Shift+Enter → 上一个 Escape → 关闭</span></pre>
<h4>imagePaste — 粘贴图片转 base64</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>imagePaste<span class="c-punc">);</span>
<span class="c-com">// 在编辑区 Ctrl+V 粘贴剪贴板图片</span>
<span class="c-com">// 自动插入 ![](data:image/png;base64,...)</span></pre>
</section>
<!-- ======== 主题 ======== -->
<section id="themes">
<h2>主题系统</h2>
<pre><span class="c-com">// 全局切换主题</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'light'</span><span class="c-punc">);</span> <span class="c-com">// 亮色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> <span class="c-com">// 暗色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'warm'</span><span class="c-punc">);</span> <span class="c-com">// 暖色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'auto'</span><span class="c-punc">);</span> <span class="c-com">// 自动(默认)</span>
<span class="c-com">// 实例级主题(v0.1.5,每个编辑器独立)</span>
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span> <span class="c-com">// 'dark'</span>
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'themeChange'</span><span class="c-punc">,</span> <span class="c-punc">({</span> theme <span class="c-punc">})</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
<span class="c-com">// 注册自定义主题(v0.1.5: 支持继承)</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> <span class="c-com">// 继承 dark 主题</span>
accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span><span class="c-punc">,</span>
<span class="c-punc">});</span>
<span class="c-com">// 外部主题跟随(v0.1.5</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">followExternalTheme</span><span class="c-punc">(</span>
<span class="c-punc">{</span> element<span class="c-punc">:</span> document<span class="c-punc">.</span>body<span class="c-punc">,</span> attr<span class="c-punc">:</span> <span class="c-str">'data-theme'</span> <span class="c-punc">},</span>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
<span class="c-punc">);</span>
<span class="c-com">// 从父容器继承主题</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">adoptFromParent</span><span class="c-punc">(</span>editor<span class="c-punc">.</span>container<span class="c-punc">,</span>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
<span class="c-punc">);</span>
<span class="c-com">// CSS 变量导出</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> <span class="c-com">// { '--md-bg': '#fff', ... }</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span> <span class="c-com">// '#3b82f6'</span></pre>
<p>CSS 变量限定在 <code>.me-wrapper</code> 元素上,不污染全局。支持 <code>@media print</code> 打印样式。v0.1.5 新增实例级主题隔离、外部跟随、主题继承。</p>
</section>
<!-- ======== 国际化 ======== -->
<section id="i18n">
<h2>国际化</h2>
<pre><span class="c-com">// 切换语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'zh-CN'</span><span class="c-punc">);</span>
<span class="c-com">// 翻译函数</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> i18nUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '粗体'zh-CN</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; 'Bold'en-US</span>
<span class="c-com">// 添加语言</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><span class="c-punc">(</span><span class="c-str">'ja'</span><span class="c-punc">,</span> <span class="c-punc">{</span> bold<span class="c-punc">:</span> <span class="c-str">'太字'</span><span class="c-punc">,</span> italic<span class="c-punc">:</span> <span class="c-str">'斜体'</span> <span class="c-punc">});</span>
<span class="c-com">// 格式化</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '1,234,567'</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '$99.99'</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '1/15/2024'</span></pre>
</section>
</main>
</div>
</div>
<footer>
<div class="container">
MetonaEditor v0.1.10 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
<script>
// 高亮当前 section 的侧边栏链接
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('.sidebar a');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(s => {
if (window.scrollY >= s.offsetTop - 100) current = s.id;
});
links.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === '#' + current);
});
});
</script>
</body>
</html>
+417
View File
@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>在线演示 — MetonaToast</title>
<style>
:root {
--bg: #0b1121; --surface: #151d33; --border: #1e2d4d;
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
--radius: 14px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); color:var(--text); line-height:1.6; min-height:100vh; }
a { color:var(--accent2); text-decoration:none; }
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.85em; }
.nav { position:sticky; top:0; z-index:100; background:rgba(11,17,33,0.88); backdrop-filter:blur(14px); border-bottom:1px solid var(--border); }
.nav-inner { max-width:1300px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; padding:0 20px; height:56px; }
.nav-logo { font-size:18px; font-weight:700; background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.nav-links { display:flex; gap:20px; }
.nav-links a { color:var(--muted); font-size:13px; font-weight:500; transition:.2s; }
.nav-links a:hover { color:var(--accent2); }
.hero { text-align:center; padding:40px 20px 30px; border-bottom:1px solid var(--border); background:radial-gradient(ellipse at 50% 0%,rgba(99,102,241,0.12) 0%,transparent 50%); }
.hero h1 { font-size:36px; font-weight:800; }
.hero h1 span { background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.hero p { color:var(--muted); font-size:14px; margin-top:6px; }
.container { max-width:1300px; margin:0 auto; padding:24px 20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:18px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; }
.card h2 { font-size:15px; margin-bottom:2px; }
.card > p { color:var(--muted); font-size:11px; margin-bottom:12px; }
.btn-row { display:flex; flex-wrap:wrap; gap:6px; }
.btn { padding:8px 14px; border:none; border-radius:7px; font-size:12px; font-weight:500; cursor:pointer; transition:.15s; color:#fff; }
.btn:hover { transform:translateY(-1px); filter:brightness(1.15); }
.btn-sm { padding:5px 10px; font-size:11px; border-radius:5px; }
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
.btn-outline:hover { border-color:var(--accent); background:rgba(99,102,241,0.06); }
.btn-tiny { padding:4px 9px; font-size:10px; border-radius:4px; }
.c-s { background:#10b981; } .c-e { background:#ef4444; } .c-w { background:#f59e0b; color:#1e293b; }
.c-i { background:#3b82f6; } .c-p { background:#6366f1; } .c-pp { background:#8b5cf6; }
.c-pk { background:#ec4899; } .c-t { background:#14b8a6; } .c-o { background:#f97316; }
.c-sl { background:#475569; } .c-r { background:#f43f5e; } .c-g { background:#22c55e; }
.label { font-size:10px; text-transform:uppercase; letter-spacing:0.8px; color:var(--muted); margin-bottom:10px; font-weight:600; }
.demo-section { border-top:1px solid var(--border); padding-top:8px; margin-top:8px; }
footer { text-align:center; padding:30px 20px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }
@media (max-width:768px) { .container { grid-template-columns:1fr; } }
</style>
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<div class="nav-logo">MetonaToast</div>
<div class="nav-links">
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html">演示</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaToast" target="_blank">Gitea</a>
</div>
</div>
</nav>
<header class="hero">
<h1><span>在线演示</span></h1>
<p>覆盖项目全部功能的交互演示 — 点击即可体验</p>
</header>
<main class="container">
<!-- 1. 基础通知 -->
<div class="card">
<div class="label">📢 基础通知</div>
<div class="btn-row">
<button class="btn c-s" onclick="MeToast.success('操作成功!')">✓ 成功</button>
<button class="btn c-e" onclick="MeToast.error('操作失败!')">✕ 错误</button>
<button class="btn c-w" onclick="MeToast.warning('请注意检查')">⚠ 警告</button>
<button class="btn c-i" onclick="MeToast.info('系统维护中')"> 信息</button>
<button class="btn c-sl" onclick="MeToast.show('默认消息')">— 默认</button>
</div>
<div class="demo-section">
<div class="btn-row">
<button class="btn btn-outline btn-sm" onclick="MeToast.success({title:'保存成功',message:'文件已同步到云端'})">带标题</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.info('持续10秒',{duration:10000})">长时10s</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.warning('手动关闭',{duration:0,closeButton:true})">手动关闭</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.info('',{icon:false})">无图标</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.info('无按钮',{closeButton:false})">无关闭按钮</button>
<button class="btn btn-outline btn-sm" onclick="demoCustomIcon()">自定义图标</button>
</div>
</div>
</div>
<!-- 2. Loading 链式 -->
<div class="card">
<div class="label">🔄 Loading 链式转换</div>
<div class="btn-row">
<button class="btn c-s" onclick="demoLoading()">提交成功</button>
<button class="btn c-e" onclick="demoLoadingFail()">提交失败</button>
<button class="btn c-w" onclick="demoLoadingWarn()">转为警告</button>
<button class="btn c-i" onclick="demoLoadingUpdate()">原地更新</button>
</div>
</div>
<!-- 3. Promise + confirm + prompt -->
<div class="card">
<div class="label">💬 对话框</div>
<div class="btn-row">
<button class="btn c-pp" onclick="demoPromise()">Promise 风格</button>
<button class="btn c-pk" onclick="demoConfirm()">确认对话框</button>
<button class="btn c-t" onclick="demoPrompt()">输入对话框</button>
<button class="btn c-o" onclick="demoPromptPassword()">密码输入</button>
</div>
</div>
<!-- 4. 进度 & 倒计时 -->
<div class="card">
<div class="label">📊 进度 & 倒计时</div>
<div class="btn-row">
<button class="btn c-o" onclick="demoProgress()">进度条(水平)</button>
<button class="btn c-g" onclick="demoProgressV()">进度条(垂直)</button>
<button class="btn c-pk" onclick="demoCountdown()">倒计时 5s</button>
<button class="btn c-r" onclick="demoCountdownPause()">倒计时(可暂停)</button>
</div>
</div>
<!-- 5. 队列 & 堆叠 -->
<div class="card">
<div class="label">📋 队列 & 堆叠</div>
<div class="btn-row">
<button class="btn c-p" onclick="demoQueue()">队列展示</button>
<button class="btn c-e" onclick="demoQueueCancel()">队列中途取消</button>
<button class="btn c-pp" onclick="demoStack()">堆叠展示</button>
<button class="btn c-t" onclick="demoStackMixed()">混合类型堆叠</button>
</div>
</div>
<!-- 6. Action Toast -->
<div class="card">
<div class="label">⚡ Action Toast <span style="color:#34d399;font-size:10px;">v2.0</span></div>
<div class="btn-row">
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
<button class="btn c-s" onclick="demoActionNoClose()">不自动关闭</button>
</div>
</div>
<!-- 7. 分组管理 -->
<div class="card">
<div class="label">📁 分组管理 <span style="color:#34d399;font-size:10px;">v2.0</span></div>
<div class="btn-row">
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
<button class="btn c-i" onclick="MeToast.info('分组数量: '+group.count(),{type:'info'})">查看数量</button>
<button class="btn btn-outline btn-sm" onclick="demoGroupMixed()">混合类型分组</button>
</div>
</div>
<!-- 8. 动画效果 -->
<div class="card">
<div class="label">🎭 11种动画效果</div>
<div class="btn-row">
<button class="btn btn-outline btn-tiny" onclick="anim('slide')">slide</button>
<button class="btn btn-outline btn-tiny" onclick="anim('fade')">fade</button>
<button class="btn btn-outline btn-tiny" onclick="anim('scale')">scale</button>
<button class="btn btn-outline btn-tiny" onclick="anim('bounce')">bounce</button>
<button class="btn btn-outline btn-tiny" onclick="anim('flip')">flip</button>
<button class="btn btn-outline btn-tiny" onclick="anim('rotate')">rotate</button>
<button class="btn btn-outline btn-tiny" onclick="anim('zoom')">zoom</button>
<button class="btn btn-outline btn-tiny" onclick="anim('slideUp')">slideUp</button>
<button class="btn btn-outline btn-tiny" onclick="anim('slideDown')">slideDown</button>
<button class="btn btn-outline btn-tiny" onclick="anim('slideLeft')">slideLeft</button>
<button class="btn btn-outline btn-tiny" onclick="anim('slideRight')">slideRight</button>
</div>
</div>
<!-- 9. 6种位置 -->
<div class="card">
<div class="label">📍 全部位置</div>
<div class="btn-row">
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左上角',{position:'top-left'})">↖ top-left</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.success('顶部居中',{position:'top-center'})">↑ top-center</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右上角',{position:'top-right'})">↗ top-right</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左下角',{position:'bottom-left'})">↙ bottom-left</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.success('底部居中',{position:'bottom-center'})">↓ bottom-center</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右下角',{position:'bottom-right'})">↘ bottom-right</button>
</div>
</div>
<!-- 10. 主题切换 + 自定义 -->
<div class="card">
<div class="label">🎨 主题切换 & 自定义</div>
<div class="btn-row">
<button class="btn btn-outline btn-sm" onclick="switchTheme('light')">☀️ Light</button>
<button class="btn btn-outline btn-sm" onclick="switchTheme('dark')">🌙 Dark</button>
<button class="btn btn-outline btn-sm" onclick="switchTheme('auto')">🔄 Auto</button>
<button class="btn btn-outline btn-sm" onclick="switchTheme('warm')">🔥 Warm</button>
<button class="btn btn-outline btn-sm" onclick="demoRegisterTheme()">注册自定义主题</button>
</div>
</div>
<!-- 11. 插件系统 -->
<div class="card">
<div class="label">🔌 插件系统</div>
<div class="btn-row">
<button class="btn btn-outline btn-sm" onclick="MeToast.use('keyboard');MeToast.info('已安装 — 按 ESC 关闭所有 Toast')">keyboard ESC关闭</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.use('persistence');MeToast.info('已安装 — 配置将持久化到 localStorage')">persistence 持久化</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.use('accessibility');MeToast.info('已安装 — 屏幕阅读器可用')">accessibility 无障碍</button>
<button class="btn btn-outline btn-sm" onclick="demoCustomPlugin()">自定义插件</button>
</div>
</div>
<!-- 12. v2.0 新功能 -->
<div class="card">
<div class="label">🆕 v2.0 新功能 <span style="color:#34d399;font-size:10px;">resetTimer · onUpdate · render · notify</span></div>
<div class="btn-row">
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
<button class="btn c-pp" onclick="demoCustomRender()">自定义渲染</button>
<button class="btn c-w" onclick="demoNotify()">系统通知</button>
</div>
</div>
<!-- 13. Toast 管理 -->
<div class="card">
<div class="label">🔧 Toast 管理</div>
<div class="btn-row">
<button class="btn c-s" onclick="MeToast.success('消息 '+(MeToast.count()+1))">发一条</button>
<button class="btn c-i" onclick="MeToast.info('消息 '+(MeToast.count()+1))">再一条</button>
<button class="btn c-w" onclick="MeToast.info('当前 '+MeToast.count()+' 条')">查看数量</button>
<button class="btn c-sl" onclick="MeToast.pauseAll();MeToast.info('已暂停全部')">暂停全部</button>
<button class="btn c-sl" onclick="MeToast.resumeAll();MeToast.info('已恢复全部')">恢复全部</button>
<button class="btn c-e" onclick="MeToast.dismiss()">关闭全部</button>
<button class="btn c-r" onclick="MeToast.destroy();MeToast.info('已销毁')">销毁</button>
</div>
<div class="demo-section">
<div class="btn-row">
<button class="btn btn-outline btn-tiny" onclick="demoFindById()">findById</button>
<button class="btn btn-outline btn-tiny" onclick="demoGetAll()">getAll</button>
<button class="btn btn-outline btn-tiny" onclick="demoClearByPos()">clear(位置)</button>
<button class="btn btn-outline btn-tiny" onclick="demoGetConfig()">查看配置</button>
</div>
</div>
</div>
<!-- 14. 交互特性 -->
<div class="card">
<div class="label">✋ 交互特性</div>
<div class="btn-row">
<button class="btn c-i" onclick="demoDraggable()">拖拽关闭</button>
<button class="btn c-p" onclick="demoPauseHover()">悬停暂停</button>
<button class="btn c-w" onclick="demoCloseOnClick()">点击关闭</button>
<button class="btn c-pp" onclick="demoWidth()">自定义宽度</button>
<button class="btn c-t" onclick="demoClassName()">自定义className</button>
</div>
</div>
<!-- 15. 国际化切换 -->
<div class="card">
<div class="label">🌍 国际化 & 格式化</div>
<div class="btn-row">
<button class="btn c-s" onclick="switchLocale('zh-CN')">切换到中文</button>
<button class="btn c-i" onclick="switchLocale('en-US')">Switch to English</button>
<button class="btn btn-outline btn-sm" onclick="demoFormat()">格式化演示</button>
</div>
</div>
<!-- 16. 107种预设图标 -->
<div class="card" style="grid-column:1/-1;">
<div class="label">🎨 107种预设图标 — 点击任意图标发送Toast</div>
<div class="btn-row" id="icon-grid" style="max-height:300px;overflow-y:auto;padding:4px 0;"></div>
</div>
</main>
<footer>
MetonaToast v2.0.1 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
</footer>
<script src="../dist/metona-toast.js"></script>
<script>
MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true });
// === Loading ===
function demoLoading() { const l=MeToast.loading('正在提交...'); setTimeout(()=>l.success('提交成功!'),2000); }
function demoLoadingFail() { const l=MeToast.loading('正在处理...'); setTimeout(()=>l.error('处理失败'),2000); }
function demoLoadingWarn() { const l=MeToast.loading('检查中...'); setTimeout(()=>l.warning('发现潜在问题'),2000); }
function demoLoadingUpdate() { const l=MeToast.loading('处理中 0%'); let i=1; const t=setInterval(()=>{if(i>3){clearInterval(t);l.success('完成!');return;} l.update({message:'处理中 '+i*33+'%'}); i++;},600); }
// === Promise / confirm / prompt ===
async function demoPromise() {
const p=new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000));
try{await MeToast.promise(p,{loading:'处理中...',success:'成功!',error:'失败'});}catch(_){}
}
async function demoConfirm() { const ok=await MeToast.confirm('确定删除?',{confirmText:'删除',confirmColor:'#ef4444'}); if(ok)MeToast.success('已删除'); }
async function demoPrompt() { const v=await MeToast.prompt('请输入姓名',{placeholder:'请输入...'}); if(v) MeToast.info('你好,'+v+''); }
async function demoPromptPassword() { const v=await MeToast.prompt('请输入密码',{placeholder:'密码',inputType:'password',submitText:'登录'}); if(v) MeToast.info('密码已输入'); }
// === Progress & Countdown ===
function demoProgress() { const p=MeToast.progress('上传中...',{progressColor:'#3b82f6'}); let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('上传完成!');}else p.setProgress(v);},400); }
function demoProgressV() { const p=MeToast.progress('上传中...',{progressColor:'#10b981',progressDirection:'vertical'}); let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('完成!');}else p.setProgress(v);},400); }
function demoCountdown() { MeToast.countdown('{seconds} 秒后执行',5,{onComplete:()=>MeToast.success('已执行')}); }
function demoCountdownPause() {
const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')});
setTimeout(()=>{c.pause();MeToast.warning('已暂停3秒后恢复',{duration:3000})},4000);
setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000);
}
// === Queue & Stack ===
async function demoQueue() { await MeToast.queue(['第一步','第二步','第三步'],{delay:800,duration:2000,type:'info'}); }
async function demoQueueCancel() {
const q=MeToast.queue(['步骤1','步骤2','步骤3','步骤4','步骤5'],{delay:600,duration:1500,type:'info'});
setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500);
}
function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); }
function demoStackMixed() {
MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150});
}
// === Action ===
function demoActionDelete() { MeToast.action('文件 data.json 已删除',[{text:'撤销',onClick:()=>MeToast.success('已撤销'),color:'#3b82f6'}]); }
function demoActionMulti() { MeToast.action('邮件已发送',[{text:'查看',onClick:()=>MeToast.info('打开邮件详情'),color:'#3b82f6'},{text:'撤回',onClick:()=>MeToast.warning('已撤回'),color:'#f59e0b'}]); }
function demoActionNoClose() { MeToast.action('完成后点击关闭',[{text:'我知道了',onClick:()=>{},color:'#10b981'}]); }
// === Group ===
const group = MeToast.group('demo-group');
function demoGroup() { group.success('分组成功 #'+group.count()); group.info('分组消息 #'+group.count()); }
function dismissGroup() { MeToast.dismissGroup('demo-group'); MeToast.info('分组已关闭'); }
function demoGroupMixed() {
const g = MeToast.group('mixed-test');
g.success('OK'); g.warning('Warning'); g.info('Info');
setTimeout(()=>MeToast.dismissGroup('mixed-test'),4000);
}
// === Animation ===
function anim(name) { MeToast.success('动画: '+name,{animation:name,duration:3000,showProgress:false}); }
// === Theme ===
function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); }
function demoRegisterTheme() {
try {
MeToast.themes.registerTheme('sunset',{bg:'rgba(255,248,240,0.96)',text:'#92400e',border:'rgba(249,115,22,0.2)',shadow:'0 10px 36px -10px rgba(249,115,22,0.2)',hoverShadow:'0 14px 48px -10px rgba(249,115,22,0.3)',progressBg:'rgba(249,115,22,0.1)',closeHoverBg:'rgba(249,115,22,0.1)'});
MeToast.themes.switchTheme('sunset');
MeToast.configure({theme:'sunset'});
MeToast.success('已切换到 sunset 主题',{theme:'sunset'});
} catch(_) { MeToast.warning('主题已注册,已切换'); }
}
// === Plugins ===
function demoCustomPlugin() {
MeToast.use({name:'click-counter',version:'1.0',install(){let c=0;Toast.on('afterShow',t=>{c++;setTimeout(()=>t.update({message:t.message.replace(/\\(\\d+\\)/,'')+' (总通知#'+c+')'}),1);});}});
MeToast.info('自定义插件已安装(消息后追加计数)');
}
// === v2.0 new features ===
function demoResetTimer() {
const t=MeToast.info('这条消息不会自动消失(resetTimer',{duration:3000,resetTimerOnUpdate:true,closeButton:true});
let n=0; const iv=setInterval(()=>{n++;if(n>8){clearInterval(iv);t.close();return;}t.update({message:'重置计时器 #'+n+' — 每次更新重置3秒'});},1000);
}
function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); setTimeout(()=>t.update({message:'再次更新!'}),2000); }
function demoCustomRender() {
MeToast.show({render:toast=>'<div style="background:#111;color:#0f0;padding:16px 20px;border-radius:10px;border:1px solid #0f0;font-family:monospace">🟢 <b>'+toast.message+'</b><br><span style="font-size:11px;opacity:0.6"># custom render by thzxx</span></div>',message:'自定义渲染内容',duration:5000});
}
function demoNotify() {
if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;}
if(Notification.permission==='denied'){MeToast.error('通知权限已拒绝');return;}
MeToast.info('页面隐藏时看系统通知',{notifyWhenHidden:true,duration:6000});
}
// === Management ===
function demoFindById() {
const t=MeToast.info('这是测试 toast',{duration:5000});
setTimeout(()=>{const found=MeToast.find(t.id); if(found)MeToast.success('找到了 #'+found.id);},500);
}
function demoGetAll() { const all=MeToast.getAll(); MeToast.info('当前共 '+all.size+' 条 toast'); }
function demoClearByPos() { MeToast.error('右下角临时消息',{position:'bottom-right',duration:3000}); setTimeout(()=>{MeToast.clear('bottom-right');MeToast.info('右下角已清除');},500); }
function demoGetConfig() { try{const c=MeToast.getConfig();MeToast.info('配置: '+JSON.stringify({position:c.position,duration:c.duration,theme:c.theme,animation:c.animation,locale:c.locale}).replace(/"/g,''),{duration:5000});}catch(e){MeToast.error('无 getConfig 方法');} }
// === Interaction ===
function demoDraggable() { MeToast.info('← 试试拖动我来关闭',{draggable:true,duration:8000,closeButton:true}); }
function demoPauseHover() { MeToast.info('鼠标悬停在我上面,倒计时会暂停',{pauseOnHover:true,duration:8000}); }
function demoCloseOnClick() { MeToast.warning('点击我即可关闭',{closeOnClick:true,duration:10000}); }
function demoWidth() { MeToast.info('宽550px',{width:550}); setTimeout(()=>MeToast.info('宽280px(窄)',{width:280}),500); }
function demoClassName() { MeToast.success('带自定义 className',{className:'demo-custom-class'}); }
function demoCustomIcon() {
MeToast.info('通用通知', {
iconHTML: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
});
}
// === i18n ===
function switchLocale(loc) {
try{MeToast.i18n.switchLocale(loc);MeToast.configure({locale:loc});MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English',{theme:'dark'});}
catch(e){MeToast.info('已切换语言');}
}
function demoFormat() {
try{
MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000});
}catch(e){MeToast.info('格式化: 1234567 → 1,234,567');}
}
// === 图标网格 ===
(function renderIcons() {
var grid = document.getElementById('icon-grid');
if (!grid) return;
var icons = ['success','error','warning','info','loading','close','check','x','alert','question','star','heart','bell','mail','settings','user','home','search','plus','minus','edit','trash','download','upload','share','link','external','clock','calendar','map','compass','globe','wifi','cloud','sun','moon','zap','activity','cpu','database','server','terminal','code','git','package','layers','grid','list','filter','sort','refresh','sync','power','battery','bluetooth','volume','mic','camera','image','video','music','file','folder','clipboard','save','print','eye','eyeOff','lock','unlock','shield','key','flag','bookmark','tag','gift','award','target','crosshair','move','maximize','minimize','copy','cut','paste','rotateCw','rotateCcw','zoomIn','zoomOut','crop','sliders','toggleLeft','toggleRight','checkCircle','xCircle','alertCircle','infoCircle','helpCircle','alertTriangle','checkSquare','square','circle','triangle','hexagon','octagon','pentagon','diamond'];
icons.forEach(function(ic) {
var btn = document.createElement('button');
btn.className = 'btn btn-outline btn-tiny';
btn.textContent = ic;
btn.title = 'type=' + ic;
btn.onclick = function() { MeToast.success(ic, { type: ic, duration: 2000, showProgress: false }); };
grid.appendChild(btn);
});
})();
</script>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>API 文档 — MetonaToast</title>
<style>
:root {
--bg: #0b1121; --surface: #1a2332; --border: #1e2d4d;
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
--radius: 10px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); color:var(--text); line-height:1.7; display:flex; min-height:100vh; }
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.9em; white-space:nowrap; }
pre { background:#0a0f1a; border:1px solid var(--border); border-radius:8px; padding:14px 18px; overflow-x:auto; font:13px/1.7 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace; color:#cbd5e1; margin:10px 0; }
pre .kw { color:#c084fc; } pre .fn { color:#60a5fa; } pre .s { color:#34d399; } pre .cm { color:#475569; }
a { color:var(--accent2); text-decoration:none; }
.sidebar { width:260px; min-width:260px; background:var(--surface); border-right:1px solid var(--border); padding:24px 0; position:sticky; top:0; height:100vh; overflow-y:auto; }
.sidebar h2 { font-size:15px; padding:0 24px; margin-bottom:16px; color:var(--accent2); }
.sidebar a { display:block; padding:7px 24px; font-size:13px; color:var(--muted); transition:.15s; border-left:2px solid transparent; }
.sidebar a:hover, .sidebar a.active { color:var(--accent2); border-left-color:var(--accent); background:rgba(99,102,241,0.06); }
.sidebar .section { font-size:11px; text-transform:uppercase; letter-spacing:1px; padding:16px 24px 6px; color:var(--muted); font-weight:600; }
.main { flex:1; padding:40px 48px; max-width:950px; }
.main h1 { font-size:30px; font-weight:700; margin-bottom:8px; }
.main h2 { font-size:22px; font-weight:700; margin:44px 0 14px; padding-top:22px; border-top:1px solid var(--border); }
.main h3 { font-size:17px; font-weight:600; margin:28px 0 10px; color:var(--accent2); }
.main p { margin:8px 0; color:var(--muted); font-size:14px; }
.main table { width:100%; border-collapse:collapse; margin:14px 0 20px; font-size:13px; }
.main th, .main td { text-align:left; padding:9px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
.main th { color:var(--muted); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
.main td:first-child { color:var(--accent2); font-family:monospace; font-size:12px; white-space:nowrap; }
.main td:nth-child(3) { font-family:monospace; font-size:11px; color:var(--muted); }
.badge { display:inline-block; padding:1px 7px; border-radius:3px; font-size:10px; font-weight:700; margin-left:6px; vertical-align:middle; }
.badge-new { background:rgba(16,185,129,0.2); color:#34d399; }
.hint { font-size:12px; color:var(--muted); margin-top:2px; }
.return-box { background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:8px; padding:14px 18px; margin:12px 0; }
.return-box h4 { font-size:12px; color:#34d399; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.5px; }
@media (max-width:768px) {
body { flex-direction:column; }
.sidebar { width:100%; min-width:0; height:auto; position:static; border-right:none; border-bottom:1px solid var(--border); padding:12px 16px; }
.sidebar a { display:inline-block; padding:4px 10px; border-left:none; }
.main { padding:24px 20px; }
}
</style>
</head>
<body>
<nav class="sidebar">
<h2>📘 MetonaToast 文档</h2>
<a href="index.html">← 返回首页</a>
<a href="demo.html">🎮 在线演示</a>
<div class="section">基础通知</div>
<a href="#show">show()</a>
<a href="#success">success()</a>
<a href="#error">error()</a>
<a href="#warning">warning()</a>
<a href="#info">info()</a>
<a href="#loading">loading()</a>
<div class="section">高级功能</div>
<a href="#promise">promise()</a>
<a href="#confirm">confirm()</a>
<a href="#prompt">prompt()</a>
<a href="#progress">progress()</a>
<a href="#countdown">countdown()</a>
<a href="#queue">queue()</a>
<a href="#stack">stack()</a>
<a href="#action">action() <span class="badge badge-new">NEW</span></a>
<div class="section">管理方法</div>
<a href="#group">group() <span class="badge badge-new">NEW</span></a>
<a href="#dismiss">dismiss()</a>
<a href="#clear">clear()</a>
<a href="#configure">configure()</a>
<a href="#use">use()</a>
<a href="#destroy">destroy()</a>
<div class="section">配置参考</div>
<a href="#config">全部配置项</a>
<a href="#callbacks">回调函数</a>
<a href="#animations">动画列表</a>
<a href="#themes">主题参考</a>
</nav>
<main class="main">
<h1>API 文档</h1>
<p>MetonaToast v2.0.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
<!-- ===== 基础通知 ===== -->
<h2 id="show">show(message, opts?)</h2>
<p>显示默认类型的 Toast 通知,无特定颜色和图标。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串,或包含 title/message 等属性的配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置对象,覆盖 <a href="#config">全部配置项</a>。仅当 message 为字符串时有效</td></tr>
</table>
<pre><span class="cm">// 字符串形式</span>
MeToast.<span class="fn">show</span>(<span class="s">'默认消息'</span>);
MeToast.<span class="fn">show</span>(<span class="s">'自定义'</span>, { <span class="fn">duration</span>: 2000, <span class="fn">position</span>: <span class="s">'bottom-center'</span> });
<span class="cm">// 对象形式(所有 opts 作为一级属性)</span>
MeToast.<span class="fn">show</span>({ <span class="fn">title</span>: <span class="s">'标题'</span>, <span class="fn">message</span>: <span class="s">'内容'</span>, <span class="fn">duration</span>: 3000 });</pre>
<h2 id="success">success(message, opts?)</h2>
<p>显示成功通知。绿色对勾图标 <code>#10b981</code>type = success。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 success</td></tr>
</table>
<pre>MeToast.<span class="fn">success</span>(<span class="s">'保存成功!'</span>);
MeToast.<span class="fn">success</span>({ <span class="fn">title</span>: <span class="s">'已保存'</span>, <span class="fn">message</span>: <span class="s">'数据已同步'</span> });
Met.<span class="fn">success</span>(<span class="s">'MeToast和Met等价'</span>);</pre>
<h2 id="error">error(message, opts?)</h2>
<p>显示错误通知。红色叉号图标 <code>#ef4444</code>type = error。aria-live 设为 assertive。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 error</td></tr>
</table>
<pre>MeToast.<span class="fn">error</span>(<span class="s">'网络错误,请重试'</span>);
MeToast.<span class="fn">error</span>({ <span class="fn">title</span>: <span class="s">'提交失败'</span>, <span class="fn">message</span>: <span class="s">'服务器不可达'</span> });</pre>
<h2 id="warning">warning(message, opts?)</h2>
<p>显示警告通知。黄色三角图标 <code>#f59e0b</code>type = warning。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 warning</td></tr>
</table>
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre>
<h2 id="info">info(message, opts?)</h2>
<p>显示信息通知。蓝色圆形图标 <code>#3b82f6</code>type = info。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 info</td></tr>
</table>
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre>
<h2 id="loading">loading(message, opts?)</h2>
<p>显示加载状态。type = loadingduration 强制为 0(不自动关闭),closeButton 和 showProgress 强制为 false。返回 LoadingControl 对象。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>加载提示文本或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖</td></tr>
</table>
<pre><span class="kw">const</span> loading = MeToast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
<span class="fn">setTimeout</span>(() => loading.<span class="fn">success</span>(<span class="s">'提交成功!'</span>), 2000);</pre>
<div class="return-box">
<h4>返回 LoadingControl</h4>
<table>
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
<tr><td>success</td><td>(msg, opts?) => ToastInstance</td><td>关闭加载 toast,原地替换为 success 类型</td></tr>
<tr><td>error</td><td>(msg, opts?) => ToastInstance</td><td>替换为 error 类型</td></tr>
<tr><td>info</td><td>(msg, opts?) => ToastInstance</td><td>替换为 info 类型</td></tr>
<tr><td>warning</td><td>(msg, opts?) => ToastInstance</td><td>替换为 warning 类型</td></tr>
<tr><td>update</td><td>(partial) => MeToast</td><td>不关闭加载 toast,原地更新其内容(支持 resetTimerOnUpdate</td></tr>
<tr><td>dismiss</td><td>()</td><td>直接关闭加载 toast 不替换</td></tr>
</table>
</div>
<!-- ===== 高级功能 ===== -->
<h2 id="promise">promise(promise, opts)</h2>
<p>监听 Promise 生命周期。自动显示 loading → 根据 resolve/reject 自动切换 success/error。返回原 Promise,支持 await 获取结果。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>promise</td><td>Promise</td><td></td><td>要监听的 Promise 对象。非 Promise 会打印错误并返回 rejected Promise</td></tr>
<tr><td>opts.loading</td><td>string</td><td>"加载中..."</td><td>加载中显示的文本</td></tr>
<tr><td>opts.success</td><td>string</td><td>"操作成功"</td><td>resolve 后显示的文本</td></tr>
<tr><td>opts.error</td><td>string</td><td>"操作失败"</td><td>reject 后显示的文本</td></tr>
</table>
<pre><span class="kw">try</span> {
<span class="kw">await</span> MeToast.<span class="fn">promise</span>(<span class="fn">fetch</span>(<span class="s">'/api/data'</span>), {
<span class="fn">loading</span>: <span class="s">'加载中...'</span>,
<span class="fn">success</span>: <span class="s">'加载完成!'</span>,
<span class="fn">error</span>: <span class="s">'加载失败'</span>,
});
} <span class="kw">catch</span> (<span class="fn">e</span>) { <span class="cm">/* promise reject 会继续抛出 */</span> }</pre>
<h2 id="confirm">confirm(message, opts?)</h2>
<p>确认对话框。返回 <code>Promise&lt;boolean&gt;</code>。内置 10 秒安全超时,超时自动 resolve(false)。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string</td><td></td><td>对话框消息。非字符串会打印错误并 resolve(false)</td></tr>
<tr><td>opts.confirmText</td><td>string</td><td>"确认"</td><td>确认按钮文字</td></tr>
<tr><td>opts.confirmColor</td><td>string</td><td>"#10b981"</td><td>确认按钮背景色</td></tr>
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型(影响图标和颜色)</td></tr>
</table>
<pre><span class="kw">const</span> ok = <span class="kw">await</span> MeToast.<span class="fn">confirm</span>(<span class="s">'确定删除?'</span>, {
<span class="fn">confirmText</span>: <span class="s">'删除'</span>,
<span class="fn">confirmColor</span>: <span class="s">'#ef4444'</span>,
<span class="fn">cancelText</span>: <span class="s">'保留'</span>,
});
<span class="kw">if</span> (ok) MeToast.<span class="fn">success</span>(<span class="s">'已删除'</span>);</pre>
<h2 id="prompt">prompt(message, opts?)</h2>
<p>输入对话框。返回 <code>Promise&lt;string|null&gt;</code>。按 Enter 或点击提交按钮返回输入值,取消返回 null。内置 10 秒安全超时。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string</td><td></td><td>对话框消息。非字符串会打印错误并 resolve(null)</td></tr>
<tr><td>opts.placeholder</td><td>string</td><td>""</td><td>输入框占位文字</td></tr>
<tr><td>opts.defaultValue</td><td>string</td><td>""</td><td>输入框默认值</td></tr>
<tr><td>opts.inputType</td><td>string</td><td>"text"</td><td>input 标签 type 属性(如 password/email/number</td></tr>
<tr><td>opts.submitText</td><td>string</td><td>"确认"</td><td>提交按钮文字</td></tr>
<tr><td>opts.submitColor</td><td>string</td><td>"#3b82f6"</td><td>提交按钮背景色</td></tr>
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
<tr><td>opts.type</td><td>string</td><td>"info"</td><td>Toast 类型</td></tr>
</table>
<pre><span class="kw">const</span> name = <span class="kw">await</span> MeToast.<span class="fn">prompt</span>(<span class="s">'请输入姓名'</span>, {
<span class="fn">placeholder</span>: <span class="s">'请输入...'</span>,
<span class="fn">defaultValue</span>: <span class="s">'张三'</span>,
<span class="fn">submitText</span>: <span class="s">'确定'</span>,
});
<span class="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre>
<h2 id="progress">progress(message, opts?)</h2>
<p>进度条通知。不自动关闭。返回 ProgressControl 对象以手动更新进度。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>进度提示文本或配置对象</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。type 默认 info</td></tr>
<tr><td>opts.progressColor</td><td>string</td><td>"#3b82f6"</td><td>进度条填充颜色</td></tr>
</table>
<pre><span class="kw">const</span> p = MeToast.<span class="fn">progress</span>(<span class="s">'上传中...'</span>, { <span class="fn">progressColor</span>: <span class="s">'#10b981'</span> });
p.<span class="fn">setProgress</span>(45); <span class="cm">// → 45%</span>
p.<span class="fn">setProgress</span>(90); <span class="cm">// → 90%</span>
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>); <span class="cm">// → 100% → success</span>
<span class="cm">// 或</span>
p.<span class="fn">error</span>(<span class="s">'上传失败'</span>);
p.<span class="fn">dismiss</span>();</pre>
<div class="return-box">
<h4>返回 ProgressControl</h4>
<table>
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
<tr><td>setProgress</td><td>(percent: number)</td><td>设置进度 0~100,自动 clamp。更新进度条宽度和百分比文字</td></tr>
<tr><td>complete</td><td>(message?: string)</td><td>跳到 100%300ms 后替换为 success toast1s 后自动关闭</td></tr>
<tr><td>error</td><td>(message?: string)</td><td>替换为 error toast2s 后自动关闭</td></tr>
<tr><td>dismiss</td><td>()</td><td>直接关闭</td></tr>
</table>
</div>
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
<p>倒计时 Toast。<code>{seconds}</code> 占位符每秒自动替换为剩余秒数。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string</td><td></td><td>消息文本。支持 <code>{seconds}</code> 占位符。非字符串打印错误并返回空控制对象</td></tr>
<tr><td>seconds</td><td>number</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
<tr><td>opts.onComplete</td><td>function</td><td></td><td>倒计时归零时的回调</td></tr>
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型</td></tr>
</table>
<pre>MeToast.<span class="fn">countdown</span>(<span class="s">'{seconds} 秒后执行'</span>, 5, {
<span class="fn">onComplete</span>: () => MeToast.<span class="fn">success</span>(<span class="s">'已执行'</span>),
});</pre>
<div class="return-box">
<h4>返回 CountdownControl</h4>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td>cancel()</td><td>清除计时器并关闭 toast</td></tr>
<tr><td>pause()</td><td>暂停倒计时</td></tr>
<tr><td>resume()</td><td>恢复倒计时</td></tr>
</table>
</div>
<h2 id="queue">queue(messages, opts?)</h2>
<p>顺序逐个显示消息队列。前一条关闭后延时显示下一条。返回 QueueControlthenable + cancel)。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>messages</td><td>Array&lt;string|object&gt;</td><td></td><td>消息数组。可为字符串或带 message/type/duration/onClose 的对象。非数组打印错误并返回空控制对象</td></tr>
<tr><td>opts.delay</td><td>number</td><td>1000</td><td>每条消息关闭后到显示下一条的间隔(ms)</td></tr>
<tr><td>opts.duration</td><td>number</td><td>3000</td><td>每条消息显示时长(ms),可被消息级 duration 覆盖</td></tr>
<tr><td>opts.onClose</td><td>function</td><td></td><td>全部队列完成后的回调。消息级 onClose 和队列级 onClose 都会依次调用</td></tr>
<tr><td>opts.type</td><td>string</td><td></td><td>默认类型</td></tr>
</table>
<pre><span class="kw">const</span> q = MeToast.<span class="fn">queue</span>([
<span class="s">'步骤一'</span>,
{ <span class="fn">message</span>: <span class="s">'步骤二'</span>, <span class="fn">duration</span>: 5000, <span class="fn">type</span>: <span class="s">'warning'</span> },
<span class="s">'步骤三'</span>,
], { <span class="fn">delay</span>: 800, <span class="fn">duration</span>: 2000, <span class="fn">type</span>: <span class="s">'info'</span> });
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
<span class="kw">await</span> q; <span class="cm">// 等待完成(thenable 支持 await</span></pre>
<div class="return-box">
<h4>返回 QueueControl (thenable)</h4>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td>.then(fn, rj)</td><td>Promise.then 代理,支持 await</td></tr>
<tr><td>.catch(rj)</td><td>Promise.catch 代理</td></tr>
<tr><td>.cancel()</td><td>设置取消标志,不再显示下一条消息</td></tr>
</table>
</div>
<h2 id="stack">stack(messages, opts?)</h2>
<p>同时错峰显示多条消息。每条间隔 stagger 毫秒依次出现,全部叠加在屏幕上。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>messages</td><td>Array&lt;string|object&gt;</td><td></td><td>消息数组。可为字符串或带 message/type/duration 的对象。非数组打印错误</td></tr>
<tr><td>opts.stagger</td><td>number</td><td>100</td><td>每条消息之间的显示间隔(ms)</td></tr>
<tr><td>opts.type</td><td>string</td><td></td><td>默认类型</td></tr>
</table>
<pre>MeToast.<span class="fn">stack</span>([<span class="s">'消息1'</span>, <span class="s">'消息2'</span>, { <span class="fn">message</span>: <span class="s">'警告'</span>, <span class="fn">type</span>: <span class="s">'warning'</span> }], {
<span class="fn">stagger</span>: 150,
<span class="fn">type</span>: <span class="s">'info'</span>,
});</pre>
<h2 id="action">action(message, actions, opts?) <span class="badge badge-new">v2.0</span></h2>
<p>Action Toast:内嵌操作按钮。默认 duration=0 不自动关闭,closeButton=true。</p>
<table>
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>message</td><td>string | object</td><td></td><td>消息字符串或配置对象</td></tr>
<tr><td>actions</td><td>ActionButton[]</td><td>[]</td><td>按钮数组。每个按钮可配 text/onClick/color/style/close</td></tr>
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。duration 默认 0closeButton 默认 true</td></tr>
</table>
<table>
<tr><th>ActionButton 字段</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>text</td><td>string</td><td>—(必填)</td><td>按钮显示文字</td></tr>
<tr><td>onClick</td><td>(toast) => void</td><td>—(必填)</td><td>点击回调函数,参数为当前 toast 实例</td></tr>
<tr><td>color</td><td>string</td><td>"#6366f1"</td><td>按钮背景色</td></tr>
<tr><td>style</td><td>object</td><td>{}</td><td>按钮内联样式,可覆盖 colorstyle.background 优先)</td></tr>
<tr><td>close</td><td>boolean</td><td>true</td><td>点击后是否自动关闭 toast。设为 false 可多次点击</td></tr>
</table>
<pre>MeToast.<span class="fn">action</span>(<span class="s">'文件已删除'</span>, [
{ <span class="fn">text</span>: <span class="s">'撤销'</span>, <span class="fn">onClick</span>: () => <span class="fn">restore</span>(), <span class="fn">color</span>: <span class="s">'#3b82f6'</span> },
{ <span class="fn">text</span>: <span class="s">'查看详情'</span>, <span class="fn">onClick</span>: (t) => <span class="fn">openFile</span>(), <span class="fn">color</span>: <span class="s">'#10b981'</span>, <span class="fn">close</span>: false },
]);</pre>
<!-- ===== 管理方法 ===== -->
<h2 id="group">group(name) <span class="badge badge-new">v2.0</span></h2>
<p>创建 Toast 分组,返回 GroupAPI 对象。该对象所有方法自动传入 <code>group: name</code>,按组管理。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>name</td><td>string</td><td>分组名称。GroupAPI 和 dismissGroup 通过此名称关联</td></tr>
</table>
<pre><span class="kw">const</span> orders = MeToast.<span class="fn">group</span>(<span class="s">'orders'</span>);
orders.<span class="fn">success</span>(<span class="s">'订单已创建'</span>);
orders.<span class="fn">error</span>(<span class="s">'支付失败'</span>, { <span class="fn">duration</span>: 5000 });
orders.<span class="fn">count</span>(); <span class="cm">// 该组当前 toast 数量</span>
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭该组全部 toast</span>
<span class="cm">// 也支持主对象关闭</span>
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre>
<div class="return-box">
<h4>返回 GroupAPI</h4>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td>show(msg, opts?)</td><td>等价 MeToast.show,自动注入 group</td></tr>
<tr><td>success(msg, opts?)</td><td>等价 MeToast.success</td></tr>
<tr><td>error(msg, opts?)</td><td>等价 MeToast.error</td></tr>
<tr><td>warning(msg, opts?)</td><td>等价 MeToast.warning</td></tr>
<tr><td>info(msg, opts?)</td><td>等价 MeToast.info</td></tr>
<tr><td>loading(msg, opts?)</td><td>等价 MeToast.loading</td></tr>
<tr><td>action(msg, actions, opts?)</td><td>等价 MeToast.action</td></tr>
<tr><td>dismiss()</td><td>关闭该组所有 toast</td></tr>
<tr><td>count()</td><td>返回该组当前 toast 数量</td></tr>
</table>
</div>
<h2 id="dismiss">dismiss(id?)</h2>
<p>关闭 Toast。无参数则关闭全部。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>id</td><td>string</td><td>可选。Toast 的 id,不传则关闭所有</td></tr>
</table>
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 关闭所有</span>
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 关闭指定</span></pre>
<h2 id="clear">clear(position?)</h2>
<p>按位置清除 Toast。不传参数清除所有位置。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>position</td><td>string</td><td>可选。位置如 'top-right',不传则清除全部</td></tr>
</table>
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre>
<h2 id="configure">configure(opts)</h2>
<p>全局配置,影响后续所有 Toast。theme 和 locale 变化会触发相应副作用(应用主题 CSS / 切换语言)。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>opts</td><td>object</td><td>包含任意 <a href="#config">配置项</a> 的对象</td></tr>
</table>
<pre>MeToast.<span class="fn">configure</span>({
<span class="fn">position</span>: <span class="s">'top-right'</span>,
<span class="fn">duration</span>: 4000,
<span class="fn">theme</span>: <span class="s">'dark'</span>,
<span class="fn">animation</span>: <span class="s">'slide'</span>,
<span class="fn">locale</span>: <span class="s">'zh-CN'</span>,
});</pre>
<h2 id="use">use(plugin)</h2>
<p>安装插件。支持字符串(内置预设名)或插件对象。已被拒绝注册的无效插件会打印错误。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>plugin</td><td>string | object</td><td>预设名 'keyboard'/'persistence'/'accessibility' 或自定义插件对象 { name, version?, install, uninstall? }</td></tr>
</table>
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有 Toast</span>
MeToast.<span class="fn">use</span>(<span class="s">'persistence'</span>); <span class="cm">// 配置自动保存到 localStorage</span>
MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <span class="cm">// 屏幕阅读器实时朗读 toast 内容</span></pre>
<p class="hint">内置插件详情:keyboard 监听 keydown Escape 键;persistence 将配置写入 localStorage 并在页面加载时恢复;accessibility 在 afterShow/afterUpdate 钩子中通过 aria-live 区域朗读 toast 内容。</p>
<h2 id="destroy">destroy()</h2>
<p>完全销毁。关闭所有 Toast、移除所有 DOM 容器和注入的样式标签、清空内存缓存。</p>
<pre>MeToast.<span class="fn">destroy</span>();</pre>
<!-- ===== 配置参考 ===== -->
<h2 id="config">全部配置项</h2>
<p>以下配置可用于 <code>configure()</code><code>init()</code> 或单个 Toast 方法的 opts 参数。带 <span class="badge badge-new">v2.0</span> 标识的为 v2.0 新增。</p>
<table>
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>position</td><td>string</td><td>'top-right'</td><td>位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right</td></tr>
<tr><td>duration</td><td>number</td><td>4000</td><td>显示时长(ms)0=不自动关闭</td></tr>
<tr><td>max</td><td>number</td><td>6</td><td>同一位置最多同时显示条数,超出则关闭最早的</td></tr>
<tr><td>gap</td><td>number</td><td>12</td><td>Toast 之间的间距(px)</td></tr>
<tr><td>offset</td><td>number</td><td>24</td><td>容器到屏幕边缘的距离(px)</td></tr>
<tr><td>pauseOnHover</td><td>boolean</td><td>true</td><td>鼠标悬停时暂停 duration 倒计时和进度条</td></tr>
<tr><td>closeOnClick</td><td>boolean</td><td>true</td><td>点击 Toast 任意位置关闭。关闭按钮始终触发关闭</td></tr>
<tr><td>draggable</td><td>boolean</td><td>true</td><td>允许拖拽关闭。拖拽超过 120px 触发关闭</td></tr>
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示 duration 倒计时进度条</td></tr>
<tr><td>progressDirection</td><td>string</td><td>'horizontal'</td><td>进度条方向:horizontal(底部水平) / vertical(右侧垂直)</td></tr>
<tr><td>icon</td><td>boolean</td><td>true</td><td>显示类型对应图标(success→对勾等)</td></tr>
<tr><td>closeButton</td><td>boolean</td><td>true</td><td>显示右上角关闭 × 按钮</td></tr>
<tr><td>theme</td><td>string</td><td>'auto'</td><td>主题:light / dark / auto(跟随系统)/ warm / 自定义注册名</td></tr>
<tr><td>animation</td><td>string</td><td>'slide'</td><td>入场动画名称,支持 CSS 动画列表见下</td></tr>
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 CSS z-index</td></tr>
<tr><td>width</td><td>number|string</td><td>360</td><td>Toast 宽度。数字表示 px</td></tr>
<tr><td>className</td><td>string</td><td>''</td><td>附加到 Toast 元素上的 CSS 类名</td></tr>
<tr><td>style</td><td>object</td><td>{}</td><td>附加到 Toast 元素上的内联样式对象</td></tr>
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码(zh-CN / en-US 等)</td></tr>
<tr><td>resetTimerOnUpdate <span class="badge badge-new">v2.0</span></td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
<tr><td>notifyWhenHidden <span class="badge badge-new">v2.0</span></td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
<tr><td>render <span class="badge badge-new">v2.0</span></td><td>function</td><td></td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
</table>
<h2 id="callbacks">回调函数</h2>
<table>
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
<tr><td>onShow</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 创建并播放入场动画后</td></tr>
<tr><td>onClose</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 被移除后(离场动画完成时)</td></tr>
<tr><td>onClick</td><td>(toast: ToastInstance) => void</td><td>Toast 被点击时(closeOnClick 为 true 时还会自动关闭)</td></tr>
<tr><td>onUpdate <span class="badge badge-new">v2.0</span></td><td>(toast: ToastInstance) => void</td><td>Toast 内容通过 update() 更新后</td></tr>
</table>
<h2 id="animations">动画列表</h2>
<p>配置 <code>animation</code> 的值,支持以下 CSS 动画。未在此列表中的值将 fallback 到 slide。</p>
<table>
<tr><th>名称</th><th>效果</th><th>时长</th></tr>
<tr><td>slide</td><td>从右侧滑入 + 回弹</td><td>400ms</td></tr>
<tr><td>fade</td><td>纯淡入 + blur→清晰</td><td>500ms</td></tr>
<tr><td>scale</td><td>弹性放大 (0.55→1.07→1)</td><td>450ms</td></tr>
<tr><td>bounce</td><td>从天而降四段弹跳</td><td>650ms</td></tr>
<tr><td>flip</td><td>3D 翻转入场 + 回摆</td><td>500ms</td></tr>
<tr><td>rotate</td><td>旋转摇摆进入</td><td>500ms</td></tr>
<tr><td>zoom</td><td>从中心爆发式弹出</td><td>500ms</td></tr>
<tr><td>slideUp</td><td>从下方弹入</td><td>400ms</td></tr>
<tr><td>slideDown</td><td>从上方弹入</td><td>400ms</td></tr>
<tr><td>slideLeft</td><td>从左侧滑入</td><td>400ms</td></tr>
<tr><td>slideRight</td><td>从右侧滑入</td><td>400ms</td></tr>
</table>
<h2 id="themes">主题参考</h2>
<pre><span class="cm">// 内置主题</span>
light <span class="cm">— 白色玻璃质感</span>
dark <span class="cm">— 深色玻璃质感</span>
auto <span class="cm">— 跟随系统主题设置</span>
warm <span class="cm">— 暖色调</span>
<span class="cm">// 注册自定义主题</span>
MeToast.themes.<span class="fn">registerTheme</span>(<span class="s">'ocean'</span>, {
<span class="fn">bg</span>: <span class="s">'rgba(240,249,255,0.96)'</span>,
<span class="fn">text</span>: <span class="s">'#0c4a6e'</span>,
<span class="fn">border</span>: <span class="s">'rgba(14,165,233,0.2)'</span>,
<span class="fn">shadow</span>: <span class="s">'0 10px 36px -10px rgba(14,165,233,0.18)'</span>,
<span class="fn">hoverShadow</span>: <span class="s">'0 14px 48px -10px rgba(14,165,233,0.22)'</span>,
<span class="fn">progressBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
<span class="fn">closeHoverBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
});
MeToast.themes.<span class="fn">switchTheme</span>(<span class="s">'ocean'</span>);</pre>
</main>
<script src="../dist/metona-toast.js"></script>
<script>
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
document.querySelectorAll('.sidebar a[href^="#"]').forEach(a => {
a.addEventListener('click', () => {
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
a.classList.add('active');
});
});
</script>
</body>
</html>
-973
View File
@@ -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 提取的文件操作 hookopen/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<string, Processor>();\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<T>(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<T extends (...args: any[]) => any>(fn: T, ms: number): T {\n let timer: ReturnType<typeof setTimeout>;\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 中 <ErrorBoundary><App /></ErrorBoundary>"
},
"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<boolean>(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 ? <ConfirmDialog .../> : 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 依赖 AA 需先完成)。",
"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": [
"保留现有销毁重建逻辑作为 fallbackfeature 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-srcCodeMirror 依赖)",
"分步启用 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): 高级功能 — 锦上添花"
]
}
}
-248
View File
@@ -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",
"预览区防抖机制(150msPF-05"
],
"status": "completed"
},
{
"phase": 3,
"name": "代码架构改进",
"items": [
"sidebarStore 精简与 loadFromDB 优化",
"useTheme 统一由 useSettingsInit 管理(AR-04",
"统一错误处理模块 errorHandler.tsCQ-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 审查流程"
]
}
}
-593
View File
@@ -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<string, Processor> 按 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 内联样式"
}
]
}
}
+3 -14
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.3.13",
"version": "0.4.1",
"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.10",
"@metona-team/metona-toast": "^2.0.1",
"dexie": "^4.0.11",
"metona-toast": "file:../MetonaToast",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+8 -49
View File
@@ -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 (
<button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={toggleAutoSaveExternal}
>
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</button>
)
}
export function App() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
@@ -60,8 +33,7 @@ export function App() {
const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode } = useSettings()
const { themeMode, cycleTheme } = useTheme()
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<boolean> => {
@@ -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() {
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile} onSave={handleSave}
viewMode={viewMode} onViewModeChange={saveViewMode}
darkMode={darkMode} onToggleDark={toggleDarkMode}
themeMode={themeMode} onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/>
<div id="workspace">
<Sidebar />
@@ -139,16 +101,13 @@ export function App() {
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>}
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
<StatusBar />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
+179 -99
View File
@@ -1,127 +1,207 @@
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, MarkdownEditorOptions, EditMode, ThemeName } 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'
import type { ThemeMode } from '../../types/settings'
interface EditorProps {
darkMode: boolean
// v0.1.9 demo 验证过但类型声明未收录的配置项
interface ExtendedEditorOptions extends MarkdownEditorOptions {
lineNumbers?: boolean
autoBrackets?: boolean
}
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
interface EditorProps {
themeMode: ThemeMode
onAppSave?: () => void
}
/** 将应用 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'
}
/** 预设插件(字符串形式,与 demo 一致) */
const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML
]
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: 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<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(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)
})
})
const container = containerRef.current
if (!container) return
const config: ExtendedEditorOptions = {
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: themeMode as ThemeName,
placeholder: '在此输入 Markdown 内容...',
spellcheck: false,
tabSize: 2,
wordCount: true,
autofocus: true,
lineNumbers: true,
autoBrackets: true,
readOnly: viewMode === 'preview',
plugins: EDITOR_PLUGINS,
// 使用 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 })
},
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
onSave: () => {
onAppSave?.()
}
}
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Save current tab state on unmount or tab switch
useEffect(() => {
const editor = MeEditor.create(container, config)
editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
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 {
// 全局主题(documentElement + localStorage
MeEditor.setTheme(themeMode)
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode)
} catch {
// 容错
}
}, [themeMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
// 预览模式设为只读
editor.setReadOnly(viewMode === 'preview')
}, [viewMode])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} />
{showSearch && (
<SearchReplace
getView={getView}
onClose={() => setShowSearch(false)}
/>
)}
<div ref={containerRef} className="milkdown-wrapper" />
<div ref={containerRef} className="metona-editor-wrapper" />
</div>
)
})
@@ -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(<T,>(command: $Command<T>, 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 (
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrongCommand)}
title="粗体 (Ctrl+B)"
aria-label="粗体"
>
<strong>B</strong>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleEmphasisCommand)}
title="斜体 (Ctrl+I)"
aria-label="斜体"
>
<em>I</em>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrikethroughCommand)}
title="删除线"
aria-label="删除线"
>
<s>S</s>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleInlineCodeCommand)}
title="行内代码"
aria-label="行内代码"
>
{'</>'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(1)}
title="标题"
aria-label="一级标题"
>
H1
</button>
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(2)}
title="二级标题"
aria-label="二级标题"
>
H2
</button>
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(3)}
title="三级标题"
aria-label="三级标题"
>
H3
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBulletListCommand)}
title="无序列表"
aria-label="无序列表"
>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInOrderedListCommand)}
title="有序列表"
aria-label="有序列表"
>
1.
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBlockquoteCommand)}
title="引用"
aria-label="引用"
>
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)}
title="代码块"
aria-label="代码块"
>
{'{ }'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
title="链接"
aria-label="插入链接"
>
🔗
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
title="图片"
aria-label="插入图片"
>
🖼
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(insertHrCommand)}
title="分割线"
aria-label="插入分割线"
>
</button>
</div>
)
})
EditorToolbar.displayName = 'EditorToolbar'
@@ -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<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
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<SearchPluginState>('search-highlight')
function createSearchPlugin(): Plugin<SearchPluginState> {
return new Plugin<SearchPluginState>({
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<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(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
}
}
-29
View File
@@ -38,35 +38,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
)
}
export function EditMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
<circle cx="18" cy="6" r="1" fill="currentColor"/>
</svg>
)
}
export function PreviewMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="12" cy="12" r="1" fill="currentColor"/>
</svg>
)
}
export function SourceMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
)
}
export function Moon({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
-102
View File
@@ -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<HTMLDivElement>(null)
const requestIdRef = useRef(0)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLDivElement>) => {
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 (
<div
ref={previewRef}
id="preview"
className="markdown-body"
role="region"
aria-label="Markdown预览"
aria-live="polite"
aria-busy={rendering}
onClick={handleClick}
>
{rendering && html === '' && (
<div className="preview-loading" aria-label="正在渲染Markdown">
<LoadingSpinner size="medium" label="正在渲染..." />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
})
Preview.displayName = 'Preview'
@@ -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<HTMLInputElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
// Keep latest state in refs for use in callbacks without stale closures
const matchesRef = useRef<SearchMatch[]>([])
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<HTMLInputElement>) => {
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 (
<div
ref={panelRef}
className="search-replace-panel"
role="search"
aria-label="搜索和替换"
onKeyDown={handleKeyDown}
>
<div className="search-row">
<div className="search-input-group">
<input
ref={searchInputRef}
type="text"
className="search-input"
placeholder="搜索..."
value={query}
onChange={handleQueryChange}
aria-label="搜索文本"
/>
<span className="search-count" aria-live="polite">
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
</span>
</div>
<button
className={`search-btn ${caseSensitive ? 'active' : ''}`}
onClick={toggleCaseSensitive}
title="区分大小写 (点击切换)"
aria-label="区分大小写"
aria-pressed={caseSensitive}
>
Aa
</button>
<button
className={`search-btn ${useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="使用正则表达式 (点击切换)"
aria-label="正则表达式"
aria-pressed={useRegex}
>
.*
</button>
<button
className="search-btn"
onClick={() => navigateMatch(-1)}
title="上一个 (Shift+Enter)"
aria-label="上一个匹配"
disabled={matchCount === 0}
>
</button>
<button
className="search-btn"
onClick={() => navigateMatch(1)}
title="下一个 (Enter)"
aria-label="下一个匹配"
disabled={matchCount === 0}
>
</button>
<button
className="search-btn"
onClick={() => setShowReplace(!showReplace)}
title="切换替换模式 (Ctrl+H)"
aria-label="替换模式"
aria-expanded={showReplace}
>
</button>
<button
className="search-btn search-close"
onClick={() => {
const view = getView()
if (view) clearSearchDecorations(view)
onClose()
}}
title="关闭 (Esc)"
aria-label="关闭搜索"
>
</button>
</div>
{showReplace && (
<div className="search-row">
<div className="search-input-group">
<input
type="text"
className="search-input"
placeholder="替换..."
value={replacement}
onChange={e => setReplacement(e.target.value)}
aria-label="替换文本"
/>
</div>
<button
className="search-btn replace-btn"
onClick={replaceCurrent}
title="替换当前"
aria-label="替换当前匹配"
disabled={currentMatch < 0}
>
</button>
<button
className="search-btn replace-btn"
onClick={replaceAll}
title="全部替换"
aria-label="替换全部匹配"
disabled={matchCount === 0}
>
</button>
</div>
)}
</div>
)
})
SearchReplace.displayName = 'SearchReplace'
@@ -1 +0,0 @@
export { SearchReplace } from './SearchReplace'
+36 -31
View File
@@ -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<HTMLElement | null>(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
// 导航失败,静默忽略
}
}, [])
@@ -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<HTMLTextAreaElement>(null)
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
const pendingSelectionRef = useRef<number | null>(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<HTMLTextAreaElement>) => {
if (!activeTabId) return
updateTabContent(activeTabId, e.target.value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<div className={`source-editor-wrapper${darkMode ? ' source-editor-dark' : ''}`}>
<textarea
ref={textareaRef}
className="source-editor-textarea"
value={activeTab?.content ?? ''}
onChange={handleChange}
onKeyDown={handleKeyDown}
spellCheck={false}
wrap="off"
aria-label="Markdown 源码编辑器"
placeholder="在此输入 Markdown 内容..."
/>
</div>
)
})
SourceEditor.displayName = 'SourceEditor'
@@ -1,42 +0,0 @@
import React, { useMemo } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../../stores/statusBarStore'
/**
* StatusBar — 纯渲染层
*
* 不包含任何业务逻辑。所有状态栏项由各功能模块通过
* useStatusBarStore.register() 注入,本组件按 alignment + priority 排序渲染。
* 每个 item.Component 是独立 React 组件,可自由使用 hooks。
*/
export const StatusBar = React.memo(function StatusBar() {
const items = useStatusBarStore(s => s.items)
const sorted = useMemo(() => {
const list = Object.values(items)
if (list.length === 0) return { left: [] as StatusBarItem[], right: [] as StatusBarItem[] }
const byAlignment = (a: 'left' | 'right') =>
list.filter(i => i.alignment === a).sort((x, y) => x.priority - y.priority)
return { left: byAlignment('left'), right: byAlignment('right') }
}, [items])
return (
<div id="statusbar" role="status" aria-live="polite">
<div className="status-left">
{sorted.left.map(({ id, Component }) => (
<Component key={id} />
))}
</div>
<div className="status-right">
{sorted.right.map(({ id, Component }, i) => (
<React.Fragment key={id}>
{i > 0 && <span className="status-divider">|</span>}
<Component />
</React.Fragment>
))}
</div>
</div>
)
})
StatusBar.displayName = 'StatusBar'
@@ -1,64 +0,0 @@
import { useTabStore } from '../../stores/tabStore'
import { useEditorStore } from '../../stores/editorStore'
import { getFileName } from '../../lib/fileUtils'
import { useDocStats } from '../../hooks/useDocStats'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
/** 文件信息 — 文件名或"就绪" */
export function FileInfoItem() {
const activeTab = useTabStore(s => s.getActiveTab())
return (
<span id="status-text">
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
</span>
)
}
/** 加载指示器 — 打开文件/加载目录/渲染中 */
export function LoadingItem() {
const loadingStates = useEditorStore(s => s.loadingStates)
const hasLoading = Object.values(loadingStates).some(Boolean)
if (!hasLoading) return null
const label = loadingStates['file-open']
? '正在打开文件...'
: loadingStates['dir-load']
? '正在加载目录...'
: loadingStates['markdown-render']
? '正在渲染...'
: ''
return (
<span className="status-loading" aria-busy="true">
<LoadingSpinner size="small" />
<span>{label}</span>
</span>
)
}
/** 文档统计 — 行数 / 词数 / 字符数 */
export function DocStatsItem() {
const activeTab = useTabStore(s => s.getActiveTab())
const stats = useDocStats(activeTab?.content)
if (!activeTab) return null
return (
<>
<span className="status-stat" title="行数" aria-label={`${stats.lines}`}>{stats.lines} </span>
<span className="status-divider">|</span>
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>{stats.words} </span>
<span className="status-divider">|</span>
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>{stats.chars} </span>
</>
)
}
/** 静态项 — 编码 */
export function EncodingItem() {
return <span id="status-encoding">UTF-8</span>
}
/** 静态项 — 语言 */
export function LangItem() {
return <span id="status-lang">Markdown</span>
}
+36 -36
View File
@@ -1,18 +1,34 @@
import React from 'react'
import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons'
import type { ViewMode } from '../../types/settings'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
import type { ThemeMode } from '../../types/settings'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
darkMode: boolean
onToggleDark: () => void
themeMode: ThemeMode
onCycleTheme: () => void
onShowAbout: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
}
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
const THEME_LABELS: Record<ThemeMode, string> = {
light: '亮色',
dark: '暗色',
warm: '暖色',
}
/**
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/
export const Toolbar = React.memo(function Toolbar({
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
isAutoSaving, autoSaveEnabled, onToggleAutoSave
}: ToolbarProps) {
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
@@ -26,39 +42,23 @@ export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, o
</button>
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
onClick={() => onViewModeChange('editor')}
title="WYSIWYG 编辑 (Ctrl+1)"
aria-label="WYSIWYG 编辑模式"
aria-pressed={viewMode === 'editor'}
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
>
<EditMode size={18} />
<span></span>
</button>
<button
className={`toolbar-btn ${viewMode === 'source' ? 'active' : ''}`}
onClick={() => onViewModeChange('source')}
title="源码编辑 (Ctrl+3)"
aria-label="源码编辑模式"
aria-pressed={viewMode === 'source'}
>
<SourceMode size={18} />
<span></span>
</button>
<button
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
onClick={() => onViewModeChange('preview')}
title="预览 (Ctrl+2)"
aria-label="预览模式"
aria-pressed={viewMode === 'preview'}
>
<PreviewMode size={18} />
<span></span>
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
<button
className="toolbar-btn"
onClick={onCycleTheme}
title={`当前 ${nextLabel} — 点击切换`}
aria-label={`当前${nextLabel}主题,点击切换`}
>
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
</button>
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
<Info size={18} />
+1 -1
View File
@@ -18,7 +18,7 @@ export interface ActiveTabRecord {
export interface SettingsRecord {
id: string // 固定为 'default'
darkMode: boolean
themeMode: 'light' | 'dark' | 'warm'
viewMode: 'editor' | 'preview' | 'source'
sidebarCollapsed: boolean
sidebarWidth: number
+1 -1
View File
@@ -8,7 +8,7 @@ export const settingsRepository = {
const record: SettingsRecord | undefined = await db.settings.get('default')
if (record) {
return {
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
+11 -1
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
@@ -8,12 +8,16 @@ import { showToast } from '../lib/toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
*/
export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const savingGate = useRef(false)
const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return
@@ -33,6 +37,9 @@ export function useFileOperations() {
}, [createTab, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
if (savingGate.current) return
savingGate.current = true
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
@@ -47,6 +54,9 @@ export function useFileOperations() {
} catch (error) {
logError('保存文件失败', error)
showToast('保存失败', 'error')
} finally {
// 300ms 后释放锁,允许下次保存
setTimeout(() => { savingGate.current = false }, 300)
}
}, [getActiveTab])
+8 -9
View File
@@ -1,19 +1,20 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
/**
* 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
*/
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const setViewMode = useEditorStore(s => s.setViewMode)
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return }
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
@@ -28,7 +29,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
// mruStack[0] 是最近一个被切走的标签(即上一个活动标签)
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
@@ -36,7 +36,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
return
}
}
// MRU 栈为空或不合法时回退到顺序切换
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = e.shiftKey
? (idx - 1 + tabs.length) % tabs.length
@@ -45,7 +44,7 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
}
return
}
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
}, [handleOpenFile, handleSave, handleSaveAs])
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
-21
View File
@@ -1,21 +0,0 @@
import { useCallback } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings'
/**
* AR-04: 设置 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责视图模式的读取和保存
*/
export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const saveViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode)
settingsRepository.save({ viewMode: mode })
}, [setViewMode])
return { viewMode, saveViewMode }
}
+7 -8
View File
@@ -6,11 +6,11 @@ import { logError } from '../lib/errorHandler'
/**
* AR-04: 统一设置加载 hook
* 一次性从 IndexedDB 加载所有设置,分发到各 store
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
* 一次性从 IndexedDB 加载所有设置,分发到各 store
* 替代各 hook 各自独立加载设置的模式
*/
export function useSettingsInit() {
const setDarkMode = useEditorStore(s => s.setDarkMode)
const setThemeMode = useEditorStore(s => s.setThemeMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
@@ -20,11 +20,10 @@ export function useSettingsInit() {
settingsRepository.load()
.then((settings) => {
// 主题
// 主题:优先使用保存的设置,否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
setDarkMode(darkMode)
document.documentElement.classList.toggle('dark', darkMode)
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
setThemeMode(themeMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
@@ -42,7 +41,7 @@ export function useSettingsInit() {
.catch((error: unknown) => {
logError('加载设置失败', error)
})
}, [setDarkMode, setViewMode])
}, [setThemeMode, setViewMode])
return { isInitialized }
}
-21
View File
@@ -1,21 +0,0 @@
import { useEffect } from 'react'
import { useStatusBarStore, type StatusBarItem } from '../stores/statusBarStore'
/**
* 便捷 hook — 挂载时注册一个状态栏项,卸载时自动清除。
*
* 适合在 App.tsx 或自定义 hook 中注册需要响应式更新的状态栏项。
* 当 item 对象变化时(通过 deps 控制),旧项会被新项替换。
*
* @param item - 状态栏项(含 Component 函数组件)
*/
export function useStatusBarItem(item: StatusBarItem | null): void {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
if (!item) return
register(item)
return () => unregister(item.id)
}, [item, register, unregister])
}
-36
View File
@@ -1,36 +0,0 @@
import { useEffect } from 'react'
import { useStatusBarStore } from '../stores/statusBarStore'
import {
FileInfoItem,
LoadingItem,
DocStatsItem,
EncodingItem,
LangItem
} from '../components/StatusBar/StatusBarItems'
/**
* useDefaultStatusBarItems — 注册所有默认状态栏项(不含 auto-save)。
*
* auto-save 项由 App.tsx 单独注册,因为它需要从 autoSaveStore
* 读取状态并通过 toggleAutoSaveExternal() 触发切换。
*/
export function useDefaultStatusBarItems() {
const register = useStatusBarStore(s => s.register)
const unregister = useStatusBarStore(s => s.unregister)
useEffect(() => {
register({ id: 'statusbar.file-info', alignment: 'left', priority: 0, Component: FileInfoItem })
register({ id: 'statusbar.loading', alignment: 'left', priority: 1, Component: LoadingItem })
register({ id: 'statusbar.doc-stats', alignment: 'right', priority: 100, Component: DocStatsItem })
register({ id: 'statusbar.encoding', alignment: 'right', priority: 300, Component: EncodingItem })
register({ id: 'statusbar.lang', alignment: 'right', priority: 400, Component: LangItem })
return () => {
unregister('statusbar.file-info')
unregister('statusbar.loading')
unregister('statusbar.doc-stats')
unregister('statusbar.encoding')
unregister('statusbar.lang')
}
}, [register, unregister])
}
+80 -13
View File
@@ -1,23 +1,90 @@
import { useEffect } from 'react'
import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import { syncToastTheme } from '../lib/toast'
import type { ThemeMode } from '../types/settings'
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
function hexWithAlpha(hex: string, alpha: number): string {
if (!hex || !hex.startsWith('#')) return hex
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
}
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
function syncAppColorsToEditor(): void {
try {
const vars = MeEditor.themes.exportCSSVars()
if (!vars || typeof vars !== 'object') return
const root = document.documentElement
const set = (name: string, value: string | undefined) => {
if (value) root.style.setProperty(name, value)
}
const accent = vars['--md-accent'] ?? '#1a73e8'
// 直接映射 — 文本/边框用纯色
set('--bg', vars['--md-bg'])
set('--text', vars['--md-text'])
set('--border', vars['--md-border'])
set('--primary', accent)
set('--primary-dark', accent)
set('--text-secondary', vars['--md-muted'])
set('--text-tertiary', vars['--md-muted'])
set('--code-bg', vars['--md-code-bg'])
// 背景派生 — 用 accent 的低透明度版本
const accentBg = hexWithAlpha(accent, 0.12)
set('--primary-light', accentBg)
set('--sidebar-active', accentBg)
set('--sidebar-bg', vars['--md-bg'])
set('--sidebar-border', vars['--md-border'])
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--search-border', vars['--md-border'])
// 阴影根据主题适配
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
root.style.setProperty('--shadow', isDark
? '0 1px 3px rgba(0,0,0,0.3)'
: '0 1px 3px rgba(0,0,0,0.08)')
root.style.setProperty('--shadow-lg', isDark
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 4px 12px rgba(0,0,0,0.1)')
} catch { /* 容错 */ }
}
const THEME_TO_TOAST: Record<ThemeMode, string> = {
light: 'light',
dark: 'dark',
warm: 'warm',
}
/**
* AR-04: 主题 hook
* 不再独立加载设置(由 useSettingsInit 统一加载)
* 仅负责主题切换时的同步和保存
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
* 应用颜色自动同步编辑器主题,保持一致。
*/
export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode)
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
const themeMode = useEditorStore(s => s.themeMode)
const cycleTheme = useEditorStore(s => s.cycleTheme)
// darkMode 变化时同步 class 并保存
const handleCycleTheme = useCallback(() => {
const next = cycleTheme()
// 同步编辑器全局主题
MeEditor.setTheme(next)
// 同步 Toast 主题
import('../lib/toast').then(({ MeToast }) => {
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
})
// 持久化
settingsRepository.save({ themeMode: next })
}, [cycleTheme])
// themeMode 变化时同步应用颜色
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
syncToastTheme(darkMode)
}, [darkMode])
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
syncAppColorsToEditor()
}, [themeMode])
return { darkMode, toggleDarkMode }
return { themeMode, cycleTheme: handleCycleTheme }
}
+15
View File
@@ -138,3 +138,18 @@ export async function renderMarkdown(content: string, filePath?: string | null):
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
/**
* 同步版本 — 供 MetonaEditor 的 render 钩子使用。
* 所有 unified 插件均为同步转换器,可以在 render 钩子中同步调用。
*/
export function renderMarkdownSync(content: string, filePath?: string | null): string {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = processor.processSync(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import MeToast from 'metona-toast'
import MeToast from '@metona-team/metona-toast'
/**
* MeToast 全局配置
@@ -5,7 +5,7 @@ describe('editorStore', () => {
beforeEach(() => {
useEditorStore.setState({
viewMode: 'editor',
darkMode: false,
themeMode: 'light',
externallyModified: null,
loadingStates: {},
})
@@ -24,29 +24,36 @@ describe('editorStore', () => {
})
})
describe('setDarkMode', () => {
it('should enable dark mode', () => {
useEditorStore.getState().setDarkMode(true)
expect(useEditorStore.getState().darkMode).toBe(true)
describe('themeMode', () => {
it('should default to light', () => {
expect(useEditorStore.getState().themeMode).toBe('light')
})
it('should disable dark mode', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().setDarkMode(false)
expect(useEditorStore.getState().darkMode).toBe(false)
it('should set theme mode', () => {
useEditorStore.getState().setThemeMode('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm')
})
})
describe('toggleDarkMode', () => {
it('should toggle from false to true', () => {
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(true)
describe('cycleTheme', () => {
it('should cycle light → dark', () => {
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should toggle from true to false', () => {
useEditorStore.setState({ darkMode: true })
useEditorStore.getState().toggleDarkMode()
expect(useEditorStore.getState().darkMode).toBe(false)
it('should cycle dark → warm', () => {
useEditorStore.setState({ themeMode: 'dark' })
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
})
it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light')
})
})
+22 -14
View File
@@ -1,30 +1,32 @@
import { create } from 'zustand'
import type { EditorView } from '@milkdown/prose/view'
import type { ViewMode } from '../types/settings'
import type { ViewMode, ThemeMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter/setter for the ProseMirror EditorView
// Module-level getter for the MetonaEditor instance
// Used by OutlinePanel for heading navigation
let _getView: (() => EditorView | null) | null = null
let _getEditor: (() => MarkdownEditor | null) | null = null
export function setEditorViewGetter(fn: () => EditorView | null) {
_getView = fn
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getEditor = fn
}
export function getEditorView(): EditorView | null {
return _getView ? _getView() : null
export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null
}
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
interface EditorState {
viewMode: ViewMode
darkMode: boolean
themeMode: ThemeMode
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null
// UX-02: 全局加载状态
loadingStates: Record<string, boolean>
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
toggleDarkMode: () => void
setThemeMode: (mode: ThemeMode) => void
cycleTheme: () => ThemeMode
setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void
@@ -33,13 +35,19 @@ interface EditorState {
export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor',
darkMode: false,
themeMode: 'light',
externallyModified: null,
loadingStates: {},
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
cycleTheme: () => {
const current = get().themeMode
const idx = THEME_CYCLE.indexOf(current)
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
set({ themeMode: next })
return next
},
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) => set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading }
-47
View File
@@ -1,47 +0,0 @@
import { create } from 'zustand'
import type { ComponentType } from 'react'
/**
* 状态栏扩展点架构
*
* 功能模块通过 register/unregister 注入自己的状态栏组件,
* StatusBar 只负责按 alignment + priority 排序渲染。
* 避免 StatusBar 随功能膨胀,实现类似 VS Code contributions 的注册模式。
*
* 每个 StatusBarItem 的 Component 是独立的 React 函数组件(无 props),
* 内部使用 hooks 读取所需 store 状态。组件在 StatusBar 的渲染中挂载,
* 因此 hooks 调用在合法的 React 组件上下文中。
*/
export interface StatusBarItem {
id: string
alignment: 'left' | 'right'
/** 同侧排序权重,数字越小越靠左(left)或越靠中(right) */
priority: number
Component: ComponentType
}
interface StatusBarState {
/** 以 id 为 key,方便覆盖和删除 */
items: Record<string, StatusBarItem>
register: (item: StatusBarItem) => void
unregister: (id: string) => void
}
export const useStatusBarStore = create<StatusBarState>((set, get) => ({
items: {},
register: (item: StatusBarItem) => {
const { items } = get()
if (items[item.id]?.Component === item.Component) return
set({ items: { ...items, [item.id]: item } })
},
unregister: (id: string) => {
const { items } = get()
if (!(id in items)) return
const next = { ...items }
delete next[id]
set({ items: next })
}
}))
+9 -428
View File
@@ -121,236 +121,13 @@ body {
overflow: hidden;
}
/* Editor Toolbar */
.editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
overflow-x: auto;
}
.editor-toolbar::-webkit-scrollbar {
height: 0;
}
.toolbar-btn-sm {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 26px;
padding: 0 6px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.toolbar-btn-sm:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.toolbar-divider-sm {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
}
/* Milkdown Wrapper */
.milkdown-wrapper {
/* MetonaEditor Wrapper */
.metona-editor-wrapper {
flex: 1;
overflow: hidden;
height: 100%;
}
.milkdown-wrapper .milkdown {
height: 100%;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
background: var(--bg);
}
.milkdown-wrapper .milkdown .editor {
overflow: auto;
height: 100%;
padding: 12px 16px;
outline: none;
user-select: text;
-webkit-user-select: text;
line-height: 1.7;
}
.milkdown-wrapper .milkdown .editor p {
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor h1,
.milkdown-wrapper .milkdown .editor h2,
.milkdown-wrapper .milkdown .editor h3,
.milkdown-wrapper .milkdown .editor h4,
.milkdown-wrapper .milkdown .editor h5,
.milkdown-wrapper .milkdown .editor h6 {
font-family: var(--font-ui);
font-weight: 600;
margin: 1em 0 0.5em;
line-height: 1.3;
color: var(--text);
}
.milkdown-wrapper .milkdown .editor h1 {
font-size: 1.6em;
}
.milkdown-wrapper .milkdown .editor h2 {
font-size: 1.4em;
}
.milkdown-wrapper .milkdown .editor h3 {
font-size: 1.2em;
}
.milkdown-wrapper .milkdown .editor blockquote {
border-left: 3px solid var(--primary);
padding-left: 12px;
margin: 0.5em 0;
color: var(--text-secondary);
}
.milkdown-wrapper .milkdown .editor code {
font-family: var(--font-mono);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.milkdown-wrapper .milkdown .editor pre {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 12px 16px;
overflow-x: auto;
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.milkdown-wrapper .milkdown .editor ul,
.milkdown-wrapper .milkdown .editor ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.milkdown-wrapper .milkdown .editor li {
margin: 0.25em 0;
}
.milkdown-wrapper .milkdown .editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
.milkdown-wrapper .milkdown .editor a {
color: var(--primary);
text-decoration: none;
}
.milkdown-wrapper .milkdown .editor a:hover {
text-decoration: underline;
}
.milkdown-wrapper .milkdown .editor img {
max-width: 100%;
border-radius: var(--radius);
}
/* ProseMirror selection */
.milkdown-wrapper .milkdown .editor .ProseMirror-selectednode {
outline: 2px solid var(--primary);
border-radius: 2px;
}
.milkdown-wrapper .milkdown .editor ::selection {
background: rgba(26, 115, 232, 0.3);
}
:root.dark .milkdown-wrapper .milkdown .editor ::selection {
background: rgba(100, 160, 255, 0.35);
}
/* ProseMirror cursor */
.milkdown-wrapper .milkdown .editor .ProseMirror-focused {
outline: none;
}
/* Dark mode styles for Milkdown */
.milkdown-wrapper.milkdown-dark .milkdown {
background: var(--bg);
color: var(--text);
}
.milkdown-wrapper.milkdown-dark .milkdown .editor blockquote {
border-left-color: var(--primary);
}
/* Preview Panel */
#preview-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
background: var(--bg);
}
#preview {
padding: 24px 32px;
user-select: text;
cursor: text;
}
/* Source Editor */
.source-editor-wrapper {
flex: 1;
display: flex;
min-width: 0;
min-height: 0;
}
.source-editor-textarea {
flex: 1;
border: none;
outline: none;
resize: none;
padding: 16px 20px;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
tab-size: 2;
color: #1e1e1e;
background: #fafafa;
user-select: text;
cursor: text;
}
.source-editor-dark .source-editor-textarea {
color: #d4d4d4;
background: #1e1e1e;
}
/* Tab Bar */
#tab-bar {
height: 36px;
@@ -571,53 +348,21 @@ body {
color: #ffd54f;
}
/* Status Bar */
#statusbar {
height: var(--statusbar-height);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.status-left,
.status-right {
display: flex;
align-items: center;
gap: 8px;
}
.status-divider {
color: var(--border);
}
.status-stat {
white-space: nowrap;
}
.status-auto-save {
white-space: nowrap;
/* Toolbar auto-save button */
.toolbar-autosave {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
transition: color 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
font-family: var(--font-ui);
padding: 0;
padding: 4px 10px;
}
.status-auto-save:hover {
.toolbar-autosave:hover {
color: var(--primary);
}
.status-auto-save.saving {
color: var(--primary, #1a73e8);
.toolbar-autosave.saving {
color: var(--primary);
animation: auto-save-pulse 1s ease-in-out infinite;
}
@@ -990,14 +735,6 @@ body {
background: var(--primary);
}
/* View Modes */
#app.mode-preview #editor-panel {
display: none;
}
#app.mode-editor #preview-panel {
display: none;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -1321,19 +1058,6 @@ body {
}
}
/* Status bar loading indicator */
.status-loading {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--primary);
font-size: 12px;
}
.status-loading .loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
/* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
@@ -1420,149 +1144,6 @@ button:focus-visible,
background: var(--primary-dark);
}
/* ===== Search & Replace Panel ===== */
.search-replace-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
animation: slideDown 0.15s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
}
.search-input-group {
flex: 1;
display: flex;
align-items: center;
position: relative;
min-width: 0;
}
.search-input {
flex: 1;
height: 28px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 12px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s;
}
.search-input:focus {
border-color: var(--primary);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
.search-count {
position: absolute;
right: 8px;
font-size: 11px;
color: var(--text-tertiary);
pointer-events: none;
white-space: nowrap;
}
/* D6: 正则语法错误提示 */
.search-input-group .search-count {
color: #c5221f;
}
.search-input-group .search-count:not(:empty) {
/* 仅当有正则错误时变红 */
}
.search-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.search-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.search-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-btn.active {
background: var(--primary-light);
color: var(--primary);
}
.search-close:hover {
background: #e74c3c20;
color: #e74c3c;
}
.replace-btn {
font-size: 11px;
padding: 0 8px;
}
/* Search match highlights */
.search-match-highlight {
background: rgba(255, 213, 0, 0.4);
color: inherit;
border-radius: 2px;
padding: 0;
}
.search-match-active {
background: rgba(255, 150, 0, 0.6);
outline: 1px solid rgba(255, 150, 0, 0.8);
border-radius: 2px;
}
:root.dark .search-match-highlight {
background: rgba(255, 213, 0, 0.25);
}
:root.dark .search-match-active {
background: rgba(255, 180, 0, 0.4);
outline-color: rgba(255, 180, 0, 0.6);
}
/* ===== Outline Panel (Table of Contents) ===== */
.sidebar-outline-section {
+2 -19
View File
@@ -23,23 +23,6 @@
--sidebar-active: var(--primary-light);
--search-bg: var(--bg-secondary);
--search-border: var(--border);
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
}
:root.dark {
--primary: #8ab4f8;
--primary-light: #1a3a5c;
--primary-dark: #aecbfa;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d2d;
--text: #d4d4d4;
--text-secondary: #9e9e9e;
--text-tertiary: #6e6e6e;
--border: #3e3e3e;
--border-light: #333333;
--code-bg: #2d2d2d;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, 'Courier New', monospace;
}
+3 -2
View File
@@ -1,14 +1,15 @@
export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings {
darkMode: boolean
themeMode: ThemeMode
viewMode: ViewMode
sidebarCollapsed: boolean
sidebarWidth: number
}
export const DEFAULT_SETTINGS: Settings = {
darkMode: false,
themeMode: 'light',
viewMode: 'editor',
sidebarCollapsed: false,
sidebarWidth: 240
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.13'
export const APP_VERSION = 'v0.4.1'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([