30 Commits
Author SHA1 Message Date
thzxx bbaea2df3e docs: 更新关于弹框与 README(移除状态栏、依赖版本 0.4.1/0.4.4) 2026-08-09 22:07:15 +08:00
thzxx cf920ab2e6 refactor: 移除自定义状态栏 — 使用 MetonaEditor 自带底栏
- editor 自带底栏(wordCount: true)已显示字符数/词数/行数/阅读时间
- 删除自定义 StatusBar 组件、editorStore 的 stats/cursor 状态与事件绑定
- 保留 zenMode 同步(Toolbar 专注按钮消费);自动保存状态由 Toolbar 按钮显示
2026-08-09 22:04:06 +08:00
thzxx cb243c5c95 fix: 存储引擎回归 AriaEngine — sqlark 0.4.4 修复 SSTable 大 value 编码缺陷
- 0.4.4 引入 v2 格式(magic SSTC):u32 长度字段(修复 u16 截断 >64KB value)
  + UTF-8 字节精确估算(修复块大小低估导致写入越界)+ 超大条目独立成块
- 真实 Chromium 实测:300KB 中文内容写入 → close → 重启 → 读回数据完好,5 轮零失败
- schema.ts 切回 aria(version 0 + aria- 前缀内部库名)
2026-08-09 21:54:04 +08:00
thzxx b814114ff0 fix: 存储引擎切回 HybridEngine — AriaEngine SSTable 大 value 编码缺陷
根因(真实 Chromium 环境实测复现):
- AriaEngine SSTableBuilder.computeBlockSize 用 JS 字符串 length 估算块大小,
  实际写入为 UTF-8 字节(中文 1 字符 = 3 字节)→ finalSize 缓冲区低估
  → 大文件内容(中文 markdown)flush 时写入越界(offset is out of bounds)
- valueLen 为 u16(上限 64KB),大 value 长度截断
- 关闭时 flush 崩溃 → 半写 SSTable → 下次打开 DataView 越界 → ARIA_OPEN_ERROR

HybridEngine 行级 IDB 存储无此限制(300KB 中文内容实测闭环正常)
2026-08-09 21:26:47 +08:00
thzxx 63fa0d3040 fix: 数据库初始化加固 — 关闭流程禁止重开 + initDb 串行化 + 错误详情显示
- closeDatabase 后置 isClosing 标记,关闭流程中 getDb 直接拒绝,
  避免 forceClose 前其他组件重新建立连接(未关闭即被进程终止)
- initDb 串行链:失败重置后并发 getDb 不再同时 createDatabase
- 弹框显示 ARIA_OPEN_ERROR 等包装错误的 details(原始异常信息)
2026-08-09 21:09:15 +08:00
thzxx 8bee37cb89 release: v0.6.1 — 专注模式宽度官方 API 100% + 版本号更新 2026-08-09 20:45:37 +08:00
thzxx a981d9fd08 style: 专注模式宽度调整为 1200px 2026-08-09 20:39:04 +08:00
thzxx 8398e3f124 refactor: 专注模式宽度改用 0.4.1 官方 API(zenMaxWidth: '100%'),移除 CSS hack 2026-08-09 20:37:19 +08:00
thzxx 6450cd6435 chore: 升级 metona-editor 0.4.1 2026-08-09 20:34:53 +08:00
thzxx c647b7e602 chore: 升级 metona-sqlark 0.4.3(修复 close 后后台任务竞态)
- 0.4.3 修复: 写操作串行队列、close 前排空后台 flush/compaction、
  去掉 setTimeout 分片(close 后定时器执行问题)、活跃事务先回滚
- 实测 4 轮重启数据一条不丢、强杀重开完好、零警告
2026-08-09 19:54:10 +08:00
thzxx 764d3d831f fix: 存储引擎回归 AriaEngine(sqlark 0.4.2 已修复崩溃损坏问题)
- AriaEngine(LSM-Tree + WAL + MVCC)功能更强,0.4.2 修复了 0.4.1 的半写损坏
- 实测 4 轮重启数据一条不丢、强杀重开完好、旧残缺 SSTable 被完整性校验跳过
- 自愈删库恢复 aria- 前缀内部库名
2026-08-09 19:21:57 +08:00
thzxx 7e0e579dac chore: 升级 metona-sqlark 0.4.2(修复引擎损坏/版本管理/WAL 丢数据等全部已知问题)
- 移除 resolveDbVersion hack,改用标准 API(version: 1,0.4.2 打开时自动版本自适应)
- 实测 4 轮重启数据一条不丢、强杀重开完好
- 0.4.2 修复项:SSTable 残缺块跳过/完整性校验/repair 自愈、VersionError 自适应、
  version 0 TypeError、WAL count 原子提交、close 前 checkpoint、错误统一包装、
  迁移版本持久化、blocked 重试
2026-08-09 19:20:05 +08:00
thzxx b762488ac9 fix: 存储引擎切换为 HybridEngine(write-through 直写 IDB)+ 动态版本解析
- AriaEngine 的 SSTable/WAL 多 key 非原子写入在异常退出/强杀时留下半写文件,
  重启解析越界(offset is out of bounds)导致每次关闭后必损坏
- HybridEngine 每行单 key 原子写入 IDB,实测 close/强杀/多次重启数据完好
- 解决 sqlark IndexedDBEngine 建表升级版本号导致的 VersionError:
  启动时解析库当前版本再打开
- Zen 专注模式宽度改为全宽(max-width: none)
2026-08-09 17:17:39 +08:00
thzxx 0e06219969 chore: 移除误提交的临时测试脚本 2026-08-09 17:05:48 +08:00
thzxx 58c8a1a3a0 fix: 关闭应用前干净关闭数据库 + 自愈仅启动早期触发
- 退出前 db.close() 确保 WAL/SSTable 干净落盘,避免渲染进程销毁时 flush 中断留下半写文件导致下次启动损坏
- 自愈(删损坏库/重载)仅限页面生命周期内首次初始化;运行中失败直接抛错,不再阻塞应用关闭流程
2026-08-09 17:05:31 +08:00
thzxx 9fcbec790d fix: 移除 devDependencies 中残留的 rollup linux 平台包声明,平台依赖恢复由 rollup optional 机制管理 2026-08-09 16:54:06 +08:00
thzxx 26d54f042b chore: 移除 rollup 平台包 postinstall 脚本,平台依赖按需手动安装 2026-08-09 16:52:17 +08:00
thzxx ee5f35177e release: v0.6.0 — 内置解析器迁移 / Toast 全面接入 / Sqlark 深度集成
- 渲染管线迁移至 MetonaEditor 内置解析器,移除 unified/rehype 全家桶(9 个依赖)
- 修复相对路径图片修复的目录前缀碰撞与路径解析 bug
- ConfirmDialog/useConfirm/LoadingSpinner/useDocStats 移除,改用 MeToast.confirm/loading/promise
- 新增状态栏(字数/行数/阅读时间/光标位置)、Zen 模式、数据备份导出导入
- Sqlark: 版本化迁移(addMigration/migrateTo)、subscribe 表变更、备份 exportAll/importTable
- 数据库损坏自愈:异常退出残留残缺 SSTable 导致打开失败时自动重建
- 大纲导航改用 scrollToLine 官方 API
- 修复 rollup 平台包互删(postinstall 自动补齐)+ 集成测试(fake-indexeddb)
2026-08-09 16:50:06 +08:00
thzxx 20eb39efc5 release: v0.5.0 — 升级 MetonaEditor 0.4.0 / MetonaToast 0.5.0,数据库迁移 MetonaSqlark 0.4.1
- 编辑器升级 0.4.0:TypeScript 全模块重构、启用浮动格式工具栏
- 通知升级 0.5.0:钩子系统、新增动画
- 移除 Dexie.js,迁移至 MetonaSqlark 0.4.1(AriaEngine:LSM-Tree + WAL + MVCC)
- 数据库更换为 MarkLiteV2,旧 Dexie 数据放弃
- 修复 Editor 组件无条件重渲染(getActiveTab 返回新引用)
- 更新关于弹框、README、DESIGN 文档
2026-08-09 15:45:36 +08:00
thzxx d6ae9e3c8e release: v0.4.5 — Mermaid 图表渲染支持
新增 Mermaid 图表渲染:
- npm 安装 mermaid@10 作为项目依赖
- markdown.ts 新增 rehypeMermaid 插件:将 mermaid 代码块
  转换为 .me-mermaid 容器(与 MetonaEditor 内置输出一致)
- Editor.tsx 初始化 mermaid (startOnLoad: false)
- 绑定 afterRender 事件 → mermaid.run() 渲染 SVG 图表
- 首次加载 setTimeout 兜底渲染

技术实现:
- rehypeMermaid 插件在 rehypeHighlight 之后运行
- 查找 pre > code.language-mermaid 节点
- 替换为 div.me-mermaid > div.mermaid 容器
- 保持 unified 管线完整(图片路径修复 + sanitize + highlight)
- mermaid.run({ querySelector: '.me-mermaid .mermaid' })

质量保证:
- TypeScript 严格模式: 0 errors
- 单元测试: 98/98 passed (16 markdown + 82 others)
2026-07-25 16:41:14 +08:00
thzxx 2e7baa1073 release: v0.4.4 — 升级 MetonaEditor 0.2.4 & MetonaToast 0.2.1
依赖升级:
- @metona-team/metona-editor: 0.1.14 → 0.2.4
- @metona-team/metona-toast: 0.1.2 → 0.2.1(精确版本)

Editor.tsx 适配 v0.2.4:
- 移除 ExtendedEditorOptions 类型 hack(lineNumbers/autoBrackets 已原生支持)
- 本地声明 EditMode/ThemeName 类型(v0.2.4 不再命名导出)
- 适配 onModeChange 签名 (mode: EditMode → mode: string)
- 适配 onLinkClick 签名 ({href}) → (href, text, editor)
- 新增 syncScroll: true 同步滚动
- 新增 wordWrap: true 自动换行
- 新增 outline: false(保留自研 OutlinePanel)
- 新增 historyLimit: 100 / historyDebounce: 400
- 新增 shortcutHelp 预设插件(按 ? 弹出快捷键面板)
- 新增 onLinkClick 回调(通过 openExternal 安全打开链接)
- 新增 onFocus / onBlur 回调(预留扩展点)

toast.ts 适配 v0.2.1:
- 安装 keyboard 插件(ESC 关闭所有 Toast)
- 安装 accessibility 插件(屏幕阅读器朗读)
- 新增 width: 360 配置
2026-07-25 16:23:59 +08:00
thzxx 8bbbb7ff70 chore: 锁定 MetonaEditor 版本为 0.1.14
- package.json: ^0.1.10 → 0.1.14 精确锁定
- DESIGN.md: 三处 v0.1.3 → v0.1.14 同步更新
- README.md: 技术栈表格版本号同步
2026-07-24 20:59:27 +08:00
thzxx 1dd22be0a8 release: v0.4.3 — 版本号更新与预览区文本选择修复
- 版本号从 0.4.2 升级至 0.4.3
- 修复预览区内容无法鼠标拖拽选择/复制的问题
- 根因:html/body 上的 user-select: none 级联到预览面板
- 修复:为 .me-preview 及其子元素恢复 user-select: text
2026-07-24 20:50:59 +08:00
thzxx 20b6d03617 release: v0.4.2 — MetonaToast 0.1.2 降级与版本号更新
- chore: @metona-team/metona-toast 2.0.1 -> 0.1.2
- chore: 应用版本号 0.4.1 -> 0.4.2
2026-07-24 15:06:21 +08:00
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
thzxx 43b0778849 release: v0.3.13 — MeToast 集成与全面质量修复
- 集成 MetonaToast v2.0.0 替换自研 Toast 组件(右上角、进度条、自动关闭、主题联动)
- 修复 sidebar resize 未持久化到 IndexedDB
- 修复 Ctrl+Tab MRU 切换逻辑与文档不一致
- 完善 WYSIWYG 模式标签切换选区恢复
- 清理死代码(useActiveHeading observerRef、旧 Toast 样式、重复滚动条样式、SearchReplace 重复检查)
- 同步 DESIGN.md / docs 文档(CodeMirror→Milkdown、stores 计数、editorStore 字段)
- 新增 .npmrc.example 模板
- 更新关于对话框与文档远程地址为 Gitea
- 版本号 0.3.12 → 0.3.13
2026-07-22 15:55:45 +08:00
thzxx 77fdf03ed7 feat: use icon.png as app icon on welcome screen and about dialog 2026-07-06 13:41:55 +08:00
thzxx ed505d48bf chore: update application icons 2026-07-06 13:21:19 +08:00
95 changed files with 7997 additions and 7984 deletions
+19
View File
@@ -0,0 +1,19 @@
# MarkLite npm 配置模板
# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
#
# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
# registry=https://registry.npmmirror.com
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
#
# @metona-team 私有 registrymetona-editor / metona-toast / metona-sqlark):
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
# //git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
#
# 配置说明:
# registry — npm 包下载源
# ELECTRON_MIRROR — Electron 二进制文件下载源
# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
#
# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
# 详见 DEVSETUP.md
+20 -10
View File
@@ -11,7 +11,7 @@
2. **克隆并安装** 2. **克隆并安装**
```bash ```bash
git clone https://gitee.com/thzxx/MarkLite.git git clone https://git.metona.cn/MetonaTeam/MarkLite.git
cd MarkLite cd MarkLite
npm install npm install
``` ```
@@ -30,7 +30,7 @@
## 分支策略 ## 分支策略
- `main` — 稳定版本 - `master` — 稳定版本
- `dev` — 开发分支 - `dev` — 开发分支
- `feature/*` — 功能分支 - `feature/*` — 功能分支
- `fix/*` — 修复分支 - `fix/*` — 修复分支
@@ -98,16 +98,26 @@ src/
├── main/ # 主进程 (Node.js) ├── main/ # 主进程 (Node.js)
├── preload/ # 预加载脚本 ├── preload/ # 预加载脚本
├── renderer/ # 渲染进程 (React) ├── renderer/ # 渲染进程 (React)
│ ├── components/ # UI 组件 │ ├── components/ # UI 组件Toolbar · TabBar · Editor · Sidebar 等)
│ ├── stores/ # Zustand 状态管理 │ ├── stores/ # Zustand 状态管理tabStore · editorStore · sidebarStore · autoSaveStore
│ ├── hooks/ # 自定义 Hooks │ ├── hooks/ # 自定义 HooksuseTheme · useAutoSave · useKeyboard 等)
│ ├── lib/ # 工具库 │ ├── lib/ # 工具库markdown · fileUtils · errorHandler · toast
│ ├── db/ # IndexedDB 持久化 │ ├── db/ # IndexedDB 持久化schema · repositories
│ ├── types/ # TypeScript 类型 │ ├── types/ # TypeScript 类型定义
│ └── styles/ # 全局样式 │ └── styles/ # 全局样式variables · global · markdown-body
└── shared/ # 主进程/渲染进程共享 └── 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)。 贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
+159 -103
View File
@@ -1,14 +1,14 @@
# MarkLite v0.3.12 — 架构设计文档 # MarkLite v0.4.3 — 架构设计文档
## 1. 项目概述 ## 1. 项目概述
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Milkdown v7 WYSIWYG 编辑器 + 源码编辑模式、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线 MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.4.0 编辑器(三模式视图 + 内置解析器 + 插件系统)、Zustand 状态管理、MetonaSqlarkAriaEngine)持久化
### 1.1 核心原则 ### 1.1 核心原则
1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义 1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义
2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types 2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types
3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize 3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + 内置解析器 XSS 防护
4. **可测试性** — 业务逻辑(lib/)与 UIcomponents/)解耦 4. **可测试性** — 业务逻辑(lib/)与 UIcomponents/)解耦
## 2. 技术架构 ## 2. 技术架构
@@ -20,11 +20,12 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | | 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
| 前端框架 | React | v18 | 函数组件 + Hooks | | 前端框架 | React | v18 | 函数组件 + Hooks |
| 类型系统 | TypeScript | v5.6 | 全量类型安全 | | 类型系统 | TypeScript | v5.6 | 全量类型安全 |
| 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 | | 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
| 状态管理 | Zustand | v5 | 轻量级状态管理 | | 状态管理 | Zustand | v5 | 轻量级状态管理 |
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 | | 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件AriaEngine |
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 | | Markdown 解析 | MetonaEditor 内置解析器 | v0.4.0 | 零依赖,GFM + 脚注 + 数学公式 + mermaid |
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js | | 代码高亮 | MetonaEditor 内置高亮器 | v0.4.0 | 零依赖,16 种语言 |
| Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 |
| 构建工具 | electron-vite | v3 | Electron + ViteHMR 热更新 | | 构建工具 | electron-vite | v3 | Electron + ViteHMR 热更新 |
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 | | 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 | | 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
@@ -44,25 +45,25 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
┌───────────────────────▼──────────────────────────────────────┐ ┌───────────────────────▼──────────────────────────────────────┐
│ Preload Script (src/preload/) 1 文件 │ │ Preload Script (src/preload/) 1 文件 │
│ index.ts contextBridge 类型安全暴露 │ │ index.ts contextBridge 类型安全暴露 │
│ electronAPI 18 个方法/事件的类型安全接口 │ │ electronAPI 22 个方法/事件的类型安全接口 │
└───────────────────────┬──────────────────────────────────────┘ └───────────────────────┬──────────────────────────────────────┘
┌───────────────────────▼──────────────────────────────────────┐ ┌───────────────────────▼──────────────────────────────────────┐
│ Renderer Process (src/renderer/) React 18 │ │ Renderer Process (src/renderer/) React 18 │
│ │ │ │
│ components/ (23) Toolbar · TabBar · Editor · EditorToolbar │ components/ Toolbar · TabBar · Editor · Sidebar
SourceEditor · Preview · Sidebar · FileTree FileTree · OutlinePanel · WelcomeScreen
OutlinePanel · StatusBar · WelcomeScreen ConfirmDialog · ModifiedBanner
Toast · ConfirmDialog · ModifiedBanner DropOverlay · ErrorBoundary · AboutDialog
SearchReplace · DropOverlay · ErrorBoundary LoadingSpinner · Icons
│ AboutDialog · LoadingSpinner · Icons │
│ │ │ │
│ stores/ (3) tabStore · editorStore · sidebarStore │ stores/ (4) tabStore · editorStore · sidebarStore │
hooks/ (19) useTheme · useSettings · useSettingsInit autoSaveStore
useKeyboard · useDragDrop · useFileWatch hooks/ (15) useTheme · useSettingsInit · useKeyboard
│ useAutoSave · useIpcListeners ... │ useDragDrop · useFileWatch · useAutoSave
│ useIpcListeners · useFileOperations ... │
│ lib/ (4) markdown · fileUtils · errorHandler │ │ lib/ (4) markdown · fileUtils · errorHandler │
│ constants toast · constants │
│ db/ (4) schema · tabRepository · settingsRepo │ │ db/ (4) schema · tabRepository · settingsRepo │
│ recentFilesRepository │ │ recentFilesRepository │
│ types/ (5) tab · file · settings · ipc · index │ │ types/ (5) tab · file · settings · ipc · index │
@@ -83,9 +84,9 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|------|------|------| |------|------|------|
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 | | Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | | Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 22 个类型安全方法/事件 | | IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 | | CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 | | HTML | `MetonaEditor 内置解析器` | 渲染 MarkdownescapeHTML + safeUrl XSS 防护) |
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | | 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
| 路径 | `validatePath()` | 防止路径遍历攻击 | | 路径 | `validatePath()` | 防止路径遍历攻击 |
@@ -96,20 +97,19 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ App.tsx (根组件) │ │ App.tsx (根组件) │
├─────────┬──────────┬──────────┬────────────────────────┤ ├─────────┬──────────┬──────────┬──────────────────────────┤
│Toolbar │ TabBar │ Sidebar │ Editor │ Preview │Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor)
│ │ │ │ (CM6) │ │ ├─────────┴──────────┴──────────┴──────────────────────────┤
├─────────┴──────────┴──────────┴──────────┴──────────────┤
│ Zustand Stores │ │ Zustand Stores │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐
│ │ tabStore │ │editorStore│ │sidebarStore│ │ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│
│ │ - tabs │ │- viewMode│ │- tree │ │ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │
│ │- activeId│ │- darkMode│ │- expanded │ │ │- activeId│ │- darkMode│ │- expanded │ │- enabled │
│ │ - mru │ │ │ │- rootPath │ │ │ - mru │ │- extMod │ │- rootPath │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘
│ │ │ │ │ │ │ │
│ ┌────▼────────────▼────────────▼────────────────────┐ │ │ ┌────▼────────────▼────────────▼────────────────────┐ │
│ │ IndexedDB (Dexie.js) │ │ │ │ IndexedDB (MetonaSqlark) │ │
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │ │ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
│ └───────────────────────────────────────────────────┘ │ │ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
@@ -130,7 +130,7 @@ interface TabState {
setModified(tabId, modified) // 设置修改状态 setModified(tabId, modified) // 设置修改状态
getActiveTab() // 获取当前标签 getActiveTab() // 获取当前标签
updateTabScroll(tabId, scroll) // 更新滚动/光标位置 updateTabScroll(tabId, scroll) // 更新滚动/光标位置
saveToDB() // 保存到 IndexedDB saveToDB() // 保存到 IndexedDB(防抖 500ms
loadFromDB() // 从 IndexedDB 加载 loadFromDB() // 从 IndexedDB 加载
} }
``` ```
@@ -139,9 +139,15 @@ interface TabState {
```typescript ```typescript
interface EditorState { interface EditorState {
viewMode: 'editor' | 'preview' // 视图模式 viewMode: 'editor' | 'preview' | 'source' // 视图模式
darkMode: boolean // 暗色主题 darkMode: boolean // 暗色主题
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 — 侧边栏状态 ### 3.4 sidebarStore — 侧边栏状态
@@ -151,36 +157,49 @@ interface SidebarState {
isVisible: boolean // 是否显示 isVisible: boolean // 是否显示
rootPath: string | null // 当前打开的文件夹路径 rootPath: string | null // 当前打开的文件夹路径
tree: FileNode[] // 目录树数据 tree: FileNode[] // 目录树数据
expandedDirs: Set<string> // 已展开的目录集合 expandedDirs: string[] // 已展开的目录集合
sidebarWidth: number // 侧边栏宽度 (180~500) sidebarWidth: number // 侧边栏宽度 (180~500)
} }
``` ```
## 4. 数据持久化 — IndexedDB ## 4. 数据持久化 — IndexedDB
通过 Dexie.js 封装 IndexedDB v0.5.0: 由 Dexie.js 迁移至 MetonaSqlarkAriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
### 4.1 数据库 Schema ### 4.1 数据库 Schema
```typescript ```typescript
const db = new Dexie('MarkLite') // schema.ts — 懒加载单例
let dbPromise: Promise<MetonaSqlark> | null = null
db.version(1).stores({ export function getDb(): Promise<MetonaSqlark> {
tabSnapshots: 'id, filePath, updatedAt', // 标签页快照 if (!dbPromise) {
settings: 'id', // 用户设置 dbPromise = MetonaSqlark.create({
recentFiles: '++id, filePath, lastOpened', // 最近打开文件 name: 'MarkLiteV2',
activeTab: 'id' // 当前活动标签 mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
}) diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 1,
}).then(async (db) => {
if (!(await db.getTableNames()).includes('tabSnapshots')) {
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
}
// settings / recentFiles / activeTab 同理
return db
})
}
return dbPromise
}
``` ```
### 4.2 数据模型 ### 4.2 数据模型
| Store | 字段 | 说明 | | Store | 字段 | 说明 |
|-------|------|------| |-------|------|------|
| `tabSnapshots` | id, filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt | 标签页状态快照 | | `tabSnapshots` | id(PK), filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt(index) | 标签页状态快照 |
| `settings` | id, darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 | | `settings` | id(PK), themeMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 | | `recentFiles` | filePath(PK), lastOpened(index) | 最近打开文件列表v0.5.0 改用 filePath 主键,sqlark 无自增) |
| `activeTab` | id, activeTabId | 当前活动标签 ID | | `activeTab` | id(PK), activeTabId | 当前活动标签 ID |
## 5. IPC 通信设计 ## 5. IPC 通信设计
@@ -212,66 +231,100 @@ db.version(1).stores({
| `window:confirmClose` | 无 | 请求确认关闭 | | `window:confirmClose` | 无 | 请求确认关闭 |
| `sidebar:dirChanged` | 无 | 目录结构变化 | | `sidebar:dirChanged` | 无 | 目录结构变化 |
## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea) ## 6. 编辑器架构 — MetonaEditor v0.1.14
### 6.1 双编辑模式 ### 6.1 三模式视图
- **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo - **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
- **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键 - **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
- **预览模式 (preview)**:仅显示渲染后的 HTML
两种模式共享同一 tabStore 数据源,可随时切换 MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB
### 6.2 插件体系 ### 6.2 内置功能
| 功能 | 说明 |
|------|------|
| 格式化工具栏 | 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.3 插件体系
通过 `plugins` 配置数组安装 MetonaEditor 预设插件:
| 插件 | 说明 | | 插件 | 说明 |
|------|------| |------|------|
| commonmark | 基础 Markdown 语法 | | searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) | | imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
| history | undo/redo |
| listener | 内容变更监听 |
| indent | Tab 缩进 |
| trailing | 尾随换行 |
| clipboard | 剪贴板增强 |
| searchPlugin (自研) | 搜索高亮装饰 |
| autoPairPlugin (自研) | 自动配对括号/引号 |
### 6.3 滚动与选区持久化 > 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
切换标签时自动保存/恢复: ### 6.4 渲染管线集成
- 滚动位置 (`scrollTop`)
- 光标选区 (`selectionStart`, `selectionEnd`) v0.6.0: 移除 unified/remark/rehype 自研管线,改用 MetonaEditor **内置解析器**parseMarkdown),
通过 `render` 钩子接入,实现:
- **相对路径图片解析**:内置解析器的 safeUrl 会过滤 `file:` 协议,因此渲染后做 HTML 后处理,将相对路径图片 src 转换为 `file://` 绝对路径(越界路径保持原样)
- **XSS 防护**:内置 escapeHTML + safeUrl(过滤 javascript:/vbscript:/file:/data:+ 属性转义
- **代码高亮**:内置零依赖高亮器(`highlight: MeEditor.highlight`16 种语言)
- **Mermaid 图表**:内置解析器原生输出 `.me-mermaid` 容器,`mermaid.run()` 直接渲染
```
Markdown 源码
parseMarkdownMetonaEditor 内置解析器)
├── GFM / 任务列表 / 表格 / 删除线
├── 脚注 / 数学公式 / 定义列表 / emoji
├── 引用链接 / 自动链接 / 上下标
├── mermaid → <div class="me-mermaid">…
└── XSS 防护(escapeHTML + safeUrl
fixImageSrcsHTML 后处理:相对路径 → file://)
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 渲染管线 ## 7. Markdown 渲染管线
v0.6.0: 渲染完全由 MetonaEditor 内置解析器承担(零依赖),应用侧仅保留图片路径修复后处理:
``` ```
Markdown 文本 Markdown 文本
remark-parse 解析为 MDAST parseMarkdownMetonaEditor 内置解析器)
├── 块级:标题 / 列表 / 引用 / 代码块 / 表格 / 水平线 / 脚注 / 数学公式 / 定义列表
├── 行内:粗体 / 斜体 / 删除线 / 高亮 / 上下标 / 行内代码 / 链接 / 图片 / emoji
├── mermaid<div class="me-mermaid"><pre class="mermaid">…
└── 安全:escapeHTML 转义 + safeUrl URL 过滤 + 属性级注入防护
remark-gfm 扩展 GFM 语法 fixImageSrcsmarkdown.ts 后处理)
└── 相对路径图片 src → file:// 绝对路径(越界 ../ 不处理)
remark-rehype 转换为 HAST MetonaEditor 预览区 / getHTML 导出
rehype-raw 解析内联 HTML
rehype-sanitize 安全过滤
rehype-fixImages 相对路径图片转 file:// URL
rehype-highlight 代码语法高亮
rehype-stringify 序列化为 HTML
dangerouslySetInnerHTML 渲染到 DOM
``` ```
## 8. UI 设计 ## 8. UI 设计
@@ -304,18 +357,21 @@ dangerouslySetInnerHTML 渲染到 DOM
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ MarkLite - filename.md ─ □ ✕ │ │ MarkLite - filename.md ─ □ ✕ │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 🔗 ️ │ │ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ [file1.md] [file2.md] [未命名] [+] │ │ [file1.md] [file2.md] [未命名] [+] │
├──────────┬─────────────────────────────────────────────────────────────┤ ├──────────┬───────────────────────────────────────────────────────────────
│ 资源管理器 │ │ │ 资源管理器 │ │
│ ▼ project │ 1 # Title │ Title │ ▼ project │ ┌─────────────────────────────────────────────┐
│ 📁 src │ 2 │ ─────── │ 📁 src │ │ B I S U </> │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │
│ 📄 file1│ 3 content... │ content... │ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │
│ 📄 file2│ │ 📄 file2│ ├─────────────────────────────────────────────┤
├──────────┴─────────────────────────────────────────────────────────────┤ │ │ │ # Title │ Title │ │
filename.md │ UTF-8 Markdown 文档大纲 │ │ ───────
└────────────────────────────────────────────────────────────────────────┘ │ · Title │ │ content... │ content... │ │
├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
└──────────────────────────────────────────────────────────────────────────┘
``` ```
## 9. 构建与发布 ## 9. 构建与发布
@@ -349,11 +405,11 @@ npm run build:portable # 便携版(免安装)
|------|------|------| |------|------|------|
| react / react-dom | ^18.3 | UI 框架 | | react / react-dom | ^18.3 | UI 框架 |
| zustand | ^5.0 | 状态管理 | | zustand | ^5.0 | 状态管理 |
| dexie | ^4.0 | IndexedDB 封装 | | @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(AriaEngine) |
| nanoid | ^5.0 | 唯一 ID 生成 | | nanoid | ^5.0 | 唯一 ID 生成 |
| @codemirror/* | ^6.x | 代码编辑器 | | @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(内置解析器 + 高亮) |
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 | | @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
| rehype-highlight | ^7.0 | 代码语法高亮 | | mermaid | ^10.9 | Mermaid 图表渲染 |
### 开发依赖 ### 开发依赖
+13 -2
View File
@@ -1,4 +1,4 @@
# Electron 项目开发环境完整配置指南 # MarkLite v0.5.0 — 开发环境配置指南
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。 以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
@@ -21,16 +21,23 @@ nvm alias default 18
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。 npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
### 推荐方式:项目级 .npmrc仅对当前项目生效 ### 项目级 .npmrc推荐
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容: 在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
``` ```
registry=https://registry.npmmirror.com 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_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ 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` 查看路径),内容同上。 如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
## 三、配置文件说明 ## 三、配置文件说明
@@ -38,6 +45,7 @@ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-
| 配置项 | 作用 | | 配置项 | 作用 |
|:-------|:-----| |:-------|:-----|
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 | | `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 | | `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 | | `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
@@ -66,6 +74,8 @@ nvm use 18
# ② 在项目根目录创建 .npmrc,写入: # ② 在项目根目录创建 .npmrc,写入:
# registry=https://registry.npmmirror.com # 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_MIRROR=https://npmmirror.com/mirrors/electron/
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ # 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` 文件解决。 - **`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`),以及是否重新打开了终端。 - **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。 - **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."
}
+50 -47
View File
@@ -14,12 +14,12 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript"> <img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React"> <img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.12-orange?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/Version-v0.6.1-orange?style=flat-square" alt="Version">
</p> </p>
<p align="center"> <p align="center">
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br> 基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。
</p> </p>
--- ---
@@ -30,18 +30,23 @@
|------|------| |------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)| | ✏️ **编辑** | 基于 MetonaEditor 的代码+预览编辑器,内置解析器渲染、格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、Zen 专注模式、粘贴图片转 base64 |
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | | 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,MetonaEditor 内置解析器渲染 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🔤 **代码高亮** | MetonaEditor 内置零依赖高亮器(js / ts / python / bash / css / html / json 等 16 种语言) |
| 🎨 **种视图** | 编辑模式 / 预览模式,自由切换 | | 🎨 **种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB | | 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 | | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | | 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | | 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlarkAriaEngine)持久化,关闭后可恢复 |
| 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | | ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | | 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 |
| 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 |
## 🚀 快速开始 ## 🚀 快速开始
@@ -55,7 +60,7 @@
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://gitee.com/thzxx/MarkLite.git git clone https://git.metona.cn/MetonaTeam/MarkLite.git
cd MarkLite cd MarkLite
# 安装依赖 # 安装依赖
@@ -116,8 +121,6 @@ npm run test:coverage
- `lib/` — 工具库(fileUtils, markdown, errorHandler - `lib/` — 工具库(fileUtils, markdown, errorHandler
- `hooks/` — 自定义 Hooks - `hooks/` — 自定义 Hooks
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`
## ⌨️ 快捷键 ## ⌨️ 快捷键
| 快捷键 | 功能 | | 快捷键 | 功能 |
@@ -129,15 +132,13 @@ npm run test:coverage
| `Ctrl + O` | 打开文件 | | `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 | | `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 | | `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 预览模式 |
| `Ctrl + 3` | 源码模式 |
| `Ctrl + F` | 搜索 | | `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 | | `Ctrl + H` | 搜索并替换 |
| `Ctrl + B` | 粗体 | | `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
| `Ctrl + I` | 斜体 | | `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | | `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) |
| `Esc` | 关闭搜索栏 | | `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
| `?` | 快捷键帮助面板(编辑器内置) |
## 🛠️ 技术栈 ## 🛠️ 技术栈
@@ -146,11 +147,12 @@ npm run test:coverage
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | | 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | | 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| 编辑器 | [Milkdown](https://milkdown.dev/) v7 | WYSIWYG 编辑器 + 源码模式 | | 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | | 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | | 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.4 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | | Markdown 解析 | MetonaEditor 内置解析器 | 零依赖:GFM / 脚注 / 数学公式 / Mermaid |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | | 代码高亮 | MetonaEditor 内置高亮器 | 零依赖,16 种语言 |
| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v0.5.0 | 通知提示组件 |
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | | 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | | 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 | | 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
@@ -164,7 +166,7 @@ MarkLite/
├── tsconfig.node.json # Node 端 TypeScript 配置 ├── tsconfig.node.json # Node 端 TypeScript 配置
├── electron.vite.config.ts # electron-vite 构建配置 ├── electron.vite.config.ts # electron-vite 构建配置
├── vitest.config.ts # Vitest 测试框架配置 ├── vitest.config.ts # Vitest 测试框架配置
├── .eslintrc.cjs # ESLint + TypeScript 规则 ├── eslint.config.mjs # ESLint + TypeScript 规则
├── CONTRIBUTING.md # 贡献指南 ├── CONTRIBUTING.md # 贡献指南
├── src/ ├── src/
@@ -183,19 +185,14 @@ MarkLite/
│ │ ├── main.tsx # React 入口 │ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件:布局编排、全局事件 │ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │ │ │ │
│ │ ├── components/ # UI 组件 (23个) │ │ ├── components/ # UI 组件
│ │ │ ├── Toolbar/ # 工具栏 │ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于)
│ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序) │ │ │ ├── TabBar/ # 标签页栏含拖拽排序、右键菜单)
│ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏 │ │ │ ├── Editor/ # MetonaEditor 编辑器集成
│ │ │ ├── Preview/ # Markdown 预览面板 │ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲
│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea)
│ │ │ ├── Sidebar/ # 侧边栏文件树
│ │ │ ├── FileTree/ # 递归文件树 │ │ │ ├── FileTree/ # 递归文件树
│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮) │ │ │ ├── OutlinePanel/ # 文档大纲活跃标题高亮
│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关) │ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件)
│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则)
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
│ │ │ ├── Toast/ # Toast 通知
│ │ │ ├── ConfirmDialog/ # 确认对话框 │ │ │ ├── ConfirmDialog/ # 确认对话框
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示 │ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 │ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
@@ -207,29 +204,31 @@ MarkLite/
│ │ ├── stores/ # Zustand 状态管理 │ │ ├── stores/ # Zustand 状态管理
│ │ │ ├── tabStore.ts # 标签页状态 │ │ │ ├── tabStore.ts # 标签页状态
│ │ │ ├── editorStore.ts # 编辑器状态 │ │ │ ├── editorStore.ts # 编辑器状态
│ │ │ ── sidebarStore.ts # 侧边栏状态 │ │ │ ── sidebarStore.ts # 侧边栏状态
│ │ │ └── autoSaveStore.ts # 自动保存状态
│ │ │ │ │ │
│ │ ├── hooks/ # 自定义 Hooks (19个) │ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useTheme.ts # 暗色/亮色主题 │ │ │ ├── useTheme.ts # 暗色/亮色主题
│ │ │ ├── useSettings.ts # 视图模式
│ │ │ ├── useSettingsInit.ts # 设置初始加载 │ │ │ ├── useSettingsInit.ts # 设置初始加载
│ │ │ ├── useKeyboard.ts # 全局快捷键 │ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useDragDrop.ts # 拖拽打开 │ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useFileWatch.ts # 外部修改监听 │ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒 │ │ │ ├── useUnsavedWarning.ts # 未保存提醒
│ │ │ ├── useAutoSave.ts # 自动保存 │ │ │ ├── useAutoSave.ts # 自动保存Electron IPC
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录 │ │ │ ├── useAutoExpandDir.ts # 自动展开目录
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪 │ │ │ ├── useActiveHeading.ts # 活跃标题追踪
│ │ │ ├── useDocStats.ts # 文档统计
│ │ │ └── ... # 文件操作、IPC监听等 │ │ │ └── ... # 文件操作、IPC监听等
│ │ │ │ │ │
│ │ ├── lib/ # 工具库 │ │ ├── lib/ # 工具库
│ │ │ ├── markdown.ts # Markdown 渲染管线 │ │ │ ├── markdown.ts # Markdown 渲染管线unified/rehype
│ │ │ ├── fileUtils.ts # 文件工具函数 │ │ │ ├── fileUtils.ts # 文件工具函数
│ │ │ ├── errorHandler.ts # 错误处理 │ │ │ ├── errorHandler.ts # 错误处理
│ │ │ ├── toast.ts # Toast 通知(MetonaToast
│ │ │ └── constants.ts # 常量定义 │ │ │ └── constants.ts # 常量定义
│ │ │ │ │ │
│ │ ├── db/ # IndexedDB 持久化层 │ │ ├── db/ # MetonaSqlark 持久化层
│ │ │ ├── schema.ts # Dexie 数据库定义 │ │ │ ├── schema.ts # 数据库定义(懒初始化单例)
│ │ │ ├── tabRepository.ts # 标签页 CRUD │ │ │ ├── tabRepository.ts # 标签页 CRUD
│ │ │ ├── settingsRepository.ts # 设置 CRUD │ │ │ ├── settingsRepository.ts # 设置 CRUD
│ │ │ └── recentFilesRepository.ts # 最近文件 │ │ │ └── recentFilesRepository.ts # 最近文件
@@ -239,7 +238,8 @@ MarkLite/
│ │ │ │
│ └── shared/ # 主进程/渲染进程共享 │ └── shared/ # 主进程/渲染进程共享
│ ├── ipc-channels.ts # IPC 通道名常量 │ ├── ipc-channels.ts # IPC 通道名常量
── types.ts # 共享类型定义 ── types.ts # 共享类型定义
│ └── constants.ts # 共享常量(版本号等)
├── assets/ ├── assets/
│ └── icon.ico # 应用图标 │ └── icon.ico # 应用图标
@@ -253,14 +253,17 @@ MarkLite/
- ✅ 标题(h1 ~ h6 - ✅ 标题(h1 ~ h6
-**粗体** / *斜体* / ~~删除线~~ -**粗体** / *斜体* / ~~删除线~~
- ✅ 高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
- ✅ 有序列表 / 无序列表 - ✅ 有序列表 / 无序列表
- ✅ 任务列表 `- [x]` - ✅ 任务列表 `- [x]`
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言) - ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
- ✅ 行内代码 - ✅ 行内代码
- ✅ 链接 / 图片(支持相对路径) - ✅ 链接 / 图片(支持相对路径、粘贴图片
- ✅ 表格 - ✅ 表格(含列对齐)
- ✅ 引用块 - ✅ 引用块
- ✅ 水平线 - ✅ 水平线
- ✅ Emoji 短码 `:smile:` `:rocket:`
- ✅ Mermaid 图表 ` ```mermaid `
- ✅ HTML 内联元素 - ✅ HTML 内联元素
- ✅ GFMGitHub Flavored Markdown - ✅ GFMGitHub Flavored Markdown
@@ -277,7 +280,7 @@ npm run prepare
## 📄 许可证 ## 📄 许可证
[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx) [MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
--- ---
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 230 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 760 KiB

+300
View File
@@ -0,0 +1,300 @@
<!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.4.1 — 全功能演示</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.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 .3s,color .3s}
.header{background:var(--header-bg);color:var(--header-text);padding:28px 24px 20px;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,.3) 0%,transparent 70%);border-radius:50%}
.header h1{font-size:1.85rem;font-weight:800;letter-spacing:-.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:12px;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,.1)}
.toolbar-strip{display:flex;align-items:center;gap:8px;padding:8px 20px;background:var(--app-card);border-bottom:1px solid var(--app-border);flex-wrap:wrap;position:sticky;top:0;z-index:50;transition:background .3s}
.toolbar-strip label{font-size:12px;font-weight:500;color:var(--app-text);opacity:.7;white-space:nowrap}
.toolbar-strip select{padding:4px 8px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;background:var(--app-bg);color:var(--app-text);cursor:pointer}
.toolbar-strip button{padding:5px 12px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;font-weight:500;background:var(--app-card);color:var(--app-text);cursor:pointer;transition:all .15s;white-space:nowrap}
.toolbar-strip button:hover{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
.toolbar-strip button.on{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
.toolbar-strip .spacer{flex:1}
.editor-section{max-width:1280px;margin:20px auto;padding:0 20px}
.editor-card{border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.06);background:var(--app-card);border:1px solid var(--app-border);transition:.3s}
.editor-card:hover{box-shadow:0 8px 36px rgba(0,0,0,.1)}
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;max-width:1280px;margin:20px auto;padding:0 20px}
.info-card{background:var(--app-card);border:1px solid var(--app-border);border-radius:10px;padding:16px 18px;transition:.3s}
.info-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08)}
.info-card .icon{font-size:22px;margin-bottom:6px}
.info-card h4{font-size:13px;font-weight:650;margin-bottom:3px}
.info-card p{font-size:12px;color:var(--app-text);opacity:.65;margin:0}
.footer{text-align:center;padding:28px 24px;font-size:13px;color:var(--app-text);opacity:.5}
.footer a{color:var(--app-accent);text-decoration:none}
@media(max-width:768px){.header h1{font-size:1.4rem}.toolbar-strip{padding:6px 10px;gap:4px}.toolbar-strip button{padding:4px 8px;font-size:11px}.info-grid{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<header class="header">
<h1><span class="grad">Metona</span>Editor v0.4.1</h1>
<div class="badges">
<span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span>
<span class="badge">841 tests</span>
<span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span>
</div>
</header>
<div class="toolbar-strip" id="bar">
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:4px" title="首页">🏠</a>
<label>主题</label>
<select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
<label>语言</label>
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option><option value="fr">Français</option><option value="de">Deutsch</option></select>
<label>模式</label>
<button data-cmd="mode" data-arg="edit">编辑</button>
<button data-cmd="mode" data-arg="split" class="on">分屏</button>
<button data-cmd="mode" data-arg="preview">预览</button>
<div class="spacer"></div>
<button data-cmd="zen" title="Zen 专注模式">🧘 Zen</button>
<button data-cmd="outline" title="大纲面板">📋 大纲</button>
<button data-cmd="wrap" title="切换换行">↩ 换行</button>
<button data-cmd="float" title="浮动工具栏" class="on">💬 浮动栏</button>
<button data-cmd="toast" title="Toast 通知">💬 Toast</button>
<button data-cmd="openFile" title="打开文件">📂 打开</button>
<button data-cmd="saveFile" 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>TypeScript 全模块重构</h4><p>17 个 .ts 源文件,严格模式,完整类型导出</p></div>
<div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizerjs / ts / python / bash 等 16 个语言标识</p></div>
<div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div>
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,宽度可配置(zenMaxWidth,默认 960px</p></div>
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
<div class="info-card"><div class="icon"></div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
</div>
<footer class="footer">
MetonaEditor v0.4.1 · TypeScript · <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
</footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="../dist/metona-editor.js"></script>
<script>
(function(){
var demoMd=[
'# 🚀 MetonaEditor v0.4.1 全功能演示',
'',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。',
'',
'## ✨ v0.4.1 新特性',
'',
'- **Zen 专注模式宽度可配置**`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽',
'- **修复 5 处问题**`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新',
'- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证',
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
'',
'## 📐 文本样式',
'',
'- **粗体** / __另一种粗体__',
'- ***粗斜体*** / ___另一种粗斜体___',
'- *斜体* / _另一种斜体_',
'- ~~删除线~~ / ==高亮标记==',
'- <u>下划线</u>Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
'- 化学:H~2~O / 数学:x^2^',
'- 代码:`const x = 42;`',
'- Emoji:smile: :rocket: :fire: :rainbow: :coffee:',
'',
'## 📊 数学公式 & 图表',
'',
'行内:$E = mc^2$',
'块级:$$\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$',
'',
'```mermaid',
'graph TD',
' A[TypeScript] --> B[Rollup]',
' B --> C[UMD/ESM/CJS]',
' C --> D[Browser]',
'```',
'',
'## 📋 表格与对齐',
'',
'| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |',
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
'| v0.2.5 | 2026-08 | 782 | 内置高亮+安全修复 |',
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
'| v0.1.15 | 2026-07 | 583 | 解析器增强 |',
'| v0.1.14 | 2026-07 | 535 | 语法高亮+Mermaid |',
'',
'## ✅ 任务列表',
'',
'- [x] TypeScript 全模块重构',
'- [x] 解析器 99% 行覆盖率',
'- [x] parseTokens / renderTokens 分离',
'- [x] registerBlockHandler 扩展',
'- [x] 6 个预设插件完整测试',
'- [ ] VS Code 扩展',
'',
'## 🎯 嵌套列表',
'',
'1. 一级有序',
' 1. 二级有序(自动递增)',
' 2. 第二项',
'2. 回到一级',
' - 混杂无序列表',
' - 第二项',
' - [x] 三级嵌套 + 任务',
'',
'## 💻 代码块',
'',
'```ts',
'import MeEditor from "@metona-team/metona-editor";',
'',
'const editor = MeEditor.create("#editor", {',
' mode: "split",',
' theme: "dark",',
' lineNumbers: true,',
' autoBrackets: true,',
' plugins: ["autoSave", "fileSystem", "shortcutHelp"],',
' onChange: (value, editor) => {',
' console.log("changed:", value.length, "chars");',
' },',
'});',
'',
'// 链式 API',
'editor.exec("bold").exec("h1").focus();',
'```',
'',
'```python',
'def fib(n):',
' """返回斐波那契数列第 n 项"""',
' a, b = 0, 1',
' for _ in range(n):',
' a, b = b, a + b',
' return a',
'',
'print(fib(10)) # 55',
'```',
'',
'```bash',
'# 安装(Gitea 私有源)',
'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
'npm run dev',
'```',
'',
'## 📌 脚注',
'',
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
'',
'[^1]: 脚注可以包含 **格式化** 和 `代码`。',
'[^note]: 第二个脚注的说明。',
'',
'---',
'',
'> **提示:** 按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 查找 · Ctrl+H 替换',
'> 在列表行尾 Enter 体验智能延续 · 选中文字后按 `(` 体验自动包裹',
'> 点击「📂 打开」加载磁盘 .md 文件 · 点击「💾 保存」写入磁盘',
].join('\n');
var editor = MeEditor.create('#editor',{
value: demoMd,
mode: 'split', height: 620,
lineNumbers: true, autoBrackets: true, wordCount: true,
theme: 'auto', locale: 'zh-CN', outline: false,
highlight: MeEditor.highlight,
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
onLinkClick: function(uri, text, ed){
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
}
});
// Control bar
var bar = document.getElementById('bar');
document.getElementById('selTheme').addEventListener('change', function(){
editor.setTheme(this.value);
});
document.getElementById('selLocale').addEventListener('change', function(){
editor.setLocale(this.value);
});
bar.addEventListener('click', function(e){
var btn = e.target.closest('button[data-cmd]');
if (!btn) return;
var cmd = btn.dataset.cmd;
if (cmd === 'mode') { editor.setMode(btn.dataset.arg); updateModeBtns(btn.dataset.arg); }
else if (cmd === 'zen') { editor.toggleZen(); btn.classList.toggle('on', editor.isZen()); }
else if (cmd === 'outline') { toggleOutline(btn); }
else if (cmd === 'wrap') { editor.toggleWordWrap(); btn.classList.toggle('on', !editor.isWordWrap()); }
else if (cmd === 'float') {
editor.toggleFloatingToolbar();
btn.classList.toggle('on', editor.isFloatingToolbar());
}
else if (cmd === 'toast') {
var types = ['success','error','warning','info'];
var msgs = { success: '✅ 操作成功', error: '❌ 操作失败', warning: '⚠️ 注意', info: '️ 提示信息' };
editor.toast(msgs[types[Math.floor(Math.random()*4)]], { type: types[Math.floor(Math.random()*4)], duration: 2000 });
}
else if (cmd === 'openFile') { editor.openFile?.(); }
else if (cmd === 'saveFile') { editor.saveFile?.(); }
});
function updateModeBtns(mode){
bar.querySelectorAll('[data-cmd=mode]').forEach(function(b){ b.classList.toggle('on', b.dataset.arg === mode); });
}
function toggleOutline(btn){
editor.config.outline = !editor.config.outline;
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); }
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); }
}
// Custom shortcut
editor.registerShortcut('Ctrl+Shift+T', function(){
editor.toast('自定义快捷键已触发!', { type: 'info' });
}, 'Toast 通知演示');
// Events
editor.on('themeChange', function(d){ document.getElementById('selTheme').value = d.theme; });
editor.on('modeChange', function(m){ updateModeBtns(m); });
editor.on('fileOpened', function(d){ editor.toast('已打开: ' + d.name, { type: 'success' }); });
editor.on('fileSaved', function(){ editor.toast('文件已保存', { type: 'success' }); });
// Mermaid
function renderMermaid(){
if (typeof mermaid === 'undefined') return;
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }); } catch(e) {}
}
mermaid.initialize({ startOnLoad: false, theme: 'default' });
editor.on('afterRender', renderMermaid);
setTimeout(renderMermaid, 300);
})();
</script>
</body>
</html>
+511
View File
@@ -0,0 +1,511 @@
<!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 v0.4.1 · 文档</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-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
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{position:sticky;top:0;z-index:100;background:rgba(15,17,23,.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.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:-.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}
.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:.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:-.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)}
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{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}
.c-key{color:#c792ea}.c-str{color:#c3e88d}.c-fn{color:#82aaff}.c-com{color:#5c6370;font-style:italic}.c-kw{color:#89ddff}.c-num{color:#f78c6c}.c-punc{color:#89ddff;opacity:.7}
.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{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"><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">API 文档</span></h1>
<p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、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-cursor">光标与选区</a>
<a href="#api-exec">命令执行</a>
<a href="#api-history">历史栈</a>
<a href="#api-mode">模式 / 全屏 / Zen</a>
<a href="#api-stats">统计与状态</a>
<a href="#api-readonly">启用 / 禁用 / 只读</a>
<a href="#api-events">事件系统</a>
<a href="#api-plugin">插件管理</a>
<a href="#api-shortcuts">快捷键管理</a>
<a href="#api-destroy">生命周期</a>
<div class="toc-group">静态 API</div>
<a href="#static-api">全局函数</a>
<div class="toc-group">语法高亮</div>
<a href="#highlight">内置高亮器</a>
<div class="toc-group">解析器</div>
<a href="#parser-syntax">语法参考</a>
<a href="#parser-api">解析器 API</a>
<div class="toc-group">插件</div>
<a href="#plugins-convention">插件约定</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 安装(Gitea 私有源)</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
<span class="c-com">// 或项目 .npmrc 配置 scope 后直接安装</span>
<span class="c-com">// @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/</span>
<span class="c-com">// TypeScript / 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> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
id<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 实例 id(默认自动生成,隔离存储键)</span>
<span class="c-com">// 视图</span>
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> wordCount<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
lineNumbers<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> outline<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
<span class="c-com">// 行为</span>
autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span>
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
<span class="c-com">// Zen 内容区最大宽度(数字为 px / 字符串为 CSS 值 / false 不限宽)</span>
zenMaxWidth<span class="c-punc">:</span> <span class="c-num">960</span><span class="c-punc">,</span>
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
floatingToolbar<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>
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
<span class="c-com">// 钩子</span>
render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
<span class="c-com">// 外观</span>
className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> style<span class="c-punc">:</span> <span class="c-punc">{},</span>
<span class="c-com">// 插件 / 回调</span>
plugins<span class="c-punc">:</span> <span class="c-punc">[],</span>
onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onLinkClick<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</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">'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">// => string</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">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// => string</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">// => this</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">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">replaceAll</span><span class="c-punc">(</span>search<span class="c-punc">,</span> replace<span class="c-punc">,</span> caseSensitive<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
editor<span class="c-punc">.</span><span class="c-fn">replaceAllRegex</span><span class="c-punc">(</span>pattern<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">lineCount</span><span class="c-punc">();</span> <span class="c-fn">getLine</span><span class="c-punc">(</span>n<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">copyAsMarkdown</span><span class="c-punc">();</span> <span class="c-fn">copyAsHTML</span><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-fn">blur</span><span class="c-punc">();</span></pre>
</section>
<section id="api-cursor"><h3>光标与选区</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getSelectedText</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
editor<span class="c-punc">.</span><span class="c-fn">getCursorPosition</span><span class="c-punc">();</span> <span class="c-com">// => { line, column }</span>
editor<span class="c-punc">.</span><span class="c-fn">setCursorPosition</span><span class="c-punc">(</span>line<span class="c-punc">,</span> column<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">scrollToLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span class="c-punc">();</span> <span class="c-com">// => this</span></pre>
<p>伴随事件 <code>selectionChange</code><code>{ start, end, text }</code>)与 <code>cursorMove</code><code>{ line, column }</code>)。</p>
</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-com">// => this, 支持链式</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>&gt; text</td><td>Ctrl+Q</td></tr>
<tr><td><code>ul</code>/<code>ol</code></td><td>- / 1. 列表</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>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr>
<tr><td><code>hr</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>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / 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>
<tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr>
<tr><td><code>wordwrap</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> editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
</section>
<section id="api-mode"><h3>模式 / 全屏 / Zen / 浮动工具栏</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> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> <span class="c-com">// 或配置 zenMode: true 启动即进入</span>
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span>
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</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">// => { 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">// => { 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-fn">disable</span><span class="c-punc">();</span> <span class="c-fn">isDisabled</span><span class="c-punc">();</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-fn">isReadOnly</span><span class="c-punc">();</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 输入</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>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后</td><td><code>(oldValue, newValue, editor)</code></td></tr>
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(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>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
<tr><td><code>selectionChange</code></td><td>选区变化</td><td><code>({ start, end, text })</code></td></tr>
<tr><td><code>cursorMove</code></td><td>光标移动</td><td><code>({ line, column })</code></td></tr>
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr>
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr>
</table>
<h4>全局钩子</h4>
<table>
<tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</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">unuse</span><span class="c-punc">(</span>name<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-shortcuts"><h3>快捷键管理</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">registerShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">,</span> handler<span class="c-punc">,</span> <span class="c-str">'desc'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">unregisterShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span class="c-punc">();</span></pre>
</section>
<section id="api-destroy"><h3>生命周期 / Toast</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
<p>toast 动画支持 <code>fade</code> / <code>slide</code> / <code>scale</code> / <code>bounce</code> / <code>flip</code> / <code>rotate</code> / <code>zoom</code> 七种,<code>duration: 0</code> 不自动消失。</p>
</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><span class="c-str">'autoSave'</span><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>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">=></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>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <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-com">// 内置语法高亮</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> registerLanguage <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></pre>
</section>
<!-- 语法高亮 -->
<section id="highlight"><h2>内置语法高亮</h2>
<p>v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。</p>
<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">// 方式一:作为 highlight 钩子(代码块自动高亮)</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> highlight<span class="c-punc">:</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span> <span class="c-punc">});</span>
<span class="c-com">// 方式二:独立调用</span>
<span class="c-kw">const</span> html <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span><span class="c-punc">(</span>code<span class="c-punc">,</span> <span class="c-str">'typescript'</span><span class="c-punc">);</span>
<span class="c-com">// 注册自定义语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><span class="c-punc">(</span><span class="c-str">'myLang'</span><span class="c-punc">,</span> <span class="c-punc">{</span> keywords<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'kw'</span><span class="c-punc">]</span><span class="c-punc">,</span> builtins<span class="c-punc">:</span> <span class="c-punc">[]</span> <span class="c-punc">});</span></pre>
<p>高亮输出使用 <code>me-hl-keyword</code> / <code>me-hl-string</code> / <code>me-hl-comment</code> / <code>me-hl-number</code> / <code>me-hl-builtin</code> / <code>me-hl-function</code> 类。内置浅色 / 深色自适应配色,可通过 CSS 覆盖定制:</p>
<pre><span class="c-com">/* 自定义配色(覆盖内置默认) */</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-keyword</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#a855f7</span> <span class="c-punc">}</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-string</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#22c55e</span> <span class="c-punc">}</span></pre>
</section>
<!-- 解析器语法 -->
<section id="parser-syntax"><h2>解析器语法参考</h2>
<table>
<tr><th>语法</th><th>示例</th><th>输出</th></tr>
<tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td>&lt;h1&gt; ~ &lt;h6&gt;</td></tr>
<tr><td>Setext 标题</td><td><code>H1\n===</code></td><td>&lt;h1&gt; / &lt;h2&gt;</td></tr>
<tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td>&lt;strong&gt;</td></tr>
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td>&lt;em&gt;&lt;strong&gt;</td></tr>
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td>&lt;em&gt;</td></tr>
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td>&lt;del&gt; / &lt;mark&gt;</td></tr>
<tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code>&lt;u&gt;text&lt;/u&gt;</code>(裸标签透传)</td><td>&lt;u&gt;</td></tr>
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td>&lt;sup&gt; / &lt;sub&gt;</td></tr>
<tr><td>行内代码</td><td><code>`code`</code></td><td>&lt;code&gt;</td></tr>
<tr><td>代码块</td><td><code>```lang</code></td><td>&lt;pre&gt;&lt;code&gt;</td></tr>
<tr><td>缩进代码块</td><td><code> code</code></td><td>&lt;pre&gt;&lt;code&gt;</td></tr>
<tr><td>引用</td><td><code>&gt; quote</code></td><td>&lt;blockquote&gt;</td></tr>
<tr><td>无序列表</td><td><code>- * + item</code></td><td>&lt;ul&gt;&lt;li&gt;</td></tr>
<tr><td>有序列表</td><td><code>1. item</code></td><td>&lt;ol&gt;&lt;li&gt;</td></tr>
<tr><td>嵌套列表</td><td>多级缩进</td><td>多级 &lt;ul&gt;/&lt;ol&gt;</td></tr>
<tr><td>任务列表</td><td><code>- [x] done</code></td><td>&lt;li class="me-task-item"&gt;</td></tr>
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td>&lt;hr&gt;</td></tr>
<tr><td>表格</td><td><code>| a | b |</code></td><td>&lt;table&gt; + 列对齐</td></tr>
<tr><td>表格格式化 <span class="badge">v0.2.2</span></td><td><code>exec('formatTable')</code></td><td>列宽对齐</td></tr>
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
<tr><td>图片</td><td><code>![alt](url)</code></td><td>&lt;img&gt;</td></tr>
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td>&lt;a&gt; 引用式</td></tr>
<tr><td>引用图片 <span class="badge">v0.2.2</span></td><td><code>![alt][ref]</code> + <code>[ref]: url</code></td><td>&lt;img&gt; 引用式</td></tr>
<tr><td>自动链接</td><td><code>&lt;https://...&gt;</code></td><td>&lt;a&gt;</td></tr>
<tr><td>数学公式</td><td><code>$E=mc^2$</code></td><td>&lt;span&gt; / &lt;div&gt;</td></tr>
<tr><td>脚注</td><td><code>text[^1]</code></td><td>&lt;sup&gt; + 底部</td></tr>
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr>
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
</table>
<h4>XSS 防护</h4>
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code>&lt;u&gt;</code> 透传,提供 <code>sanitize</code> 钩子。</p>
</section>
<section id="parser-api"><h2>解析器 API</h2>
<h3>parseTokens / renderTokens</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> parseTokens<span class="c-punc">,</span> renderTokens <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-kw">const</span> <span class="c-punc">{</span> tokens<span class="c-punc">,</span> footnotes <span class="c-punc">}</span> <span class="c-punc">=</span> <span class="c-fn">parseTokens</span><span class="c-punc">(</span><span class="c-str">'# Hello\n\n**world**'</span><span class="c-punc">);</span>
tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class="c-punc">({</span> type<span class="c-punc">:</span> <span class="c-str">'hr'</span> <span class="c-punc">});</span> <span class="c-com">// 渲染前变换</span>
<span class="c-kw">const</span> html <span class="c-punc">=</span> <span class="c-fn">renderTokens</span><span class="c-punc">(</span>tokens<span class="c-punc">,</span> env<span class="c-punc">,</span> footnotes<span class="c-punc">);</span></pre>
<h3>registerBlockHandler</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> registerBlockHandler <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">registerBlockHandler</span><span class="c-punc">({</span>
name<span class="c-punc">:</span> <span class="c-str">'custom'</span><span class="c-punc">,</span> priority<span class="c-punc">:</span> <span class="c-num">8.5</span><span class="c-punc">,</span>
test<span class="c-punc">:</span> <span class="c-punc">(</span>line<span class="c-punc">)</span> <span class="c-punc">=></span> line<span class="c-punc">.</span><span class="c-fn">match</span><span class="c-punc">(</span><span class="c-re">/^:::/</span><span class="c-punc">),</span>
parse<span class="c-punc">:</span> <span class="c-punc">(</span>lines<span class="c-punc">,</span> i<span class="c-punc">,</span> match<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">({</span> token<span class="c-punc">:</span> <span class="c-punc">{</span> type<span class="c-punc">:</span> <span class="c-str">'alert'</span> <span class="c-punc">},</span> newIndex<span class="c-punc">:</span> j <span class="c-punc">})</span>
<span class="c-punc">});</span></pre>
</section>
<!-- 插件 -->
<section id="plugins-convention"><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> <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> 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><span class="c-str">'autoSave'</span><span class="c-punc">,</span> <span class="c-punc">{</span> key<span class="c-punc">:</span> <span class="c-str">'draft-key'</span><span class="c-punc">,</span> delay<span class="c-punc">:</span> <span class="c-num">1000</span> <span class="c-punc">});</span>
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre>
<h4>exportTool</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><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">'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">'doc.html'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportPDF</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><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
<h4>imagePaste</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">,</span> <span class="c-punc">{</span> maxSizeKB<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 粘贴图片转 base64,超限警告</span></pre>
<h4>shortcutHelp</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
<h4>fileSystem</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
</section>
<!-- 主题 -->
<section id="themes"><h2>主题系统</h2>
<pre><span class="c-com">// 实例级</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">// 注册自定义</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> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
<span class="c-com">// 外部跟随</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-fn">body</span><span class="c-punc">());</span> <span class="c-com">// 跟随外部元素</span>
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span>
<span class="c-com">// CSS 变量导出</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</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></pre>
</section>
<!-- 国际化 -->
<section id="i18n"><h2>国际化</h2>
<pre><span class="c-com">// 切换</span>
editor<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">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>
<span class="c-com">// 远程加载</span>
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</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>
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></pre>
</section>
</main>
</div>
</div>
<footer>
<div class="container">
MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div>
</footer>
<script>
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>
+811
View File
@@ -0,0 +1,811 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 在线演示 — MetonaSqlark v0.4.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
--bg:#0a0a0f; --surface:#0d1117; --surface2:#161b22; --border:#30363d;
--primary:#6366f1; --accent:#06b6d4; --accent2:#ec4899; --green:#22c55e;
--text:#e2e8f0; --text2:#8b949e; --radius:12px;
--gradient:linear-gradient(135deg,#6366f1,#06b6d4,#ec4899);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; overflow:hidden; }
header {
height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px;
background:var(--surface); border-bottom:1px solid var(--border); z-index:10; position:relative;
}
.logo { font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:10px; }
.logo .icon { width:28px; height:28px; border-radius:7px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:900; color:#fff; }
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
nav { display:flex; gap:20px; }
nav a { color:var(--text2); text-decoration:none; font-size:0.88rem; transition:.2s; }
nav a:hover,.nav-active { color:var(--text); }
.status { display:flex; align-items:center; gap:8px; font-size:0.82rem; color:var(--text2); }
.status .dot { width:7px; height:7px; border-radius:50%; background:var(--green); animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.main { display:flex; height:calc(100vh - 56px); }
.editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); min-width:400px; }
.result-panel { flex:1; display:flex; flex-direction:column; min-width:400px; }
.panel-header {
padding:12px 20px; font-size:0.78rem; font-weight:600; text-transform:uppercase;
letter-spacing:1px; color:var(--text2); border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between;
}
.panel-header .badge {
font-size:0.7rem; padding:3px 10px; border-radius:999px; background:var(--surface2);
border:1px solid var(--border); text-transform:none; letter-spacing:0;
}
.editor-area { flex:1; display:flex; flex-direction:column; }
#sql-input {
flex:1; background:var(--surface); color:var(--text); border:none; padding:20px;
font-family:'JetBrains Mono','Fira Code',monospace; font-size:0.9rem; line-height:1.8;
resize:none; outline:none; caret-color:var(--primary);
}
#sql-input::placeholder { color:var(--text2); opacity:0.5; }
.toolbar { display:flex; gap:8px; padding:12px 20px; border-top:1px solid var(--border); background:var(--surface2); flex-wrap:wrap; }
.btn {
display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px;
font-weight:600; font-size:0.85rem; cursor:pointer; border:none; transition:.25s; font-family:inherit;
}
.btn-run { background:var(--gradient); color:#fff; box-shadow:0 4px 15px rgba(99,102,241,0.35); }
.btn-run:hover { transform:translateY(-1px); box-shadow:0 6px 25px rgba(99,102,241,0.5); }
.btn-run:active { transform:scale(0.97); }
.btn-clear { background:var(--surface2); color:var(--text2); border:1px solid var(--border); }
.btn-clear:hover { color:var(--text); border-color:var(--text2); }
.btn-preset { background:transparent; color:var(--accent); border:1px solid var(--accent); font-size:0.78rem; padding:6px 14px; }
.btn-preset:hover { background:rgba(6,182,212,0.12); }
.result-content { flex:1; overflow-y:auto; padding:20px; }
.result-table { width:100%; border-collapse:collapse; font-size:0.85rem; }
.result-table th { text-align:left; padding:10px 14px; background:var(--surface2); color:var(--accent); font-weight:600; border-bottom:2px solid var(--border); position:sticky; top:0; z-index:1; }
.result-table td { padding:8px 14px; border-bottom:1px solid var(--border); color:var(--text2); max-width:250px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.result-table tr:hover td { background:rgba(99,102,241,0.04); }
.empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text2); gap:12px; }
.empty-state .icon { font-size:3rem; opacity:0.3; }
.error-box { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:10px; padding:16px; color:#f87171; font-family:'JetBrains Mono',monospace; font-size:0.82rem; margin-bottom:16px; }
.success-box { background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3); border-radius:10px; padding:12px 16px; color:#4ade80; font-size:0.85rem; margin-bottom:16px; }
.sql-tag { display:inline-block; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:4px 12px; font-family:'JetBrains Mono',monospace; font-size:0.75rem; color:var(--accent); margin-bottom:8px; }
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
@media(max-width:900px){ .main { flex-direction:column; } .editor-panel,.result-panel { min-width:auto; } .editor-panel { height:50%; } }
</style>
</head>
<body>
<header>
<div class="logo"><div class="icon"></div><span>MetonaSqlark</span></div>
<nav>
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a>
</nav>
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.4</div>
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</header>
<div class="main">
<div class="editor-panel">
<div class="editor-area">
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.4 在线演示
-- 已预置 users / orders / products 表数据
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
-- 查看所有数据
SELECT * FROM users;
-- 条件查询 + 排序
SELECT name, age FROM users WHERE age > 25 ORDER BY age DESC;
-- JOIN 多表关联
SELECT u.name, o.product, o.amount
FROM users u INNER JOIN orders o ON u.id = o.user_id;
-- 聚合统计
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;</textarea>
<div class="toolbar">
<button class="btn btn-run" onclick="runQuery()">▶ 执行 (Ctrl+Enter)</button>
<button class="btn btn-clear" onclick="clearResults()">🗑 清空</button>
<span style="flex:1"></span>
<button class="btn btn-preset" onclick="loadPreset('all')">📋 全数据</button>
<button class="btn btn-preset" onclick="loadPreset('join')">🔗 JOIN</button>
<button class="btn btn-preset" onclick="loadPreset('agg')">📊 聚合</button>
<button class="btn btn-preset" onclick="loadPreset('ddl')">🏗 DDL</button>
<button class="btn btn-preset" onclick="loadPreset('crud')">✏️ CRUD</button>
<button class="btn btn-preset" onclick="loadPreset('subquery')">🔍 子查询</button>
<button class="btn btn-preset" onclick="loadPreset('tx')">🔒 事务</button>
<button class="btn btn-preset" onclick="loadPreset('scalar')">🎯 标量子查询</button>
<button class="btn btn-preset" onclick="loadPreset('cascade')">🔗 级联</button>
<button class="btn btn-preset" onclick="loadPreset('adv')">🧪 高级</button>
<button class="btn btn-preset" onclick="loadPreset('alter')">🏗 ALTER</button>
<button class="btn btn-preset" onclick="loadPreset('truncate')">🗑 TRUNCATE</button>
<button class="btn btn-preset" onclick="loadPreset('casewhen')">🎯 CASE WHEN</button>
<button class="btn btn-preset" onclick="loadPreset('union')">🔀 UNION</button>
<button class="btn btn-preset" onclick="loadPreset('insertselect')">📥 INSERT SELECT</button>
<button class="btn btn-preset" onclick="loadPreset('exists')">🔍 EXISTS</button>
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</button>
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
<button class="btn btn-preset" onclick="loadPreset('v040')" style="color:#22c55e;border-color:#22c55e;">🚰 v0.4.0 新特性</button>
<button class="btn btn-preset" onclick="loadPreset('onupdate')" style="color:#fbbf24;border-color:#fbbf24;">🔄 ON UPDATE</button>
<button class="btn btn-preset" onclick="loadPreset('repair')" style="color:#22c55e;border-color:#22c55e;">🛡 自愈</button>
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</div>
</div>
</div>
<div class="result-panel">
<div class="panel-header">
<span>📊 查询结果</span>
<span class="badge" id="row-count">0 行</span>
</div>
<div class="result-content" id="result-area">
<div class="empty-state">
<div class="icon"></div>
<div>执行 SQL 查询以查看结果</div>
<div style="font-size:0.8rem;opacity:0.6">Ctrl + Enter 快捷执行 · 支持多语句</div>
</div>
</div>
</div>
</div>
<script src="../dist/metona-sqlark.js"></script>
<script>
// Resolve global: try both MetonaSqlark and MeSqlark
const globalNs = window.MetonaSqlark || window.MeSqlark || {};
const { MetonaSqlark, MeSqlark, create } = globalNs;
const DBClass = MetonaSqlark || MeSqlark;
let db = null;
const resultArea = document.getElementById('result-area');
const rowCount = document.getElementById('row-count');
const sqlInput = document.getElementById('sql-input');
// Init database
let currentEngine = 'memory';
async function initDB() {
try {
const engine = currentEngine;
// 关闭旧实例(预设重置/引擎切换时数据回到初始状态)
if (db) { try { await db.close(); } catch { /* ignore */ } }
db = new DBClass({ name: 'demo', mode: engine });
await db.init();
// v0.4.2: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
await db.getEngine().clearAll();
}
await seedDemoData(db);
renderInfo(`${engine === 'aria' ? '🌲 AriaEngineLSM-Tree + WAL + MVCC' : '⚡ Memory'} 引擎已就绪`);
} catch (e) {
renderError('数据库初始化失败: ' + e.message);
}
}
// 建表 + 种子数据(每次预设重置/引擎切换后重建,保证演示可预期)
async function seedDemoData(db) {
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
email: { type: 'string' },
age: { type: 'number', default: 0 },
});
await db.defineTable('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
product: { type: 'string' },
amount: { type: 'number' },
quantity: { type: 'number', default: 1 },
});
await db.defineTable('products', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
price: { type: 'number', default: 0 },
category: { type: 'string' },
});
// Seed data
await db.table('users').insertMany([
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
{ id: '4', name: 'Diana', email: 'diana@demo.com', age: 28 },
{ id: '5', name: 'Eve', email: 'eve@demo.com', age: 22 },
]);
await db.table('orders').insertMany([
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200, quantity: 1 },
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50, quantity: 2 },
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150, quantity: 1 },
{ id: 'o4', user_id: '3', product: 'Monitor', amount: 400, quantity: 1 },
{ id: 'o5', user_id: '3', product: 'Cable', amount: 20, quantity: 3 },
]);
await db.table('products').insertMany([
{ id: 'p1', name: 'Laptop', price: 1200, category: 'Electronics' },
{ id: 'p2', name: 'Mouse', price: 50, category: 'Accessories' },
{ id: 'p3', name: 'Keyboard', price: 150, category: 'Accessories' },
]);
}
// v0.4.2: 切换存储引擎(重建数据库实例)
async function switchEngine(engine) {
if (engine === currentEngine) return;
currentEngine = engine;
document.getElementById('btn-memory').style.opacity = engine === 'memory' ? '1' : '0.6';
document.getElementById('btn-aria').style.opacity = engine === 'aria' ? '1' : '0.6';
document.getElementById('engine-status').textContent = engine === 'aria' ? '🌲 Aria' : '⚡ Memory';
document.getElementById('engine-dot').style.background = engine === 'aria' ? '#ec4899' : '';
renderInfo(`⏳ 正在初始化 ${engine === 'aria' ? 'Aria' : 'Memory'} 引擎…`);
await initDB();
}
// Run SQL
async function runQuery() {
const sql = sqlInput.value.trim();
if (!sql || !db) return;
const statements = sql.split(';').map(s => s.trim()).filter(s => {
// 过滤空语句和纯注释语句
if (s.length === 0) return false;
const nonComment = s.split('\n').filter(l => !l.trimStart().startsWith('--')).join('\n').trim();
return nonComment.length > 0;
});
try {
const allResults = [];
for (const stmt of statements) {
const result = await db.query(stmt);
// 深拷贝查询结果,防止后续语句通过引用修改已保存的结果
const resultCopy = Array.isArray(result) ? result.map(r => ({ ...r })) : result;
allResults.push({ sql: stmt, result: resultCopy });
}
if (allResults.length === 1) {
renderResult(allResults[0]);
} else {
renderAllResults(allResults);
}
} catch (e) {
renderError(e.message || String(e));
}
}
function renderResult({ sql, result }) {
if (Array.isArray(result)) {
renderTable(result);
} else {
renderSuccess(`✅ 执行成功 (影响 ${result ?? 0} 行)`);
}
}
function renderAllResults(allResults) {
let html = '';
for (const { sql, result } of allResults) {
html += `<div class="sql-tag">> ${escapeHTML(sql)}</div>`;
if (Array.isArray(result)) {
if (result.length === 0) {
html += '<div class="success-box">✅ 查询返回 0 行</div>';
} else {
html += buildTableHTML(result);
}
} else {
html += `<div class="success-box">✅ 执行成功 (影响 ${result ?? 0} 行)</div>`;
}
html += '<div style="height:12px"></div>';
}
resultArea.innerHTML = html;
updateRowCount(allResults.reduce((sum, r) => sum + (Array.isArray(r.result) ? r.result.length : 0), 0));
}
function renderTable(rows) {
if (!rows || rows.length === 0) {
resultArea.innerHTML = '<div class="success-box">✅ 查询返回 0 行</div>';
updateRowCount(0);
return;
}
resultArea.innerHTML = buildTableHTML(rows);
updateRowCount(rows.length);
}
function buildTableHTML(rows) {
const columns = Object.keys(rows[0]);
let html = '<table class="result-table"><thead><tr>';
for (const col of columns) {
html += `<th>${escapeHTML(col)}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of rows) {
html += '<tr>';
for (const col of columns) {
const val = row[col];
html += `<td>${formatCell(val)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function formatCell(val) {
if (val === null) return '<span style="color:#64748b;font-style:italic">NULL</span>';
if (val === undefined) return '<span style="color:#64748b">-</span>';
if (typeof val === 'boolean') return `<span style="color:#fbbf24">${val}</span>`;
if (typeof val === 'number') return `<span style="color:#fbbf24">${val}</span>`;
return escapeHTML(String(val));
}
function renderError(msg) {
resultArea.innerHTML = `<div class="error-box">❌ ${escapeHTML(msg)}</div>`;
updateRowCount(0);
}
function renderSuccess(msg) {
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
}
function renderInfo(msg) {
resultArea.innerHTML = `<div class="success-box" style="color:#22c55e;border-color:#22c55e;">${msg}</div>`;
}
function clearResults() {
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
updateRowCount(0);
}
function updateRowCount(n) { rowCount.textContent = `${n}`; }
function escapeHTML(str) { return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;'); }
// Presets
const presets = {
all: `-- 查看所有数据
SELECT * FROM users;
SELECT * FROM orders;
SELECT * FROM products;`,
join: `-- INNER JOIN:用户 + 订单
SELECT u.name, o.product, o.amount, o.quantity
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY o.amount DESC;
-- LEFT JOIN:包含没有订单的用户
SELECT u.name, o.product, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;`,
agg: `-- 聚合统计:每个用户的消费
SELECT u.name, COUNT(*) as orders, SUM(o.amount) as total_spent
FROM users u
INNER JOIN orders o ON u.id = o.user_id
GROUP BY u.name
ORDER BY total_spent DESC;
-- 全局聚合
SELECT COUNT(*) as total_users, AVG(age) as avg_age,
MIN(age) as min_age, MAX(age) as max_age
FROM users;
-- 分类统计
SELECT category, COUNT(*) as product_count, AVG(price) as avg_price
FROM products
GROUP BY category;`,
ddl: `-- 查看所有表
-- (使用 db.getTableNames() 替代)
-- 创建新表
CREATE TABLE tasks (
id STRING PRIMARY KEY,
title STRING NOT NULL,
done BOOLEAN DEFAULT false,
priority NUMBER DEFAULT 0
);
-- 插入数据
INSERT INTO tasks (id, title, done, priority) VALUES ('t1', 'Learn SQLark', false, 1);
INSERT INTO tasks VALUES ('t2', 'Write docs', true, 2);
-- 查询
SELECT * FROM tasks ORDER BY priority DESC;
-- 删除表
DROP TABLE tasks;`,
crud: `-- 增
INSERT INTO users VALUES ('9', 'Frank', 'frank@demo.com', 40);
-- 查
SELECT * FROM users WHERE name = 'Frank';
-- 改
UPDATE users SET age = 41, email = 'frank2@demo.com' WHERE id = '9';
-- 查(确认)
SELECT * FROM users WHERE id = '9';
-- 删
DELETE FROM users WHERE id = '9';
-- 确认
SELECT COUNT(*) as user_count FROM users;`,
subquery: `-- IN 子查询:有订单的用户
SELECT name, email FROM users
WHERE id IN (SELECT user_id FROM orders);
-- NOT IN 子查询:没有订单的用户
SELECT name, email FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- 标量子查询:消费最高的用户
SELECT name, age FROM users
WHERE id IN (
SELECT user_id FROM orders
WHERE amount = (SELECT MAX(amount) FROM orders)
);`,
tx: `-- 事务演示:使用 db.transaction() API
-- 查询用户 Alice
SELECT * FROM users WHERE id = '1';
-- 为 Alice 插入新订单
INSERT INTO orders VALUES ('o99', '1', 'Headphones', 199, 1);
-- 查看 Alice 的订单汇总
SELECT u.name, COUNT(*) as order_count, SUM(o.amount) as total
FROM users u INNER JOIN orders o ON u.id = o.user_id
WHERE u.id = '1'
GROUP BY u.name;
-- 生产环境用 db.transaction() 包裹保证原子性`,
scalar: `-- 聚合无 GROUP BY 返回单行
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;
-- 年龄大于平均值的用户
SELECT name, age FROM users
WHERE age > (SELECT AVG(age) FROM users);
-- 消费超过平均值的用户
SELECT u.name, SUM(o.amount) as spent
FROM users u INNER JOIN orders o ON u.id = o.user_id
GROUP BY u.name
HAVING SUM(o.amount) > (SELECT AVG(amount) FROM orders);`,
cascade: `-- 外键级联:重建 orders 表带 CASCADE
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id STRING PRIMARY KEY,
user_id STRING REFERENCES users(id) ON DELETE CASCADE,
product STRING,
amount NUMBER,
quantity NUMBER DEFAULT 1
);
-- 重新插入数据
INSERT INTO orders VALUES ('o1', '1', 'Laptop', 1200, 1);
INSERT INTO orders VALUES ('o2', '1', 'Mouse', 50, 2);
INSERT INTO orders VALUES ('o3', '2', 'Keyboard', 150, 1);
-- 查看关联数据
SELECT u.name, o.product, o.amount
FROM users u INNER JOIN orders o ON u.id = o.user_id;
-- 删除 Alice (id=1) 订单自动级联删除
DELETE FROM users WHERE id = '1';
-- 确认级联结果
SELECT u.name, o.product, o.amount
FROM users u LEFT JOIN orders o ON u.id = o.user_id;`,
adv: `-- BETWEEN 范围查询
SELECT * FROM users
WHERE age BETWEEN 20 AND 30 AND name != 'Bob'
ORDER BY age DESC;
-- DISTINCT 去重
SELECT DISTINCT age FROM users ORDER BY age;
-- 多表 JOIN + 过滤 + 排序 + 分页
SELECT u.name, o.product, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.amount >= 50
ORDER BY o.amount DESC
LIMIT 5 OFFSET 0;
-- NOT LIKE 模糊排除
SELECT * FROM users
WHERE name NOT LIKE 'A%' AND age > 20;`,
alter: `-- 🏗 ALTER TABLE 动态修改表结构 (v0.2.5)
-- 添加新列
ALTER TABLE users ADD COLUMN phone STRING;
-- 插入数据(含新列)
INSERT INTO users (id, name, age, phone) VALUES ('6', 'Frank', 33, '123-4567');
-- 查询确认
SELECT * FROM users WHERE id = '6';
-- 删除列
ALTER TABLE users DROP COLUMN phone;
-- 查询确认(phone 列已删除)
SELECT * FROM users WHERE id = '6';`,
truncate: `-- 🗑 TRUNCATE TABLE 快速清空 (v0.2.5)
-- 创建临时表并插入数据
CREATE TABLE IF NOT EXISTS temp_logs (
id STRING PRIMARY KEY,
message STRING NOT NULL
);
INSERT INTO temp_logs VALUES ('1', 'log A');
INSERT INTO temp_logs VALUES ('2', 'log B');
INSERT INTO temp_logs VALUES ('3', 'log C');
-- 确认数据
SELECT COUNT(*) as total FROM temp_logs;
-- TRUNCATE 快速清空
TRUNCATE TABLE temp_logs;
-- 确认清空
SELECT COUNT(*) as total FROM temp_logs;
-- 清理
DROP TABLE temp_logs;`,
aria: `-- 🌲 AriaEngine 演示 (v0.4.4)
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter
-- 基础 CRUD 与 Memory 引擎完全兼容
-- 当前引擎确认
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB)
CREATE TABLE IF NOT EXISTS tasks (
id STRING PRIMARY KEY,
title STRING NOT NULL,
done BOOLEAN DEFAULT false
);
-- 插入数据
INSERT INTO tasks VALUES ('t1', 'Implement AriaEngine', false);
INSERT INTO tasks VALUES ('t2', 'Write tests', false);
INSERT INTO tasks VALUES ('t3', 'Update docs', true);
-- 查询
SELECT * FROM tasks ORDER BY title;
-- 聚合统计
SELECT done, COUNT(*) as cnt FROM tasks GROUP BY done;
-- CASE WHEN 表达式
SELECT title,
CASE WHEN done THEN '✅ done' ELSE '⏳ pending' END AS status
FROM tasks;
-- EXISTS 关联子查询
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- AriaEngine 特性:
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
-- • WAL: 原子写入 + 崩溃恢复(残缺 SSTable 打开自动跳过)+ 批量组提交
-- • MVCC: 版本链 + 快照隔离
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
-- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • 二级索引: 每列独立 LSM + 跨重启自动恢复
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT (v0.4.2)
-- • 自愈: db.repair() 无需删库重建 (v0.4.2)
-- 生产环境 API(与演示页右上角切换等价)
-- const db = await MetonaSqlark.create({
-- name: 'my-app', mode: 'aria'
-- });`,
casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2)
-- SELECT 列:多 WHEN + ELSE
SELECT name, age,
CASE WHEN age >= 30 THEN 'senior'
WHEN age >= 25 THEN 'mid'
ELSE 'junior' END AS age_group
FROM users ORDER BY age;
-- WHERE 条件中的 CASE WHEN
SELECT name FROM users
WHERE CASE WHEN age >= 25 THEN 'adult' ELSE 'young' END = 'adult';
-- 聚合中的 CASE WHEN
SELECT
SUM(CASE WHEN age >= 30 THEN 1 ELSE 0 END) AS seniors,
SUM(CASE WHEN age < 30 THEN 1 ELSE 0 END) AS juniors,
AVG(CASE WHEN age >= 25 THEN age END) AS avg_adult_age
FROM users;`,
union: `-- 🔀 UNION / UNION ALL (v0.3.0)
-- UNION 去重合并两表数据
SELECT name FROM users WHERE age >= 30
UNION
SELECT name FROM users WHERE age < 30;
-- UNION 对重复行去重(同查询两次 → 结果去重)
SELECT category FROM products
UNION
SELECT category FROM products;
-- UNION ALL 保留重复
SELECT category FROM products WHERE price >= 100
UNION ALL
SELECT category FROM products WHERE price >= 50;`,
insertselect: `-- 📥 INSERT INTO ... SELECT (v0.3.0)
-- 建备份表并复制全量数据
DROP TABLE IF EXISTS users_backup;
CREATE TABLE users_backup (
id STRING PRIMARY KEY, name STRING, email STRING, age NUMBER
);
INSERT INTO users_backup SELECT * FROM users;
-- 确认复制
SELECT COUNT(*) as backed_up FROM users_backup;
-- INSERT SELECT 带 WHERE 过滤
DROP TABLE IF EXISTS big_spenders;
CREATE TABLE big_spenders (
id STRING PRIMARY KEY, product STRING, amount NUMBER
);
INSERT INTO big_spenders (id, product, amount)
SELECT o.id, o.product, o.amount FROM orders o WHERE o.amount >= 150;
-- 确认过滤结果
SELECT * FROM big_spenders;`,
exists: `-- 🔍 EXISTS 关联子查询 (v0.3.0 / v0.3.1)
-- 有订单的用户(关联子查询)
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- 无订单的用户(NOT EXISTS
SELECT u.name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- EXISTS + JOIN 组合
SELECT u.name, o.product, o.amount FROM users u
JOIN orders o ON u.id = o.user_id
WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 100);`,
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
-- 为 orders.user_id 创建索引(已有数据自动回填)
CREATE INDEX idx_orders_user ON orders (user_id);
-- 索引查找(v0.4.2: JOIN 主表 WHERE 条件下推 + 二级索引跨重启恢复)
SELECT u.name, o.product, o.amount
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.user_id = '1';
-- 删除索引
DROP INDEX idx_orders_user ON orders (user_id);
-- 删除后回退全表扫描(结果不变)
SELECT * FROM orders WHERE user_id = '3';
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND
-- DROP INDEX idx_nonexist ON orders (user_id);`,
onupdate: `-- 🔄 ON UPDATE 外键级联 (v0.4.2)
-- 更新父表主键 → 子表外键自动级联(CASCADE / SET NULL / RESTRICT
-- 建带 onUpdate 外键的表
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS audit_log;
CREATE TABLE accounts (id STRING PRIMARY KEY, owner STRING);
CREATE TABLE audit_log (
id STRING PRIMARY KEY,
account_id STRING REFERENCES accounts.id ON UPDATE CASCADE
);
-- 种子数据
INSERT INTO accounts VALUES ('a1', 'Alice');
INSERT INTO audit_log VALUES ('l1', 'a1');
INSERT INTO audit_log VALUES ('l2', 'a1');
-- 更新父表主键 a1 → a2
UPDATE accounts SET id = 'a2' WHERE id = 'a1';
-- 子表外键已级联为 a2
SELECT * FROM audit_log ORDER BY id;
-- 再按新主键反查
SELECT * FROM accounts WHERE id = 'a2';`,
repair: `-- 🛡 崩溃恢复自愈 (v0.4.2)
-- db.repair(): 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL
-- db.clearAll(): 清空全部数据与表结构(保留库本身)
-- 打开数据库时自动跳过残缺 SSTable,无需删库重建
-- 数据写入
CREATE TABLE IF NOT EXISTS notes (id STRING PRIMARY KEY, body STRING);
INSERT INTO notes VALUES ('n1', 'Hello');
INSERT INTO notes VALUES ('n2', 'World');
-- 模拟数据(正常数据)
SELECT * FROM notes ORDER BY id;
-- API 自愈(控制台执行):
-- await db.repair(); → 校验+清理损坏文件,重建索引
-- await db.clearAll(); → 清空全部表与数据
-- 迁移版本持久化(重启后不重跑已执行迁移)
-- db.addMigration(1, async () => { ... });
-- await db.migrateTo(1);`,
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
-- 分号分隔的多语句一次执行
DROP TABLE IF EXISTS audit;
CREATE TABLE audit (id STRING PRIMARY KEY, msg STRING);
INSERT INTO audit VALUES ('a1', 'first');
INSERT INTO audit VALUES ('a2', 'second');
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → ROLLBACKa3 不会生效)
BEGIN;
INSERT INTO audit VALUES ('a3', 'will be rolled back');
ROLLBACK;
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → COMMITa4 生效)
BEGIN;
INSERT INTO audit VALUES ('a4', 'will be committed');
COMMIT;
SELECT COUNT(*) as total FROM audit;`,
v040: `-- 🚰 v0.4.0 新特性
-- 派生表:FROM (SELECT ...) 子查询作为行源
SELECT category, total FROM (
SELECT category, SUM(price) AS total FROM products GROUP BY category
) AS t WHERE total > 100 ORDER BY total DESC;
-- COUNT(DISTINCT col):去重计数
SELECT COUNT(DISTINCT category) AS categories FROM products;
-- NULLS FIRST / LASTNULL 排序位置控制
SELECT name FROM users ORDER BY age ASC NULLS FIRST LIMIT 3;
-- 普通列别名 + ORDER BY 别名
SELECT name AS n FROM users ORDER BY n DESC;
-- SQL 标准 '' 字符串转义
SELECT 'it''s a test' AS escaped;
-- 哈希连接(等值 ON 走 O(N+M);多列等值 ON 同样支持)
SELECT u.name, o.product FROM users u
INNER JOIN orders o ON o.user_id = u.id;`,
};
// v0.4.1: 点击预设前自动重置数据(前序预设可能修改/删除种子数据,重置保证每个预设可预期演示)
async function loadPreset(name) {
if (presets[name]) {
sqlInput.value = presets[name];
await initDB();
runQuery();
}
}
// Keyboard shortcut
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
});
// Boot
document.getElementById('btn-memory').style.opacity = '1';
document.getElementById('btn-aria').style.opacity = '0.6';
document.getElementById('engine-status').textContent = '⚡ Memory';
initDB().then(() => {
console.log('✅ MetonaSqlark v0.4.2 demo ready');
setTimeout(runQuery, 300);
}).catch(err => {
renderError('初始化失败: ' + err.message);
});
</script>
</body>
</html>
+866
View File
@@ -0,0 +1,866 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.4.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
--bg:#0a0a0f; --surface:#14141f; --surface2:#1a1a2e; --border:#2a2a45;
--primary:#6366f1; --primary-glow:#818cf8; --accent:#06b6d4; --accent2:#ec4899;
--text:#e2e8f0; --text2:#94a3b8; --gradient:linear-gradient(135deg,#6366f1 0%,#06b6d4 50%,#ec4899 100%);
--radius:14px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.7; }
header {
position:fixed; top:0; left:0; right:0; z-index:100; backdrop-filter:blur(20px);
background:rgba(10,10,15,0.85); border-bottom:1px solid var(--border); padding:16px 32px;
}
header .inner { max-width:1400px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; }
.logo { font-size:1.3rem; font-weight:800; display:flex; align-items:center; gap:10px; }
.logo .icon { width:32px; height:32px; border-radius:8px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:1rem; font-weight:900; color:#fff; }
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
nav { display:flex; gap:28px; }
nav a { color:var(--text2); text-decoration:none; font-weight:500; font-size:0.92rem; transition:.2s; }
nav a:hover,.nav-active { color:var(--text); }
.layout { display:flex; max-width:1400px; margin:0 auto; padding-top:80px; min-height:100vh; }
.sidebar {
width:260px; min-width:260px; padding:32px 24px; position:sticky; top:80px; height:calc(100vh - 80px);
overflow-y:auto; border-right:1px solid var(--border); background:var(--bg);
}
.sidebar h4 { font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--text2); margin:20px 0 8px; }
.sidebar h4:first-child { margin-top:0; }
.sidebar a { display:block; padding:7px 12px; border-radius:6px; color:var(--text2); text-decoration:none; font-size:0.9rem; transition:.15s; }
.sidebar a:hover,.sidebar a.active { color:var(--text); background:var(--surface2); }
.sidebar a.active { border-left:3px solid var(--primary); }
.content { flex:1; padding:32px 48px 80px; max-width:950px; }
.content h2 { font-size:1.8rem; font-weight:800; margin:48px 0 16px; padding-top:24px; border-top:1px solid var(--border); }
.content h2:first-of-type { margin-top:0; padding-top:0; border-top:none; }
.content h3 { font-size:1.25rem; font-weight:700; margin:32px 0 12px; color:var(--primary-glow); }
.content p { color:var(--text2); margin-bottom:16px; }
.content code {
background:var(--surface2); padding:2px 8px; border-radius:5px; font-family:'JetBrains Mono',monospace;
font-size:0.88rem; color:var(--accent);
}
.content pre {
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
padding:20px 24px; overflow-x:auto; font-family:'JetBrains Mono',monospace; font-size:0.85rem;
line-height:1.65; margin:16px 0; color:#e2e8f0;
}
.content pre .k { color:#c084fc; } .content pre .s { color:#34d399; }
.content pre .f { color:#60a5fa; } .content pre .c { color:#64748b; }
.content pre .n { color:#fbbf24; } .content pre .t { color:#f472b6; }
.content table { width:100%; border-collapse:collapse; margin:16px 0; }
.content th,.content td { text-align:left; padding:12px 16px; border-bottom:1px solid var(--border); font-size:0.9rem; }
.content th { color:var(--text); font-weight:600; background:var(--surface); }
.content td { color:var(--text2); }
.content td code { font-size:0.82rem; }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
</style>
</head>
<body>
<header>
<div class="inner">
<div class="logo"><div class="icon"></div><span>MetonaSqlark</span></div>
<nav>
<a href="index.html">首页</a>
<a href="docs.html" class="nav-active">文档</a>
<a href="demo.html">演示</a>
<a href="benchmark.html">基准</a>
</nav>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<h4>快速开始</h4>
<a href="#install" class="active">安装</a>
<a href="#create">创建数据库</a>
<a href="#define-table">定义表</a>
<h4>查询 API</h4>
<a href="#sql-query">SQL 查询</a>
<a href="#query-builder">Query Builder</a>
<a href="#join">JOIN 查询</a>
<a href="#groupby">GROUP BY & 聚合</a>
<a href="#where-ops">WHERE 操作符</a>
<h4>高级特性</h4>
<a href="#aria-engine">AriaEngine 🆕</a>
<a href="#transaction">事务 & 回滚</a>
<a href="#subquery">子查询</a>
<a href="#alter-table">ALTER TABLE 🆕</a>
<a href="#truncate">TRUNCATE TABLE 🆕</a>
<a href="#foreign-key">外键级联</a>
<a href="#connection-pool">连接池</a>
<a href="#migration">数据迁移</a>
<a href="#selfheal">崩溃自愈 🆕</a>
<a href="#export">导入导出</a>
<a href="#plugin">插件 & 钩子</a>
<a href="#subscribe">发布订阅</a>
<h4>框架集成</h4>
<a href="#react">React 集成</a>
<a href="#vue">Vue 集成</a>
<h4>类型参考</h4>
<a href="#types">TypeScript 类型</a>
<a href="#config">完整配置项</a>
<a href="#engine">存储引擎</a>
<a href="#errors">错误处理</a>
</aside>
<main class="content">
<h2 id="install">📦 安装</h2>
<p>MetonaSqlark 支持多种引入方式,覆盖 npm、CDN、ESM、CJS 所有常见场景。</p>
<h3>npm 安装(推荐)</h3>
<pre><span class="c"># 配置 Gitea Registry(一次性)</span>
npm config set @metona-team:registry https://git.metona.cn/api/packages/MetonaTeam/npm/
<span class="c"># 安装</span>
npm install @metona-team/metona-sqlark</pre>
<h3>CDN / UMD</h3>
<pre><span class="c">&lt;!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark --&gt;</span>
&lt;script src=<span class="s">"https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"</span>&gt;&lt;/script&gt;
&lt;script&gt;
<span class="k">const</span> db = <span class="k">await</span> window.<span class="f">MetonaSqlark</span>.create({...});
<span class="c">// 或 window.MeSqlark.create(...) — 完全等价</span>
&lt;/script&gt;</pre>
<h3>ESM</h3>
<pre><span class="k">import</span> { MetonaSqlark, MeSqlark } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark'</span>;
<span class="c">// MeSqlark 是 MetonaSqlark 的别名,行为完全一致</span></pre>
<h3>CJS</h3>
<pre><span class="k">const</span> { MetonaSqlark } = <span class="f">require</span>(<span class="s">'@metona-team/metona-sqlark'</span>);</pre>
<p>输出文件说明:</p>
<table>
<tr><th>文件</th><th>格式</th><th>用途</th></tr>
<tr><td><code>metona-sqlark.js</code></td><td>UMD</td><td>浏览器开发版(含 sourcemap</td></tr>
<tr><td><code>metona-sqlark.min.js</code></td><td>UMD (minified)</td><td>生产环境(~105KB / ~27KB gzip</td></tr>
<tr><td><code>metona-sqlark.esm.js</code></td><td>ES Module</td><td>现代打包工具 / 浏览器 ESM</td></tr>
<tr><td><code>metona-sqlark.cjs</code></td><td>CommonJS</td><td>Node.js require()</td></tr>
<tr><td><code>metona-sqlark.d.ts</code></td><td>TypeScript 声明</td><td>类型提示</td></tr>
</table>
<h2 id="create">🏗 创建数据库</h2>
<p><code>MetonaSqlark.create(config)</code> — 工厂函数,自动创建并初始化数据库实例。</p>
<pre><span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>, <span class="c">// 'memory' | 'disk' | 'hybrid' | 'aria' 🆕</span>
<span class="s">diskEngine</span>: <span class="s">'indexeddb'</span>, <span class="c">// 'indexeddb' | 'opfs'(仅 disk/hybrid 生效)</span>
<span class="s">version</span>: <span class="n">1</span>,
<span class="s">plugins</span>: [], <span class="c">// MetonaPlugin[]</span>
<span class="s">onReady</span>: (db) => {}, <span class="c">// 就绪回调</span>
<span class="s">onError</span>: (err) => {}, <span class="c">// 错误回调</span>
});
<span class="c">// 也可手动实例化</span>
<span class="k">const</span> db2 = <span class="k">new</span> <span class="f">MetonaSqlark</span>(config);
<span class="k">await</span> db2.<span class="f">init</span>();
<span class="c">// 检查状态</span>
db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">// 关闭</span>
<span class="k">await</span> db.<span class="f">close</span>();</pre>
<h2 id="define-table">📋 定义表</h2>
<p>使用 <code>db.defineTable(name, columns)</code> 定义表结构。</p>
<pre><span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'users'</span>, {
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
<span class="s">name</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">required</span>: <span class="k">true</span> },
<span class="s">email</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">unique</span>: <span class="k">true</span>, <span class="s">index</span>: <span class="k">true</span> },
<span class="s">age</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">default</span>: <span class="n">0</span>, <span class="s">min</span>: <span class="n">0</span>, <span class="s">max</span>: <span class="n">150</span> },
<span class="s">active</span>: { <span class="s">type</span>: <span class="s">'boolean'</span>, <span class="s">default</span>: <span class="k">true</span> },
<span class="s">birthday</span>: { <span class="s">type</span>: <span class="s">'date'</span> },
<span class="s">meta</span>: { <span class="s">type</span>: <span class="s">'json'</span> },
<span class="s">dept_id</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">references</span>: <span class="s">'departments.id'</span> },
});
<span class="c">// 表操作</span>
<span class="k">await</span> db.<span class="f">getTableNames</span>(); <span class="c">// ['users']</span>
<span class="k">await</span> db.<span class="f">dropTable</span>(<span class="s">'users'</span>); <span class="c">// 删除表</span></pre>
<table>
<tr><th>ColumnDef 属性</th><th>类型</th><th>说明</th></tr>
<tr><td><code>type</code></td><td><code>'string'|'number'|'boolean'|'date'|'json'</code></td><td>数据类型</td></tr>
<tr><td><code>primaryKey</code></td><td><code>boolean</code></td><td>主键(每表至少一个)</td></tr>
<tr><td><code>required</code></td><td><code>boolean</code></td><td>是否必填</td></tr>
<tr><td><code>unique</code></td><td><code>boolean</code></td><td>唯一约束(自动建索引)</td></tr>
<tr><td><code>index</code></td><td><code>boolean</code></td><td>创建哈希索引,O(1) 加速查询</td></tr>
<tr><td><code>default</code></td><td><code>unknown</code></td><td>默认值</td></tr>
<tr><td><code>maxLength</code></td><td><code>number</code></td><td>字符串最大长度</td></tr>
<tr><td><code>min</code>/<code>max</code></td><td><code>number</code></td><td>数值范围</td></tr>
<tr><td><code>references</code></td><td><code>string</code></td><td>外键引用 <code>'table.column'</code></td></tr>
<tr><td><code>onDelete</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>删除级联 ✅ v0.4.1</td></tr>
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联(更新主键时触发)✅ v0.4.2</td></tr>
</table>
<h2 id="sql-query">🔍 SQL 查询</h2>
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
<p><code>db.queryStream(sql, onRow)</code> — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECTWHERE/LIMIT/OFFSET/列投影);JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退物化。</p>
<pre><span class="c">// 流式查询 — 大表逐行处理</span>
<span class="k">let</span> count = <span class="n">0</span>;
<span class="k">await</span> db.<span class="f">queryStream</span>(<span class="s">"SELECT * FROM logs WHERE level = 'error'"</span>, (row) =&gt; {
count++;
processRow(row);
});
<span class="c">// 派生表 / COUNT(DISTINCT) / NULLS 排序(v0.4.0</span>
<span class="k">const</span> top = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, total FROM
(SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t
WHERE total > 100 ORDER BY total DESC`</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT COUNT(DISTINCT city) AS n FROM users"</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT name FROM users ORDER BY age ASC NULLS FIRST"</span>);</pre>
<h3>完整 SQL 语法支持</h3>
<pre><span class="c">// SELECT — 核心查询</span>
<span class="k">const</span> rows = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
WHERE age > 18
ORDER BY name ASC
LIMIT 10 OFFSET 0`</span>);
<span class="c">// INSERT — 插入数据</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)"</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES ('2', 'Bob', 25)"</span>);
<span class="c">// 支持多行插入</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES ('3', 'C'), ('4', 'D')"</span>);
<span class="c">// UPDATE — 更新数据</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"UPDATE users SET age = 31, active = true WHERE id = '1'"</span>);
<span class="c">// DELETE — 删除数据</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"DELETE FROM users WHERE id = '1'"</span>);
<span class="c">// DDL — 表结构操作</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE products (
id STRING PRIMARY KEY,
name STRING NOT NULL,
price NUMBER DEFAULT 0
)`</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP TABLE products'</span>);
<span class="c">-- ALTER TABLE — 动态修改表结构 (v0.2.5)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN phone STRING'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN phone'</span>);
<span class="c">-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);</pre>
<h3>SQL 扩展 (v0.3.0+)</h3>
<pre><span class="c">// 多语句 — 分号分隔一次执行(返回最后一条结果)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE t (id STRING PRIMARY KEY);
INSERT INTO t VALUES ('1'); INSERT INTO t VALUES ('2')`</span>);
<span class="c">// 事务语句 — BEGIN / COMMIT / ROLLBACK</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO t VALUES ('3')"</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK'</span>); <span class="c">// 回滚</span>
<span class="c">// INSERT INTO ... SELECT — 查询结果写入</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'INSERT INTO t SELECT id FROM t2 WHERE x > 1'</span>);
<span class="c">// UNION / UNION ALL — 合并查询</span>
<span class="k">const</span> merged = <span class="k">await</span> db.<span class="f">query</span>(
<span class="s">`SELECT name FROM users WHERE city = 'Beijing'
UNION SELECT name FROM users WHERE age < 30`</span>);
<span class="c">// EXISTS / NOT EXISTS — 关联子查询</span>
<span class="k">const</span> hasOrders = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`</span>);
<span class="c">// CASE WHEN — SELECT 列 / WHERE / 聚合 (v0.3.1 / v0.3.2)</span>
<span class="k">const</span> labeled = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT name,
CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`</span>);
<span class="k">const</span> adults = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) FROM users`</span>);
<span class="c">// CREATE / DROP INDEX — 动态二级索引</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE INDEX idx_users_city ON users (city)'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);</pre>
<h3>条件表达式</h3>
<pre><span class="c">// 比较运算符</span>
<span class="s">`WHERE age > 18 AND name LIKE 'A%'`</span>
<span class="s">`WHERE salary >= 5000 OR dept = 'Engineering'`</span>
<span class="c">// IN / NOT IN</span>
<span class="s">`WHERE dept IN ('Engineering', 'Sales')`</span>
<span class="c">// NULL 检查</span>
<span class="s">`WHERE email IS NULL`</span>
<span class="s">`WHERE email IS NOT NULL`</span>
<span class="c">// NOT 取反</span>
<span class="s">`WHERE NOT (age < 18 OR age > 65)`</span>
<span class="c">// 嵌套条件</span>
<span class="s">`WHERE (age > 18 AND active = true) OR role = 'admin'`</span></pre>
<h2 id="query-builder">⛓ Query Builder</h2>
<p>链式 APITypeScript 友好,享受 IDE 自动补全。</p>
<h3>SELECT</h3>
<pre><span class="k">const</span> users = db.<span class="f">table</span>(<span class="s">'users'</span>);
<span class="c">// 全量查询</span>
<span class="k">await</span> users.<span class="f">select</span>().<span class="f">execute</span>();
<span class="c">// 指定列 + 条件 + 排序 + 分页</span>
<span class="k">const</span> result = <span class="k">await</span> users
.<span class="f">select</span>([<span class="s">'name'</span>, <span class="s">'age'</span>, <span class="s">'email'</span>])
.<span class="f">where</span>({
<span class="s">age</span>: { <span class="s">$gt</span>: <span class="n">18</span> },
<span class="s">name</span>: { <span class="s">$like</span>: <span class="s">'A%'</span> },
})
.<span class="f">orderBy</span>(<span class="s">'age'</span>, <span class="s">'desc'</span>)
.<span class="f">limit</span>(<span class="n">10</span>)
.<span class="f">offset</span>(<span class="n">0</span>)
.<span class="f">execute</span>();</pre>
<h3>INSERT</h3>
<pre><span class="c">// 单行插入 — 返回主键值</span>
<span class="k">const</span> pk = <span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span>, <span class="s">age</span>: <span class="n">30</span> });
<span class="c">// 批量插入 — 返回主键值数组</span>
<span class="k">const</span> pks = <span class="k">await</span> users.<span class="f">insertMany</span>([
{ <span class="s">id</span>: <span class="s">'2'</span>, <span class="s">name</span>: <span class="s">'Bob'</span>, <span class="s">age</span>: <span class="n">25</span> },
{ <span class="s">id</span>: <span class="s">'3'</span>, <span class="s">name</span>: <span class="s">'Charlie'</span>, <span class="s">age</span>: <span class="n">35</span> },
]);</pre>
<h3>UPDATE / DELETE</h3>
<pre><span class="c">// 更新 — 返回影响行数</span>
<span class="k">const</span> updated = <span class="k">await</span> users
.<span class="f">update</span>({ <span class="s">age</span>: <span class="n">31</span>, <span class="s">active</span>: <span class="k">false</span> })
.<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> })
.<span class="f">execute</span>(); <span class="c">// 1</span>
<span class="c">// 删除 — 返回影响行数</span>
<span class="k">const</span> deleted = <span class="k">await</span> users
.<span class="f">delete</span>()
.<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> })
.<span class="f">execute</span>(); <span class="c">// 1</span>
<span class="c">// 全表删除</span>
<span class="k">await</span> users.<span class="f">delete</span>().<span class="f">execute</span>();</pre>
<h2 id="join">🔗 JOIN 查询</h2>
<p>支持 INNER / LEFT / RIGHT / CROSS JOINSQL 和 Query Builder 两种方式。</p>
<pre><span class="c">// SQL 方式 — 支持表别名</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT u.name, d.name AS dept_name
FROM users u
INNER JOIN departments d ON u.dept_id = d.id
LEFT JOIN orders o ON u.id = o.user_id
WHERE d.name = 'Engineering'
ORDER BY u.name`</span>);
<span class="c">// Query Builder 方式</span>
<span class="k">await</span> db.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">select</span>()
.<span class="f">as</span>(<span class="s">'u'</span>)
.<span class="f">innerJoin</span>(<span class="s">'departments'</span>, { <span class="s">'u.dept_id'</span>: { <span class="s">$col</span>: <span class="s">'d.id'</span> } }, <span class="s">'d'</span>)
.<span class="f">leftJoin</span>(<span class="s">'orders'</span>, { <span class="s">'u.id'</span>: { <span class="s">$col</span>: <span class="s">'o.user_id'</span> } }, <span class="s">'o'</span>)
.<span class="f">execute</span>();
<span class="c">// CROSS JOIN — 笛卡尔积</span>
.<span class="f">crossJoin</span>(<span class="s">'metadata'</span>, <span class="s">'m'</span>)</pre>
<h2 id="groupby">📊 GROUP BY & 聚合</h2>
<p>五大聚合函数 + GROUP BY + HAVING + DISTINCT,完整的数据分析能力。</p>
<pre><span class="c">// GROUP BY + 聚合</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, COUNT(*) as cnt, SUM(salary) as total,
AVG(salary) as avg_sal, MIN(age), MAX(age)
FROM employees
GROUP BY dept
HAVING COUNT(*) > 1
ORDER BY total DESC
LIMIT 5`</span>);
<span class="c">// DISTINCT 去重</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT DISTINCT dept FROM employees'</span>);
<span class="c">// 聚合函数别名</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT COUNT(*) AS total_users, AVG(age) AS avg_age FROM users'</span>);</pre>
<h2 id="where-ops">🎯 WHERE 操作符</h2>
<p>Query Builder 使用 <code>$</code> 前缀操作符,支持逻辑组合。</p>
<table>
<tr><th>操作符</th><th>含义</th><th>示例</th></tr>
<tr><td>直接值 / <code>$eq</code></td><td>等于</td><td><code>{ age: 30 }</code> / <code>{ age: { $eq: 30 } }</code></td></tr>
<tr><td><code>$ne</code></td><td>不等于</td><td><code>{ age: { $ne: 30 } }</code></td></tr>
<tr><td><code>$gt</code> / <code>$gte</code></td><td>大于 / 大于等于</td><td><code>{ age: { $gt: 18 } }</code></td></tr>
<tr><td><code>$lt</code> / <code>$lte</code></td><td>小于 / 小于等于</td><td><code>{ age: { $lt: 65 } }</code></td></tr>
<tr><td><code>$in</code> / <code>$nin</code></td><td>在列表中 / 不在</td><td><code>{ dept: { $in: ['IT','HR'] } }</code></td></tr>
<tr><td><code>$like</code></td><td>模糊匹配</td><td><code>{ name: { $like: 'A%' } }</code></td></tr>
<tr><td><code>$and</code></td><td>逻辑与</td><td><code>{ $and: [{...}, {...}] }</code></td></tr>
<tr><td><code>$or</code></td><td>逻辑或</td><td><code>{ $or: [{...}, {...}] }</code></td></tr>
<tr><td><code>$not</code></td><td>逻辑非</td><td><code>{ age: { $not: { $gt: 18 } } }</code></td></tr>
<tr><td><code>$col</code></td><td>列引用(JOIN ON</td><td><code>{ 'a.id': { $col: 'b.a_id' } }</code></td></tr>
</table>
<pre><span class="c">// 复合条件</span>
.<span class="f">where</span>({
<span class="s">$or</span>: [
{ <span class="s">age</span>: { <span class="s">$lt</span>: <span class="n">18</span> } },
{ <span class="s">age</span>: { <span class="s">$gt</span>: <span class="n">65</span> } },
],
<span class="s">active</span>: <span class="k">true</span>,
<span class="s">name</span>: { <span class="s">$like</span>: <span class="s">'A%'</span>, <span class="s">$ne</span>: <span class="s">'Admin'</span> },
})</pre>
<h2 id="transaction">🔒 事务 & 回滚</h2>
<p>v0.1.13 起支持真正的自动回滚:事务内任何一步失败,所有变更自动撤销。</p>
<pre><span class="k">await</span> db.<span class="f">transaction</span>(<span class="k">async</span> (trx) => {
<span class="c">// trx.table() 获取事务内表操作对象</span>
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'3'</span>, <span class="s">name</span>: <span class="s">'Charlie'</span> });
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'orders'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'o1'</span>, <span class="s">userId</span>: <span class="s">'3'</span>, <span class="s">amount</span>: <span class="n">99</span> });
<span class="c">// ✅ 全部成功 → 自动 commit</span>
<span class="c">// ❌ 任何一步失败 → 自动 rollback,数据恢复原状</span>
<span class="c">// Memory 引擎:快照回滚 | IndexedDB 引擎:延迟写入</span>
});</pre>
<h2 id="subquery">🔍 子查询</h2>
<p>v0.1.13 新增子查询支持,可在 WHERE 条件中嵌套 SELECT。</p>
<pre><span class="c">// IN 子查询 — 查询有高额订单的用户</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`</span>);
<span class="c">// 标量子查询 — 查询年龄等于平均年龄的用户</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
WHERE age = (SELECT AVG(age) FROM users)`</span>);
<span class="c">// NOT IN 子查询</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders)`</span>);</pre>
<h2 id="alter-table">🏗 ALTER TABLE (🆕 v0.2.5)</h2>
<p>v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。</p>
<h3>ADD COLUMN</h3>
<pre><span class="c">-- 添加新列</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN phone STRING'</span>);
<span class="c">-- 带约束的添加</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN email STRING UNIQUE'</span>);
<span class="c">-- 带可选 COLUMN 关键字</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN age NUMBER DEFAULT 0'</span>);</pre>
<h3>DROP COLUMN</h3>
<pre><span class="c">-- 删除列</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN phone'</span>);
<span class="c">-- 带可选 COLUMN 关键字</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN email'</span>);</pre>
<table>
<tr><th>语法</th><th>说明</th></tr>
<tr><td><code>ALTER TABLE name ADD COLUMN col type [constraints]</code></td><td>添加列(COLUMN 可选)</td></tr>
<tr><td><code>ALTER TABLE name DROP COLUMN col</code></td><td>删除列(COLUMN 可选)</td></tr>
</table>
<h2 id="truncate">🗑 TRUNCATE TABLE (🆕 v0.2.5)</h2>
<p>v0.2.5 新增 TRUNCATE TABLE 语法,快速清空表数据(保留表结构)。</p>
<pre><span class="c">-- 快速清空表数据</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);
<span class="c">-- 等价于 DELETE FROM old_logs,但语义更清晰</span></pre>
<table>
<tr><th>语法</th><th>说明</th></tr>
<tr><td><code>TRUNCATE TABLE name</code></td><td>清空表数据,保留表结构</td></tr>
</table>
<h2 id="foreign-key">🔗 外键级联</h2>
<p>v0.1.13 支持外键级联操作,定义表时可指定 ON DELETE / ON UPDATE 行为。</p>
<pre><span class="c">// 定义时指定外键 + 级联策略</span>
<span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'orders'</span>, {
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
<span class="s">user_id</span>: {
<span class="s">type</span>: <span class="s">'string'</span>,
<span class="s">references</span>: <span class="s">'users.id'</span>,
<span class="s">onDelete</span>: <span class="s">'CASCADE'</span>, <span class="c">// 删除用户时级联删除订单</span>
<span class="s">onUpdate</span>: <span class="s">'RESTRICT'</span>, <span class="c">// 禁止更新被引用的用户 ID</span>
},
<span class="s">amount</span>: { <span class="s">type</span>: <span class="s">'number'</span> },
});
<span class="c">// SQL DDL 同样支持</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE orders (
id STRING PRIMARY KEY,
user_id STRING REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
amount NUMBER
)`</span>);
<span class="c">// 删除用户 → 其所有订单自动删除</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"DELETE FROM users WHERE id = '1'"</span>);</pre>
<table>
<tr><th>级联选项</th><th>行为</th></tr>
<tr><td><code>CASCADE</code></td><td>级联删除/更新子表中的匹配行</td></tr>
<tr><td><code>SET NULL</code></td><td>将子表中的外键列设为 NULL</td></tr>
<tr><td><code>RESTRICT</code></td><td>禁止操作(默认行为)</td></tr>
</table>
<h2 id="connection-pool">🏊 连接池</h2>
<p>v0.1.13 新增连接池管理器,避免重复创建同名数据库实例。</p>
<pre><span class="c">// connect() — 获取或创建实例(单例复用)</span>
<span class="k">const</span> db1 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span>, <span class="s">mode</span>: <span class="s">'hybrid'</span> });
<span class="k">const</span> db2 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span> });
<span class="c">// db1 === db2 — 复用已有实例,避免重复 open IndexedDB</span>
<span class="c">// disconnect() — 释放连接(引用计数 -1)</span>
<span class="k">await</span> db2.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 2 → 1</span>
<span class="k">await</span> db1.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 1 → 0,自动 close()</span>
<span class="c">// disconnectAll() — 强制关闭所有连接</span>
<span class="k">await</span> <span class="f">MetonaSqlark.disconnectAll</span>();
<span class="c">// getActiveConnections() — 查看活跃连接</span>
<span class="f">MetonaSqlark.getActiveConnections</span>(); <span class="c">// ['my-app']</span></pre>
<h2 id="migration">🔄 数据迁移</h2>
<p>按版本号管理表结构变更。</p>
<pre><span class="c">// 注册迁移</span>
db.<span class="f">addMigration</span>(<span class="n">2</span>, <span class="k">async</span> (db) => {
<span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'products'</span>, {
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
<span class="s">name</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">required</span>: <span class="k">true</span> },
});
});
db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k">async</span> (db) => {
<span class="c">// 添加新列、数据迁移等</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"UPDATE users SET role = 'user' WHERE role IS NULL"</span>);
});
<span class="c">// 执行迁移到目标版本</span>
<span class="k">await</span> db.<span class="f">migrateTo</span>(<span class="n">3</span>); <span class="c">// 依次执行 v2, v3 的迁移函数</span></pre>
<p><strong>v0.4.2: 迁移版本持久化到库内</strong> — 重启后从持久化版本继续执行,已执行迁移不重跑(此前 version 每次从 config 重置,可能重跑不幂等的迁移)。</p>
<h2 id="selfheal">🛡 崩溃恢复自愈(v0.4.2</h2>
<p>异常退出(强杀/断电)后无需删库重建:打开数据库时自动跳过残缺 SSTable,应用层可调用自愈 API 恢复一致性。</p>
<pre><span class="c">// 自愈 — 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL</span>
<span class="k">await</span> db.<span class="f">repair</span>();
<span class="c">// 清空全部数据与表结构(保留库本身,实例可继续使用)</span>
<span class="k">await</span> db.<span class="f">clearAll</span>();
<span class="c">// 引擎级元数据(迁移版本等)</span>
<span class="c">// 引擎接口 IStorageEngine 可选扩展:repair() / clearAll() / getMeta() / setMeta()</span></pre>
<h2 id="export">📤 导入导出</h2>
<pre><span class="c">// 导出单表 — 返回 JSON 数组</span>
<span class="k">const</span> userData = <span class="k">await</span> db.<span class="f">exportTable</span>(<span class="s">'users'</span>);
<span class="c">// [{ id: '1', name: 'Alice', ... }, ...]</span>
<span class="c">// 导出全库 — 返回 { tableName: rows[] }</span>
<span class="k">const</span> allData = <span class="k">await</span> db.<span class="f">exportAll</span>();
<span class="c">// { users: [...], orders: [...], products: [...] }</span>
<span class="c">// 导入数据 — 返回主键列表</span>
<span class="k">const</span> pks = <span class="k">await</span> db.<span class="f">importTable</span>(<span class="s">'users'</span>, userData);</pre>
<h2 id="plugin">🧩 插件 & 钩子</h2>
<p>14 种生命周期钩子,支持插件机制。</p>
<table>
<tr><th>钩子名称</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>beforeCreateTable</code></td><td>创建表前</td><td>schema</td></tr>
<tr><td><code>afterCreateTable</code></td><td>创建表后</td><td>schema</td></tr>
<tr><td><code>beforeDropTable</code></td><td>删除表前</td><td>tableName</td></tr>
<tr><td><code>afterDropTable</code></td><td>删除表后</td><td>tableName</td></tr>
<tr><td><code>beforeInsert</code></td><td>插入前</td><td>rows[]</td></tr>
<tr><td><code>afterInsert</code></td><td>插入后</td><td>rows[]</td></tr>
<tr><td><code>beforeUpdate</code></td><td>更新前</td><td>query, updates</td></tr>
<tr><td><code>afterUpdate</code></td><td>更新后</td><td>query, updates, count</td></tr>
<tr><td><code>beforeDelete</code></td><td>删除前</td><td>query</td></tr>
<tr><td><code>afterDelete</code></td><td>删除后</td><td>query, count</td></tr>
<tr><td><code>beforeQuery</code></td><td>SQL 查询前</td><td>sql</td></tr>
<tr><td><code>afterQuery</code></td><td>SQL 查询后</td><td>sql, result</td></tr>
<tr><td><code>beforeTransaction</code></td><td>事务开始前</td><td>-</td></tr>
<tr><td><code>afterTransaction</code></td><td>事务完成后</td><td>-</td></tr>
</table>
<pre><span class="c">// 注册钩子</span>
db.<span class="f">on</span>(<span class="s">'beforeInsert'</span>, <span class="k">async</span> (row) => {
<span class="f">console.log</span>(<span class="s">'即将插入:'</span>, row);
<span class="c">// 可在此校验、转换数据</span>
});
db.<span class="f">on</span>(<span class="s">'afterQuery'</span>, <span class="k">async</span> (sql, result) => {
<span class="f">console.log</span>(<span class="s">`查询完成 [</span><span class="k">${sql}</span><span class="s">] → </span><span class="k">${(result as any[]).length}</span><span class="s"> 行`</span>);
});
<span class="c">// 注册自定义插件</span>
<span class="k">const</span> loggerPlugin = {
<span class="s">name</span>: <span class="s">'logger'</span>,
<span class="s">version</span>: <span class="s">'1.0.0'</span>,
<span class="s">description</span>: <span class="s">'记录所有数据库操作'</span>,
<span class="s">priority</span>: <span class="n">100</span>,
<span class="f">install</span>(db) {
db.<span class="f">on</span>(<span class="s">'beforeQuery'</span>, (sql) => <span class="f">console.log</span>(<span class="s">'SQL:'</span>, sql));
},
<span class="f">destroy</span>() { <span class="c">/* 清理 */</span> },
};
<span class="c">// 在 create 配置中注册</span>
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">plugins</span>: [loggerPlugin],
});</pre>
<h2 id="subscribe">📡 发布订阅</h2>
<pre><span class="c">// 订阅表变更</span>
<span class="k">const</span> unsubscribe = db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
<span class="c">// event.type: 'insert' | 'update' | 'delete'</span>
<span class="c">// event.row: 被操作的行数据</span>
<span class="f">console.log</span>(<span class="s">`users 表 </span><span class="k">${event.type}</span><span class="s">`</span>, event.row);
});
<span class="c">// 手动触发变更</span>
db.<span class="f">emit</span>(<span class="s">'users'</span>, { <span class="s">type</span>: <span class="s">'insert'</span>, <span class="s">row</span>: { <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span> } });
<span class="c">// 取消订阅</span>
<span class="f">unsubscribe</span>();</pre>
<h3>多标签页同步 (v0.3.2)</h3>
<pre><span class="c">// 启用 multiTabSync 后,其他标签页的写操作会广播到此标签页</span>
<span class="k">const</span> db = <span class="k">await</span> MetonaSqlark.<span class="f">create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
<span class="s">multiTabSync</span>: <span class="k">true</span>,
});
<span class="c">// 订阅其他标签页的变更(event.type === 'external'</span>
db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
<span class="k">if</span> (event.<span class="s">type</span> === <span class="s">'external'</span>) {
<span class="c">// Hybrid 模式已自动从磁盘重载,此处可刷新 UI</span>
<span class="f">refreshList</span>();
}
});
<span class="c">// 手动广播(Table API 已自动广播;自定义写入可调用)</span>
db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<h2 id="react">⚛️ React 集成</h2>
<pre><span class="k">import</span> { useQuery, useTable, useDatabase } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark/react'</span>;
<span class="k">import</span> { db } <span class="k">from</span> <span class="s">'./db'</span>;
<span class="k">function</span> <span class="f">UserList</span>() {
<span class="c">// 执行 SQL 查询,自动响应 db 变化</span>
<span class="k">const</span> { data, loading, error, refresh } = <span class="f">useQuery</span>(
db,
<span class="s">'SELECT * FROM users WHERE age > 18'</span>,
[<span class="c">/* deps */</span>]
);
<span class="k">if</span> (loading) <span class="k">return</span> &lt;div&gt;Loading...&lt;/div&gt;;
<span class="k">if</span> (error) <span class="k">return</span> &lt;div&gt;Error: {error.message}&lt;/div&gt;;
<span class="k">return</span> (
&lt;div&gt;
{data.<span class="f">map</span>(u =&gt; &lt;div key={u.id}&gt;{u.name} ({u.age})&lt;/div&gt;)}
&lt;button onClick={refresh}&gt;刷新&lt;/button&gt;
&lt;/div&gt;
);
}
<span class="c">// 便捷 hook — 查询整张表</span>
<span class="k">const</span> { data, loading, refresh } = <span class="f">useTable</span>(db, <span class="s">'users'</span>);
<span class="c">// 管理数据库生命周期</span>
<span class="k">function</span> <span class="f">App</span>() {
<span class="k">const</span> { db, ready, error } = <span class="f">useDatabase</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
});
<span class="k">if</span> (!ready) <span class="k">return</span> &lt;div&gt;Initializing...&lt;/div&gt;;
<span class="k">return</span> &lt;UserList /&gt;;
}</pre>
<h2 id="vue">🟢 Vue 集成</h2>
<pre><span class="k">import</span> { useSqlarkQuery, useSqlarkTable, useSqlarkDatabase }
<span class="k">from</span> <span class="s">'@metona-team/metona-sqlark/vue'</span>;
<span class="k">import</span> { db } <span class="k">from</span> <span class="s">'./db'</span>;
<span class="c">// useSqlarkQuery — 执行 SQL 查询</span>
<span class="k">const</span> { data, loading, error, refresh } = <span class="f">useSqlarkQuery</span>(
db,
<span class="s">'SELECT * FROM users WHERE age > 18'</span>
);
<span class="c">// useSqlarkTable — 获取全表数据</span>
<span class="k">const</span> { data, loading, refresh } = <span class="f">useSqlarkTable</span>(db, <span class="s">'users'</span>);
<span class="c">// useSqlarkDatabase — 管理数据库生命周期</span>
<span class="k">const</span> { db, ready, error } = <span class="f">useSqlarkDatabase</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
});</pre>
<h2 id="types">🔷 TypeScript 泛型</h2>
<pre><span class="k">interface</span> <span class="t">User</span> {
<span class="s">id</span>: <span class="t">string</span>;
<span class="s">name</span>: <span class="t">string</span>;
<span class="s">age</span>: <span class="t">number</span>;
<span class="s">email</span>?: <span class="t">string</span>;
}
<span class="c">// 泛型表操作 — 类型安全的 insert/select</span>
<span class="k">const</span> users = db.<span class="f">table</span>&lt;<span class="t">User</span>&gt;(<span class="s">'users'</span>);
<span class="c">// ✅ 类型检查通过</span>
<span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span>, <span class="s">age</span>: <span class="n">30</span> });
<span class="c">// ❌ TypeScript 报错:缺少 name</span>
<span class="c">// await users.insert({ id: '2', age: 25 });</span></pre>
<h2 id="config">⚙️ 完整配置项</h2>
<table>
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td><code>name</code></td><td><code>string</code></td><td><code>'metona-sqlark'</code></td><td>数据库名称(必填)</td></tr>
<tr><td><code>mode</code></td><td><code>'memory'|'disk'|'hybrid'|'aria'</code></td><td><code>'hybrid'</code></td><td>存储模式 🆕 aria</td></tr>
<tr><td><code>diskEngine</code></td><td><code>'indexeddb'|'opfs'</code></td><td><code>'indexeddb'</code></td><td>磁盘引擎类型</td></tr>
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</code></td><td>数据库版本号</td></tr>
<tr><td><code>plugins</code></td><td><code>MetonaPlugin[]</code></td><td><code>[]</code></td><td>初始插件列表</td></tr>
<tr><td><code>onReady</code></td><td><code>(db) => void</code></td><td>-</td><td>初始化完成回调</td></tr>
<tr><td><code>onError</code></td><td><code>(err) => void</code></td><td>-</td><td>错误回调(v0.2.5 接入执行路径)</td></tr>
<tr><td><code>maxRowsPerQuery</code></td><td><code>number</code></td><td><code>0</code></td><td>查询结果行数上限(0=不限制)✅ v0.2.5 生效</td></tr>
<tr><td><code>multiTabSync</code></td><td><code>boolean</code></td><td><code>false</code></td><td>多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2</td></tr>
</table>
<h2 id="engine">💾 存储引擎</h2>
<table>
<tr><th>引擎</th><th>模式</th><th>持久化</th><th>索引</th><th>事务</th><th>适用场景</th></tr>
<tr><td><code>MemoryEngine</code></td><td>memory</td><td></td><td>哈希</td><td>快照回滚</td><td>临时数据、缓存、测试</td></tr>
<tr><td><code>IndexedDBEngine</code></td><td>disk</td><td>✅ IDB</td><td>IDB 索引</td><td>延迟写入</td><td>通用持久化,兼容性最好</td></tr>
<tr><td><code>OPFSEngine</code></td><td>disk</td><td>✅ OPFS</td><td>哈希</td><td>快照回滚</td><td>现代浏览器,文件级存储</td></tr>
<tr><td><code>HybridEngine</code></td><td>hybrid</td><td>✅ Write-Through</td><td>哈希</td><td>双引擎代理</td><td>生产推荐,读写均走内存</td></tr>
<tr style="border-top:2px solid var(--primary);"><td><code style="color:#ec4899;font-weight:700;">AriaEngine</code></td><td>aria</td><td>✅ WAL + SSTable</td><td>LSM-Tree</td><td>MVCC 快照隔离</td><td>自研引擎:大表、高并发、需崩溃恢复</td></tr>
</table>
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。<br>
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待。<br>
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2"SSTC")与 v1"SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持 · 958 测试 52 套件。</p>
<h3>存储模式对比</h3>
<table>
<tr><th>特性</th><th>Memory</th><th>Disk(IDB)</th><th>Disk(OPFS)</th><th>Hybrid</th><th>Aria</th></tr>
<tr><td>持久化</td><td></td><td>✅ IDB</td><td>✅ OPFS</td><td>✅ 双写</td><td>✅ 后端决定</td></tr>
<tr><td>事务</td><td>✅ 快照</td><td>✅ 原子</td><td>✅ 快照</td><td>✅ 双引擎</td><td>✅ MVCC</td></tr>
<tr><td>索引</td><td>Hash</td><td>Hash</td><td>Hash</td><td>Hash</td><td>LSM二级</td></tr>
<tr><td>上限</td><td>内存</td><td>~2GB</td><td>磁盘</td><td>~2GB</td><td>内存</td></tr>
<tr><td>浏览器</td><td>全部</td><td>全部</td><td>Chromium</td><td>全部</td><td>全部</td></tr>
</table>
<h3>核心特性</h3>
<table>
<tr><th>特性</th><th>说明</th></tr>
<tr><td><strong>LSM-Tree 索引</strong></td><td>MemTable (红黑树) + 多级 SSTable,写优化,支持范围扫描</td></tr>
<tr><td><strong>Slotted Page 格式</strong></td><td>4KB 固定页面,Slot Directory + Tuple 二进制序列化</td></tr>
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB)</td></tr>
<tr><td><strong>WAL 日志</strong></td><td>Write-Ahead Log 保证崩溃恢复,支持 full/batch/none 三种同步模式(full 模式真正同步 ✅ v0.2.5),16MB 阈值自动 checkpoint</td></tr>
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC,读写路径接入版本链 ✅ v0.2.5</td></tr>
<tr><td><strong>Bloom Filter</strong></td><td>快速判定 key 不存在,减少无效磁盘 I/OSSTableReader 二分查找统一 ✅ v0.2.5</td></tr>
<tr><td><strong>LZ4 压缩</strong></td><td>可选页面级压缩,空间效率提升</td></tr>
</table>
<h3>使用方式</h3>
<pre><span class="c">// 激活 AriaEngine</span>
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'aria'</span>, <span class="c">// 🆕 AriaEngine 模式</span>
<span class="s">diskEngine</span>: <span class="s">'indexeddb'</span>, <span class="c">// 底层存储后端(indexeddb | opfs | memory</span>
});
<span class="c">// 或直接实例化 — 支持细粒度配置</span>
<span class="k">import</span> { <span class="t">AriaEngine</span> } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark'</span>;
<span class="k">const</span> engine = <span class="k">new</span> <span class="f">AriaEngine</span>({
<span class="s">pageSize</span>: <span class="n">4096</span>, <span class="c">// 页面大小</span>
<span class="s">bufferPoolPages</span>: <span class="n">256</span>, <span class="c">// 缓存页数</span>
<span class="s">memtableSizeThreshold</span>: <span class="n">4194304</span>, <span class="c">// MemTable 刷盘阈值 4MB</span>
<span class="s">walEnabled</span>: <span class="k">true</span>, <span class="c">// 启用 WAL</span>
<span class="s">walSyncMode</span>: <span class="s">'batch'</span>, <span class="c">// 'full' | 'batch' | 'none'</span>
<span class="s">storageBackend</span>: <span class="s">'indexeddb'</span>, <span class="c">// 存储后端</span>
});</pre>
<h3>AriaEngine 配置项</h3>
<table>
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td><code>pageSize</code></td><td><code>number</code></td><td><code>4096</code></td><td>页面大小(字节)</td></tr>
<tr><td><code>bufferPoolPages</code></td><td><code>number</code></td><td><code>256</code></td><td>Buffer Pool 页面数量</td></tr>
<tr><td><code>memtableSizeThreshold</code></td><td><code>number</code></td><td><code>4194304</code></td><td>MemTable 刷盘阈值(字节)</td></tr>
<tr><td><code>levelSizeMultiplier</code></td><td><code>number</code></td><td><code>10</code></td><td>LSM 层级容量倍数</td></tr>
<tr><td><code>bloomFilterBitsPerKey</code></td><td><code>number</code></td><td><code>10</code></td><td>Bloom Filter 每 key 位数</td></tr>
<tr><td><code>walEnabled</code></td><td><code>boolean</code></td><td><code>true</code></td><td>是否启用 WAL</td></tr>
<tr><td><code>walSyncMode</code></td><td><code>'full'|'batch'|'none'</code></td><td><code>'batch'</code></td><td>WAL 同步策略(full 模式真正同步 ✅ v0.2.5</td></tr>
<tr><td><code>checkpointInterval</code></td><td><code>number</code></td><td><code>1000</code></td><td>Checkpoint 触发间隔(操作数)</td></tr>
<tr><td><code>walSizeThreshold</code></td><td><code>number</code></td><td><code>16777216</code></td><td>WAL 大小阈值(字节),超阈值触发 checkpoint ✅ v0.2.5</td></tr>
<tr><td><code>compression</code></td><td><code>boolean</code></td><td><code>false</code></td><td>是否启用页面压缩</td></tr>
<tr><td><code>storageBackend</code></td><td><code>'indexeddb'|'opfs'|'memory'</code></td><td><code>'indexeddb'</code></td><td>存储后端类型</td></tr>
</table>
<h2 id="errors">⚠️ 错误处理</h2>
<p>所有错误抛出 <code>DatabaseError</code> 实例。</p>
<pre><span class="k">try</span> {
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM nonexistent'</span>);
} <span class="k">catch</span> (err) {
<span class="k">if</span> (err <span class="k">instanceof</span> <span class="t">DatabaseError</span>) {
<span class="f">console.log</span>(err.message); <span class="c">// 'Table "nonexistent" does not exist'</span>
<span class="f">console.log</span>(err.code); <span class="c">// 'TABLE_NOT_FOUND'</span>
<span class="f">console.log</span>(err.details); <span class="c">// 附加信息</span>
}
}</pre>
<table>
<tr><th>错误码</th><th>触发场景</th></tr>
<tr><td><code>TABLE_NOT_FOUND</code></td><td>表不存在</td></tr>
<tr><td><code>TABLE_EXISTS</code></td><td>表已存在</td></tr>
<tr><td><code>DUPLICATE_KEY</code></td><td>主键重复</td></tr>
<tr><td><code>UNIQUE_VIOLATION</code></td><td>唯一约束冲突</td></tr>
<tr><td><code>VALIDATION_ERROR</code></td><td>数据校验失败</td></tr>
<tr><td><code>TYPE_ERROR</code></td><td>字段类型错误</td></tr>
<tr><td><code>SCHEMA_ERROR</code></td><td>表结构定义错误</td></tr>
<tr><td><code>DB_NOT_READY</code></td><td>数据库未初始化</td></tr>
<tr><td><code>PARSE_ERROR</code></td><td>SQL 语法错误</td></tr>
<tr><td><code>TRANSACTION_ERROR</code></td><td>事务执行失败</td></tr>
<tr><td><code>COMPILE_ERROR</code></td><td>编译 AST 到查询计划失败</td></tr>
<tr><td><code>CONFIG_ERROR</code></td><td>配置错误</td></tr>
</table>
</main>
</div>
<script>
document.querySelectorAll('.sidebar a').forEach(a => {
a.addEventListener('click', function() {
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
this.classList.add('active');
});
});
</script>
</body>
</html>
+546
View File
@@ -0,0 +1,546 @@
<!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: #070b14; --surface: rgba(255,255,255,0.03); --surface2: rgba(255,255,255,0.05);
--border: rgba(255,255,255,0.08); --text: #e8ecf4; --muted: #8b94a7;
--accent: #a5b4fc; --grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
--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.65; min-height:100vh;
background-image:
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(99,102,241,0.12), transparent),
radial-gradient(ellipse 50% 40% at 90% 20%, rgba(168,85,247,0.07), transparent);
}
a { color:var(--accent); text-decoration:none; }
code { background:rgba(99,102,241,0.15); color:#a5b4fc; padding:1px 6px; border-radius:5px; font-size:.85em; }
.grad-text { background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent; }
.nav { position:sticky; top:0; z-index:100; background:rgba(7,11,20,0.72); backdrop-filter:blur(16px); border-bottom:1px solid var(--border); }
.nav-inner { max-width:1320px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; padding:0 22px; height:58px; }
.nav-logo { font-size:18px; font-weight:800; }
.nav-links { display:flex; gap:22px; }
.nav-links a { color:var(--muted); font-size:13.5px; font-weight:500; transition:.2s; }
.nav-links a:hover { color:var(--accent); }
.hero { text-align:center; padding:38px 20px 28px; }
.hero h1 { font-size:34px; font-weight:800; }
.hero p { color:var(--muted); font-size:14px; margin-top:6px; }
.container { max-width:1320px; margin:0 auto; padding:20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:16px; }
.card {
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
padding:20px; transition:border-color .2s, transform .2s; position:relative; overflow:hidden;
}
.card:hover { border-color:rgba(129,140,248,.35); }
.card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px;
background:var(--grad); opacity:0; transition:opacity .25s;
}
.card:hover::before { opacity:.8; }
.card h2 { font-size:14.5px; margin-bottom:2px; font-weight:700; }
.card > p { color:var(--muted); font-size:11.5px; margin-bottom:12px; }
.btn-row { display:flex; flex-wrap:wrap; gap:6px; }
.btn {
padding:8px 14px; border:none; border-radius:8px; font-size:12px; font-weight:600;
cursor:pointer; transition:transform .15s, filter .15s, box-shadow .15s; color:#fff;
}
.btn:hover { transform:translateY(-1.5px); filter:brightness(1.15); box-shadow:0 6px 18px -6px rgba(0,0,0,.5); }
.btn-sm { padding:5.5px 11px; font-size:11px; border-radius:6px; }
.btn-tiny { padding:4px 9px; font-size:10px; border-radius:5px; }
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
.btn-outline:hover { border-color:rgba(129,140,248,.5); background:rgba(129,140,248,.08); }
.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; }
.c-grad { background:var(--grad); }
.label {
font-size:10px; text-transform:uppercase; letter-spacing:1px; color:var(--muted);
margin-bottom:10px; font-weight:700;
}
.demo-section { border-top:1px solid var(--border); padding-top:10px; margin-top:10px; }
.status-line { font-size:11.5px; color:#34d399; margin-top:10px; font-family:monospace; min-height:18px; }
footer { text-align:center; padding:30px 20px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }
@media (max-width:820px) { .container { grid-template-columns:1fr; } }
</style>
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<div class="nav-logo"><span class="grad-text">MetonaToast</span></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 class="grad-text">在线演示</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 链式转换 <span style="color:#34d399">id 稳定</span></div>
<div class="btn-row">
<button class="btn c-s" onclick="demoLoading('success')">提交成功</button>
<button class="btn c-e" onclick="demoLoading('error')">提交失败</button>
<button class="btn c-w" onclick="demoLoading('warning')">转为警告</button>
<button class="btn c-i" onclick="demoLoading('update')">原地更新</button>
</div>
<p class="status-line" id="loading-status">转换后返回的 toast 与 loading 是同一个实例(id 不变)</p>
</div>
<!-- 3. 对话框 -->
<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('horizontal')">进度条(水平)</button>
<button class="btn c-g" onclick="demoProgress('vertical')">进度条(垂直)</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">close:false 不关闭</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">📁 分组管理</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 class="demo-section">
<div class="btn-row">
<button class="btn c-grad" onclick="demoCustomAnim()">✨ 注册自定义动画</button>
</div>
</div>
</div>
<!-- 9. 位置 -->
<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">🔌 插件系统 <span style="color:#34d399">4 款预设</span></div>
<div class="btn-row">
<button class="btn btn-outline btn-sm" onclick="MeToast.use('keyboard');MeToast.info('已安装 — 按 ESC 关闭所有 Toast')">keyboard</button>
<button class="btn btn-outline btn-sm" onclick="MeToast.use('persistence');MeToast.info('已安装 — 配置持久化')">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="MeToast.use('dedupe');MeToast.info('已安装 — 同消息去重')">dedupe</button>
<button class="btn btn-outline btn-sm" onclick="demoCustomPlugin()">自定义插件</button>
</div>
<div class="demo-section">
<div class="btn-row">
<button class="btn c-t btn-sm" onclick="demoDedupe()">去重演示</button>
</div>
</div>
</div>
<!-- 12. 钩子系统 -->
<div class="card">
<div class="label">🔗 钩子系统 <span style="color:#34d399">beforeShow 拦截演示</span></div>
<div class="btn-row">
<button class="btn c-p" onclick="demoHookBlock()">开启拦截 error</button>
<button class="btn c-e" onclick="MeToast.error('这条会被拦截')">触发被拦 error</button>
<button class="btn c-s" onclick="MeToast.success('这条不受影响')">正常 success</button>
<button class="btn btn-outline btn-sm" onclick="demoHookUnblock()">关闭拦截</button>
<button class="btn btn-outline btn-sm" onclick="demoHookCount()">钩子统计</button>
</div>
<p class="status-line" id="hook-status">钩子:未注册</p>
</div>
<!-- 13. 高级特性 -->
<div class="card">
<div class="label">🔥 高级特性</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>
<!-- 14. 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.init({});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>
<button class="btn btn-outline btn-tiny" onclick="demoRemove()">removeToast 立即移除</button>
</div>
</div>
</div>
<!-- 15. onError -->
<div class="card">
<div class="label">🛡️ onError 错误捕获</div>
<div class="btn-row">
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
</div>
</div>
<!-- 16. 交互特性 -->
<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="demoDragThreshold()">拖拽阈值 300px</button>
<button class="btn c-w" onclick="demoPauseHover()">悬停暂停</button>
<button class="btn c-pp" onclick="demoCloseOnClick()">点击关闭</button>
<button class="btn c-t" onclick="demoWidth()">自定义宽度</button>
<button class="btn c-sl" onclick="demoClassName()">自定义 className</button>
</div>
</div>
<!-- 17. 国际化 -->
<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>
<!-- 18. 图标网格 -->
<div class="card" style="grid-column:1/-1;">
<div class="label">🎨 107 种预设图标 — 点击任意图标发送 Toast</div>
<div class="btn-row" id="icon-grid" style="max-height:320px;overflow-y:auto;padding:4px 0;"></div>
</div>
</main>
<footer>
MetonaToast v0.5.0 · 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(mode) {
const l = MeToast.loading('正在处理...');
const showId = () => document.getElementById('loading-status').textContent = 'loading id: ' + l.id;
showId();
setTimeout(() => {
const map = { success:['提交成功!','success'], error:['处理失败','error'], warning:['发现潜在问题','warning'], update:null };
if (mode === 'update') {
l.update({ message: '进度 50%' });
setTimeout(() => l.success('完成!'), 1000);
return;
}
const [msg, type] = map[mode];
const t = l[type](msg);
if (t) document.getElementById('loading-status').textContent = '转换后 id: ' + t.id + '' + (t.id === l.id ? '同一实例 ✓' : '不同实例 ✗') + '';
}, 1600);
}
// === 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(dir) {
const p=MeToast.progress('上传中...',{progressColor: dir==='vertical'?'#10b981':'#3b82f6', progressDirection: dir});
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() {
let n=0;
MeToast.action('点「+1」不会关闭,可连续点击',[{text:'+1',onClick:(t)=>{n++;t.update({message:'已点击 '+n+' 次(close:false 不关闭)'});},color:'#10b981',close:false}],{duration:8000});
}
// === 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}); }
function demoCustomAnim() {
MeToast.animations.register('swing', {
enter: { transform: 'rotate(-40deg) scale(0.3)', opacity: 0 },
leave: { transform: 'rotate(20deg) scale(0.5)', opacity: 0 },
duration: 600,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
});
MeToast.success('自定义 swing 动画!', { animation: 'swing', duration: 3000 });
}
// === 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('自定义插件已安装(消息后追加计数)');
}
function demoDedupe() {
if (!MeToast.plugins.has('dedupe')) MeToast.use('dedupe');
MeToast.info('同一条消息');
MeToast.info('同一条消息');
MeToast.info('同一条消息');
MeToast.info('当前仅 ' + MeToast.count() + ' 条 — 相同消息自动合并');
}
// === Hooks ===
let hookOff = null;
function demoHookBlock() {
if (hookOff) hookOff();
hookOff = Toast.on('beforeShow', (toast) => {
if (toast.type === 'error') { document.getElementById('hook-status').textContent = '钩子:error 已被拦截 ✋'; return false; }
});
document.getElementById('hook-status').textContent = '钩子:已注册(error 类型将被拦截)';
MeToast.info('拦截器已开启 — 试试触发 error');
}
function demoHookUnblock() {
if (hookOff) { hookOff(); hookOff = null; }
document.getElementById('hook-status').textContent = '钩子:已移除';
MeToast.info('拦截器已关闭');
}
function demoHookCount() {
const stats = [];
['afterShow','afterClose','configChange','themeChange'].forEach(name => {
const list = Toast._hooks.get(name);
stats.push(name + ':' + (list ? list.length : 0));
});
MeToast.info('钩子数量 — ' + stats.join(' '), { duration: 5000 });
}
// === 高级特性 ===
function demoResetTimer() {
const t=MeToast.info('这条不会自动消失(每次更新重置 3 秒)',{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});},1000);
}
function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); }
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</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() { 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}); }
function demoRemove() { const t=MeToast.warning('这条将被立即移除(无动画)'); setTimeout(()=>{MeToast.removeToast(t.id);MeToast.info('已立即移除');},800); }
// === onError ===
function demoOnError() {
MeToast.configure({ onError: function(info) { MeToast.error('⚠️ 捕获: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' }); } });
MeToast.info('已启用 onError 监控', { duration: 3000 });
var badFn = function() { throw new Error('demo-error'); };
Toast.on('afterShow', badFn);
MeToast.success('这条会触发错误回调', { duration: 2000 });
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
}
function demoOnErrorReset() { MeToast.configure({ onError: null }); MeToast.info('onError 已重置'); }
// === Interaction ===
function demoDraggable() { MeToast.info('← 拖动我超过 120px 关闭',{draggable:true,duration:8000,closeButton:true}); }
function demoDragThreshold() { MeToast.info('← 拖动阈值 300px(要拖更远)',{draggable:true,dragThreshold:300,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) {
MeToast.i18n.switchLocale(loc); MeToast.configure({locale:loc});
MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English');
}
function demoFormat() {
MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000});
}
// === 图标网格 ===
(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>
+505
View File
@@ -0,0 +1,505 @@
<!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: #070b14; --surface: rgba(255,255,255,0.03); --border: rgba(255,255,255,0.08);
--text: #e8ecf4; --muted: #8b94a7; --accent: #a5b4fc; --accent2: #c7d2fe;
--grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
--radius: 12px;
}
* { margin:0; padding:0; box-sizing:border-box; }
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.75; display:flex; min-height:100vh;
background-image:
radial-gradient(ellipse 70% 40% at 80% -10%, rgba(99,102,241,0.12), transparent),
radial-gradient(ellipse 50% 40% at 0% 20%, rgba(236,72,153,0.05), transparent);
}
code {
background:rgba(99,102,241,0.15); color:#a5b4fc; padding:1px 6px;
border-radius:5px; font-size:.88em; white-space:nowrap;
}
a { color:var(--accent); text-decoration:none; }
a:hover { color:var(--accent2); }
/* ===== 侧边栏 ===== */
.sidebar {
width:264px; min-width:264px; background:rgba(10,15,26,.8);
border-right:1px solid var(--border); padding:22px 0;
position:sticky; top:0; height:100vh; overflow-y:auto;
backdrop-filter:blur(14px);
}
.sidebar::-webkit-scrollbar { width:5px; }
.sidebar::-webkit-scrollbar-thumb { background:rgba(255,255,255,.12); border-radius:3px; }
.sidebar-logo { font-size:16px; font-weight:800; padding:0 24px; margin-bottom:4px; }
.grad-text { background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent; }
.sidebar .back { display:block; font-size:12.5px; padding:6px 24px; color:var(--muted); }
.sidebar .back:hover { color:var(--accent2); }
.sidebar a.snav {
display:block; padding:6.5px 24px; font-size:13px; color:var(--muted);
transition:.15s; border-left:2px solid transparent;
}
.sidebar a.snav:hover { color:var(--accent2); }
.sidebar a.snav.active { color:var(--accent2); border-left-color:#a855f7; background:rgba(139,92,246,.08); }
.sidebar .section {
font-size:11px; text-transform:uppercase; letter-spacing:1.2px;
padding:18px 24px 6px; color:var(--muted); font-weight:700; opacity:.75;
}
/* ===== 主内容 ===== */
.main { flex:1; padding:44px 52px; max-width:980px; min-width:0; }
.main h1 { font-size:32px; font-weight:800; margin-bottom:6px; letter-spacing:-.5px; }
.main .h1-desc { color:var(--muted); font-size:14px; margin-bottom:8px; }
.main h2 {
font-size:22px; font-weight:700; margin:52px 0 12px; padding-top:22px;
border-top:1px solid var(--border); scroll-margin-top:24px;
}
.main h2::before { content:'#'; margin-right:8px; background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
.main h3 { font-size:16.5px; font-weight:700; margin:26px 0 10px; color:var(--accent2); }
.main p { margin:8px 0; color:var(--muted); font-size:14px; }
.main p code { font-size:.9em; }
.main table { width:100%; border-collapse:collapse; margin:14px 0 22px; 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:600; font-size:11px; text-transform:uppercase; letter-spacing:.8px; }
.main td:first-child { color:var(--accent); font-family:monospace; font-size:12px; white-space:nowrap; }
.main td:nth-child(3) { font-family:monospace; font-size:11.5px; color:var(--muted); }
.badge {
display:inline-block; padding:1px 8px; border-radius:4px; font-size:10px;
font-weight:700; margin-left:6px; vertical-align:middle;
}
.badge-new { background:rgba(16,185,129,.18); color:#34d399; border:1px solid rgba(16,185,129,.3); }
.badge-v5 { background:rgba(168,85,247,.2); color:#d8b4fe; border:1px solid rgba(168,85,247,.4); }
.hint { font-size:12.5px; color:var(--muted); margin-top:2px; }
.return-box {
background:rgba(16,185,129,.06); border:1px solid rgba(16,185,129,.2);
border-radius:10px; padding:14px 18px; margin:14px 0;
}
.return-box h4 { font-size:11.5px; color:#34d399; margin-bottom:8px; text-transform:uppercase; letter-spacing:.8px; }
.terminal {
background:#0a0f1c; border:1px solid var(--border); border-radius:10px;
overflow:hidden; margin:12px 0 20px;
}
.terminal-bar { display:flex; align-items:center; gap:7px; padding:9px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid var(--border); }
.terminal-bar .t-dot { width:10px; height:10px; border-radius:50%; }
.terminal-bar .t-dot:nth-child(1){ background:#ff5f57; } .terminal-bar .t-dot:nth-child(2){ background:#febc2e; } .terminal-bar .t-dot:nth-child(3){ background:#28c840; }
.terminal-bar .t-title { margin-left:10px; font-size:11px; color:var(--muted); }
.terminal pre { padding:16px 18px; overflow-x:auto; font:12.5px/1.75 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace; color:#cbd5e1; }
.terminal pre .kw { color:#c084fc; } .terminal pre .fn { color:#60a5fa; } .terminal pre .s { color:#34d399; } .terminal pre .cm { color:#475569; }
@media (max-width:860px) {
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 .section, .sidebar .back { padding:4px 8px; }
.sidebar a.snav { display:inline-block; padding:4px 10px; border-left:none; font-size:12.5px; }
.sidebar a.snav.active { border-left:none; border-radius:6px; }
.main { padding:26px 20px; }
}
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-logo"><span class="grad-text">MetonaToast 文档</span></div>
<a class="back" href="index.html">← 返回首页</a>
<a class="back" href="demo.html">🎮 在线演示</a>
<div class="section">基础通知</div>
<a class="snav" href="#show">show()</a>
<a class="snav" href="#success">success()</a>
<a class="snav" href="#error">error()</a>
<a class="snav" href="#warning">warning()</a>
<a class="snav" href="#info">info()</a>
<a class="snav" href="#loading">loading()</a>
<div class="section">高级功能</div>
<a class="snav" href="#promise">promise()</a>
<a class="snav" href="#confirm">confirm()</a>
<a class="snav" href="#prompt">prompt()</a>
<a class="snav" href="#progress">progress()</a>
<a class="snav" href="#countdown">countdown()</a>
<a class="snav" href="#queue">queue()</a>
<a class="snav" href="#stack">stack()</a>
<a class="snav" href="#action">action()</a>
<div class="section">管理方法</div>
<a class="snav" href="#group">group()</a>
<a class="snav" href="#dismiss">dismiss()</a>
<a class="snav" href="#clear">clear()</a>
<a class="snav" href="#updatePosition">updatePosition()</a>
<a class="snav" href="#remove">remove() / removeToast()</a>
<a class="snav" href="#configure">configure()</a>
<a class="snav" href="#use">use()</a>
<a class="snav" href="#destroy">destroy()</a>
<div class="section">扩展</div>
<a class="snav" href="#hooks">钩子系统</a>
<a class="snav" href="#react">React 适配器</a>
<a class="snav" href="#config">全部配置项</a>
<a class="snav" href="#callbacks">回调函数</a>
<a class="snav" href="#animations">动画列表</a>
<a class="snav" href="#themes">主题参考</a>
</nav>
<main class="main">
<h1>API 文档</h1>
<p class="h1-desc">MetonaToast v0.5.0 完整 API 参考。所有基础通知方法支持字符串与对象两种调用形式,均可传入任何<a href="#config">配置项</a>作为可选参数。</p>
<!-- ===== 基础通知 ===== -->
<h2 id="show">show(message, opts?)</h2>
<p>显示默认类型 Toast,无特定颜色与图标。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">show.js</span></div>
<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>: <span class="num">2000</span>, <span class="fn">position</span>: <span class="s">'bottom-center'</span> });
<span class="cm">// 对象形式</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>: <span class="num">3000</span> });</pre></div>
<h2 id="success">success(message, opts?)</h2>
<p>绿色对勾图标 <code>#10b981</code>type = success。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">success.js</span></div>
<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">'Met 与 MeToast 等价'</span>);</pre></div>
<h2 id="error">error(message, opts?)</h2>
<p>红色叉号图标 <code>#ef4444</code>type = error。aria-live = assertive。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">error.js</span></div>
<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></div>
<h2 id="warning">warning(message, opts?)</h2>
<p>黄色三角图标 <code>#f59e0b</code>type = warning。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">warning.js</span></div>
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre></div>
<h2 id="info">info(message, opts?)</h2>
<p>蓝色圆形图标 <code>#3b82f6</code>type = info。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">info.js</span></div>
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre></div>
<h2 id="loading">loading(message, opts?)</h2>
<p>type = loadingduration 强制 0(不自动关闭)。返回 LoadingControl。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">loading.js</span></div>
<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>), <span class="num">2000</span>);</pre></div>
<div class="return-box">
<h4>LoadingControl(链式转换 id 稳定)</h4>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td>success/error/info/warning</td><td>原地更新为对应类型(同一实例,id 不变),duration 自动恢复默认值</td></tr>
<tr><td>update(partial)</td><td>原地更新内容,不改变类型</td></tr>
<tr><td>dismiss()</td><td>直接关闭,不转换</td></tr>
</table>
</div>
<!-- ===== 高级功能 ===== -->
<h2 id="promise">promise(promise, opts)</h2>
<p>监听 Promise 生命周期,自动 loading → resolve/reject 切换 success/error,返回原 Promise。</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 / success / error</td><td>string</td><td>加载中... / 操作成功 / 操作失败</td><td>各阶段文本</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">promise.js</span></div>
<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> (e) { <span class="cm">/* reject 会继续抛出 */</span> }</pre></div>
<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></tr>
<tr><td>confirmText / confirmColor</td><td>确认 / #10b981</td><td>确认按钮文字与颜色</td></tr>
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮文字与颜色</td></tr>
<tr><td>type</td><td>warning</td><td>Toast 类型</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">confirm.js</span></div>
<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="kw">if</span> (ok) MeToast.<span class="fn">success</span>(<span class="s">'已删除'</span>);</pre></div>
<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></tr>
<tr><td>placeholder / defaultValue</td><td>""</td><td>输入框占位与默认值</td></tr>
<tr><td>inputType</td><td>text</td><td>input 的 typepassword/email/number 等)</td></tr>
<tr><td>submitText / submitColor</td><td>确认 / #3b82f6</td><td>提交按钮</td></tr>
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">prompt.js</span></div>
<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="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre></div>
<h2 id="progress">progress(message, opts?)</h2>
<p>进度条通知,不自动关闭。返回 ProgressControl。</p>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td>setProgress(percent)</td><td>设置进度 0~100,自动 clamp</td></tr>
<tr><td>complete(message?)</td><td>跳 100%300ms 后转 success1s 后自动关闭</td></tr>
<tr><td>error(message?)</td><td>转 error2s 后自动关闭</td></tr>
<tr><td>dismiss()</td><td>直接关闭</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">progress.js</span></div>
<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>(<span class="num">45</span>);
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>);</pre></div>
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
<p>倒计时 Toast<code>{seconds}</code> 占位符每秒自动替换。</p>
<table>
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
<tr><td>seconds</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
<tr><td>opts.onComplete</td><td></td><td>归零回调</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">countdown.js</span></div>
<pre>MeToast.<span class="fn">countdown</span>(<span class="s">'{seconds} 秒后执行'</span>, <span class="num">5</span>, { <span class="fn">onComplete</span>: () => MeToast.<span class="fn">success</span>(<span class="s">'已执行'</span>) });
<span class="cm">// 返回 { cancel(), pause(), resume() }</span></pre></div>
<h2 id="queue">queue(messages, opts?)</h2>
<p>顺序逐个显示消息队列,返回 thenable + cancel。支持 await。</p>
<table>
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
<tr><td>opts.delay</td><td>1000</td><td>每条关闭后到下一条的间隔(ms)</td></tr>
<tr><td>opts.duration</td><td>3000</td><td>每条显示时长,可被消息级 duration 覆盖</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">queue.js</span></div>
<pre><span class="kw">const</span> q = MeToast.<span class="fn">queue</span>([<span class="s">'步骤一'</span>, <span class="s">'步骤二'</span>, <span class="s">'步骤三'</span>], { <span class="fn">delay</span>: <span class="num">800</span>, <span class="fn">duration</span>: <span class="num">2000</span> });
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
<span class="kw">await</span> q; <span class="cm">// 等待完成</span></pre></div>
<h2 id="stack">stack(messages, opts?)</h2>
<p>同时错峰显示多条消息。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">stack.js</span></div>
<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>: <span class="num">150</span> });</pre></div>
<h2 id="action">action(message, actions, opts?)</h2>
<p>内嵌操作按钮。默认 duration=0 不自动关闭。点击按钮不会误触 closeOnClick(已隔离冒泡)。</p>
<table>
<tr><th>ActionButton</th><th>默认</th><th>说明</th></tr>
<tr><td>text</td><td>—(必填)</td><td>按钮文字</td></tr>
<tr><td>onClick</td><td>—(必填)</td><td>点击回调,参数为当前 toast</td></tr>
<tr><td>color / style</td><td>#6366f1</td><td>背景色 / 内联样式(style.background 优先)</td></tr>
<tr><td>close</td><td>true</td><td>点击后是否关闭。false 可多次点击</td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">action.js</span></div>
<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>: () => <span class="fn">open</span>(), <span class="fn">color</span>: <span class="s">'#10b981'</span>, <span class="fn">close</span>: <span class="kw">false</span> },
]);</pre></div>
<!-- ===== 管理方法 ===== -->
<h2 id="group">group(name)</h2>
<p>创建分组,返回 GroupAPI。所有方法自动注入 <code>group: name</code></p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">group.js</span></div>
<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">count</span>(); <span class="cm">// 该组数量</span>
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭整组</span>
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre></div>
<h2 id="dismiss">dismiss(id?)</h2>
<p>关闭 Toast。无参数关闭全部,传入 id 关闭指定。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">dismiss.js</span></div>
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 全部</span>
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 指定</span></pre></div>
<h2 id="clear">clear(position?)</h2>
<p>按位置清除。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">clear.js</span></div>
<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></div>
<h2 id="updatePosition">updatePosition(position) <span class="badge badge-v5">v0.3</span></h2>
<p>运行时将 Toast 移动到新位置容器,立即生效。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">position.js</span></div>
<pre><span class="kw">const</span> t = MeToast.<span class="fn">info</span>(<span class="s">'可移动的 Toast'</span>);
t.<span class="fn">updatePosition</span>(<span class="s">'bottom-left'</span>);</pre></div>
<h2 id="remove">remove() / removeToast(id) <span class="badge badge-new">立即移除</span></h2>
<p>立即从 DOM 和内存移除,<b>不触发离场动画</b>。适用于无动画快速清除。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">remove.js</span></div>
<pre>t.<span class="fn">remove</span>(); <span class="cm">// 实例方法</span>
MeToast.<span class="fn">removeToast</span>(t.id); <span class="cm">// 按 id(等价)</span></pre></div>
<h2 id="configure">configure(opts)</h2>
<p>全局配置,影响后续所有 Toast。theme/locale 变化触发副作用。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">configure.js</span></div>
<pre>MeToast.<span class="fn">configure</span>({
<span class="fn">position</span>: <span class="s">'top-right'</span>, <span class="fn">duration</span>: <span class="num">4000</span>, <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></div>
<h2 id="use">use(plugin)</h2>
<p>安装插件:字符串预设名或插件对象。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">use.js</span></div>
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有</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">// 屏幕阅读器朗读</span>
MeToast.<span class="fn">use</span>(<span class="s">'dedupe'</span>); <span class="cm">// 相同 type+message 自动去重</span></pre></div>
<p class="hint">dedupe:检测已有相同 toast,更新它并阻止重复弹出(beforeShow 钩子实现,支持 uninstall 卸载钩子)。</p>
<h2 id="destroy">destroy()</h2>
<p>完全销毁:关闭所有 Toast、移除全部 DOM 容器与注入样式、清空内存缓存与钩子。支持重复调用,<code>init()</code> 可恢复。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">destroy.js</span></div>
<pre>MeToast.<span class="fn">destroy</span>();
MeToast.<span class="fn">init</span>({ <span class="fn">config</span>: { <span class="fn">duration</span>: <span class="num">3000</span> } }); <span class="cm">// 恢复</span></pre></div>
<!-- ===== 钩子系统 ===== -->
<h2 id="hooks">钩子系统 <span class="badge badge-v5">v0.3</span></h2>
<p><code>Toast.on(name, handler)</code> 注册钩子并返回取消函数。<code>beforeShow</code> / <code>beforeClose</code> / <code>beforeUpdate</code> 的 handler 返回 <code>false</code> 可拦截对应操作。</p>
<table>
<tr><th>钩子</th><th>触发时机</th><th>拦截</th></tr>
<tr><td>beforeInit / afterInit</td><td>init() 前后</td><td></td></tr>
<tr><td>beforeDestroy / afterDestroy</td><td>destroy() 前后</td><td></td></tr>
<tr><td>beforeShow / afterShow</td><td>显示前后</td><td>✅ 返回 false 阻止</td></tr>
<tr><td>beforeClose / afterClose</td><td>关闭前后</td><td>✅ 返回 false 阻止</td></tr>
<tr><td>beforeUpdate / afterUpdate</td><td>update() 前后</td><td>✅ 返回 false 阻止</td></tr>
<tr><td>configChange</td><td>configure / updateConfig / resetConfig</td><td></td></tr>
<tr><td>themeChange / localeChange</td><td>主题 / 语言切换</td><td></td></tr>
<tr><td>click / hover</td><td>点击 / 悬停进出</td><td></td></tr>
<tr><td>dragStart / dragEnd</td><td>拖拽开始 / 结束</td><td></td></tr>
<tr><td>animationStart / animationEnd</td><td>入场动画开始 / 结束</td><td></td></tr>
<tr><td>progressStart / progressEnd</td><td>倒计时开始 / 归零</td><td></td></tr>
</table>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">hooks.js</span></div>
<pre><span class="kw">import</span> MeToast, { <span class="fn">Toast</span> } <span class="kw">from</span> <span class="s">'@metona-team/metona-toast'</span>;
<span class="cm">// 拦截:非允许时段禁止错误提示</span>
<span class="kw">const</span> off = <span class="fn">Toast</span>.<span class="fn">on</span>(<span class="s">'beforeShow'</span>, (toast) =&gt; {
<span class="kw">if</span> (toast.type === <span class="s">'error'</span> &amp;&amp; !isAllowed) <span class="kw">return</span> <span class="kw">false</span>;
});
<span class="cm">// ...</span>
off(); <span class="cm">// 取消注册</span></pre></div>
<!-- ===== React ===== -->
<h2 id="react">React 适配器 <span class="badge badge-v5">v0.4</span></h2>
<p>主包保持零依赖。通过子路径 <code>@metona-team/metona-toast/react</code> 导入,<code>react</code> 为 optional peerDependency。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">react.tsx</span></div>
<pre><span class="kw">import</span> { <span class="fn">useToast</span>, <span class="fn">Toast</span> } <span class="kw">from</span> <span class="s">'@metona-team/metona-toast/react'</span>;
<span class="kw">function</span> <span class="fn">SubmitButton</span>() {
<span class="kw">const</span> toast = <span class="fn">useToast</span>(); <span class="cm">// 组件卸载自动清理本组件创建的 Toast</span>
<span class="kw">const</span> submit = <span class="kw">async</span> () =&gt; {
<span class="kw">const</span> loading = toast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
<span class="kw">try</span> { <span class="kw">await</span> api(); loading.<span class="fn">success</span>(<span class="s">'完成!'</span>); }
<span class="kw">catch</span> (e) { loading.<span class="fn">error</span>(<span class="s">'失败'</span>); }
};
<span class="kw">return</span> &lt;button onClick={submit}&gt;提交&lt;/button&gt;;
}
<span class="cm">// 声明式 Toast:props 变化更新,卸载自动移除(autoClose 默认 true</span>
<span class="kw">function</span> <span class="fn">SaveIndicator</span>({ saving }) {
<span class="kw">return</span> saving ? &lt;<span class="fn">Toast</span> type=<span class="s">"info"</span> message=<span class="s">"正在保存..."</span> /&gt; : null;
}</pre></div>
<!-- ===== 配置参考 ===== -->
<h2 id="config">全部配置项</h2>
<p>可用于 <code>configure()</code><code>init()</code> 或单个 Toast 方法的 opts。</p>
<table>
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
<tr><td>position</td><td>string</td><td>'top-right'</td><td>6 个位置之一(RTL 自动翻转)</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>悬停暂停倒计时</td></tr>
<tr><td>closeOnClick</td><td>boolean</td><td>true</td><td>点击关闭</td></tr>
<tr><td>draggable</td><td>boolean</td><td>true</td><td>允许拖拽关闭</td></tr>
<tr><td>dragThreshold</td><td>number</td><td>120</td><td>拖拽关闭阈值(px)</td></tr>
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示倒计时进度条</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>显示类型图标</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>11 种内置或自定义动画名</td></tr>
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 z-index</td></tr>
<tr><td>width</td><td>number|string</td><td>360</td><td>宽度,数字表示 px</td></tr>
<tr><td>className</td><td>string</td><td>''</td><td>附加 CSS 类名</td></tr>
<tr><td>style</td><td>object</td><td>{}</td><td>附加内联样式</td></tr>
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码</td></tr>
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>update() 时重置倒计时</td></tr>
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时发系统通知</td></tr>
<tr><td>render</td><td>function</td><td></td><td>自定义渲染函数,完全接管 DOM</td></tr>
<tr><td>onBeforeShow</td><td>function</td><td></td><td>返回 false 阻止显示</td></tr>
<tr><td>onError</td><td>function</td><td></td><td>钩子/定时器异常全局回调</td></tr>
</table>
<h2 id="callbacks">回调函数</h2>
<table>
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
<tr><td>onBeforeShow</td><td>(toast) =&gt; boolean | void</td><td>DOM 创建前,返回 false 阻止显示</td></tr>
<tr><td>onShow</td><td>(toast) =&gt; void</td><td>创建并播放入场动画后</td></tr>
<tr><td>onClose</td><td>(toast) =&gt; void</td><td>DOM 移除后(离场完成)</td></tr>
<tr><td>onClick</td><td>(toast) =&gt; void</td><td>点击时(closeOnClick=true 时还会关闭)</td></tr>
<tr><td>onUpdate</td><td>(toast) =&gt; void</td><td>update() 后</td></tr>
<tr><td>onError</td><td>({ hook, source, error, toast }) =&gt; void</td><td>钩子/定时器异常时</td></tr>
</table>
<h2 id="animations">动画列表</h2>
<p>未注册的动画名自动 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 / slideDown</td><td>从下方 / 上方弹入</td><td>400ms</td></tr>
<tr><td>slideLeft / slideRight</td><td>从左侧 / 右侧滑入</td><td>400ms</td></tr>
</table>
<h2 id="themes">主题参考</h2>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">theme.js</span></div>
<pre><span class="cm">// 内置主题:light · dark · auto(跟随系统)· warm</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></div>
</main>
<script src="../dist/metona-toast.js"></script>
<script>
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
// 侧边栏滚动高亮
const links = document.querySelectorAll('.sidebar a.snav');
const sections = [];
links.forEach(l => {
const id = l.getAttribute('href').slice(1);
const el = document.getElementById(id);
if (el) sections.push({ id, el, link: l });
});
const spy = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const hit = sections.find(s => s.el === e.target);
if (hit) hit.link.classList.add('active');
}
});
}, { rootMargin: '-15% 0px -70% 0px' });
sections.forEach(s => spy.observe(s.el));
// 点击关闭高亮
links.forEach(l => l.addEventListener('click', () => {
links.forEach(x => x.classList.remove('active'));
l.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): 高级功能 — 锦上添花"
]
}
}
-247
View File
@@ -1,247 +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": "CodeMirror Compartment.reconfigure()",
"implementation": "src/renderer/components/Editor/useCodeMirror.ts",
"description": "创建 themeCompartmentdarkMode 变化时通过 reconfigure() 交换主题扩展,不重建 EditorView",
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置"
},
"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 审查流程"
]
}
}
-589
View File
@@ -1,589 +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": "CodeMirror 6",
"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 内联样式"
}
]
}
}
+6 -23
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.3.12", "version": "0.6.1",
"description": "Lightweight Markdown Editor for Windows", "description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"scripts": { "scripts": {
@@ -28,31 +28,13 @@
"author": "MarkLite", "author": "MarkLite",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@milkdown/core": "7.21.1", "@metona-team/metona-editor": "0.4.1",
"@milkdown/ctx": "7.21.1", "@metona-team/metona-sqlark": "0.4.4",
"@milkdown/plugin-clipboard": "7.21.1", "@metona-team/metona-toast": "^0.5.0",
"@milkdown/plugin-history": "7.21.1", "mermaid": "^10.9.6",
"@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",
"dexie": "^4.0.11",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
@@ -69,6 +51,7 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"fake-indexeddb": "^6.2.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lint-staged": "^17.0.7", "lint-staged": "^17.0.7",
+15 -8
View File
@@ -10,26 +10,29 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
try { try {
const fileStat = await stat(filePath) const fileStat = await stat(filePath)
if (fileStat.size > MAX_FILE_SIZE) { if (fileStat.size > MAX_FILE_SIZE) {
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } return {
success: false,
error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件`,
}
} }
// L-01: 检测并剥离 BOMUTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF // L-01: 检测并剥离 BOMUTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF
const buffer = await readFile(filePath) const buffer = await readFile(filePath)
let content: string let content: string
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) { if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
// UTF-16 BE: swap bytes to LE 再解码 // UTF-16 BE: swap bytes to LE 再解码
const swapped = Buffer.allocUnsafe(buffer.length) const swapped = Buffer.allocUnsafe(buffer.length)
buffer.copy(swapped) buffer.copy(swapped)
swapped.swap16() swapped.swap16()
content = swapped.toString('utf-16le') content = swapped.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) { } else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
// UTF-16 LE // UTF-16 LE
content = buffer.toString('utf-16le') content = buffer.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else { } else {
// UTF-8 (含 FEFF BOM 剥离) // UTF-8 (含 FEFF BOM 剥离)
content = buffer.toString('utf-8') content = buffer.toString('utf-8')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} }
return { success: true, content } return { success: true, content }
} catch (err) { } catch (err) {
@@ -48,7 +51,11 @@ export async function saveFileContent(filePath: string, content: string): Promis
return { success: true, filePath } return { success: true, filePath }
} catch (err) { } catch (err) {
// 清理残留临时文件 // 清理残留临时文件
try { await unlink(tmpFile) } catch { /* ignore */ } try {
await unlink(tmpFile)
} catch {
/* ignore */
}
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
} }
@@ -58,7 +65,7 @@ export async function buildDirTree(
dirPath: string, dirPath: string,
depth = 0, depth = 0,
maxDepth = 10, maxDepth = 10,
visited?: Set<string> visited?: Set<string>,
): Promise<FileNode[]> { ): Promise<FileNode[]> {
if (depth > maxDepth) return [] if (depth > maxDepth) return []
+1 -1
View File
@@ -15,7 +15,7 @@ export class FileWatcher {
if (!filePath) return if (!filePath) return
try { try {
this.currentPath = filePath this.currentPath = filePath
this.watcher = fs.watch(filePath, (eventType) => { this.watcher = fs.watch(filePath, eventType => {
if (eventType === 'change') { if (eventType === 'change') {
if (this.isSelfWriting) return if (this.isSelfWriting) return
const win = this.getMainWindow() const win = this.getMainWindow()
+7 -6
View File
@@ -10,7 +10,7 @@ const state = {
activeFilePath: null as string | null, activeFilePath: null as string | null,
pendingFilePath: null as string | null, pendingFilePath: null as string | null,
isClosing: false, isClosing: false,
closeTimeout: null as NodeJS.Timeout | null closeTimeout: null as NodeJS.Timeout | null,
} }
const fileWatcher = new FileWatcher(() => mainWindow) const fileWatcher = new FileWatcher(() => mainWindow)
@@ -18,20 +18,22 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void { function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => { readFileContent(filePath)
.then(result => {
if (result.success && mainWindow && !mainWindow.isDestroyed()) { if (result.success && mainWindow && !mainWindow.isDestroyed()) {
state.activeFilePath = filePath state.activeFilePath = filePath
fileWatcher.start(filePath) fileWatcher.start(filePath)
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
} }
}).catch((err) => { })
.catch(err => {
// eslint-disable-next-line no-console -- IPC file open error // eslint-disable-next-line no-console -- IPC file open error
console.error('openFileInTab failed:', err) console.error('openFileInTab failed:', err)
}) })
} }
const lockOk = setupSingleInstanceLock((filePath) => { const lockOk = setupSingleInstanceLock(filePath => {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus() mainWindow.focus()
@@ -42,10 +44,9 @@ const lockOk = setupSingleInstanceLock((filePath) => {
if (!lockOk) { if (!lockOk) {
app.quit() app.quit()
} else { } else {
function setupCloseHandler(): void { function setupCloseHandler(): void {
if (!mainWindow) return if (!mainWindow) return
mainWindow.on('close', (e) => { mainWindow.on('close', e => {
if (state.isClosing) return if (state.isClosing) return
state.isClosing = true state.isClosing = true
e.preventDefault() e.preventDefault()
+69 -13
View File
@@ -2,7 +2,7 @@ import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electro
import { readFileContent, saveFileContent, buildDirTree } from './file-system' import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher' import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises' import { stat, readFile, writeFile } from 'fs/promises'
import { basename, isAbsolute } from 'path' import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击 // 安全校验:拒绝路径遍历攻击
@@ -24,7 +24,12 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null, getMainWindow: () => BrowserWindow | null,
fileWatcher: FileWatcher, fileWatcher: FileWatcher,
sidebarWatcher: SidebarWatcher, sidebarWatcher: SidebarWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } state: {
activeFilePath: string | null
pendingFilePath: string | null
isClosing: boolean
closeTimeout: NodeJS.Timeout | null
},
): void { ): void {
// 打开文件对话框 // 打开文件对话框
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
@@ -33,7 +38,7 @@ export function registerIpcHandlers(
try { try {
const result = await dialog.showOpenDialog(win, { const result = await dialog.showOpenDialog(win, {
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
}) })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0] const filePath = result.filePaths[0]
@@ -61,7 +66,9 @@ export function registerIpcHandlers(
}) })
// 保存文件 // 保存文件
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { ipcMain.handle(
IPC_CHANNELS.FILE_SAVE,
async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
if (data.filePath && !validatePath(data.filePath)) { if (data.filePath && !validatePath(data.filePath)) {
return { success: false, error: '无效的文件路径' } return { success: false, error: '无效的文件路径' }
} }
@@ -85,7 +92,7 @@ export function registerIpcHandlers(
} else { } else {
if (!win) return { success: false, error: '窗口不可用' } if (!win) return { success: false, error: '窗口不可用' }
const saveResult = await dialog.showSaveDialog(win, { const saveResult = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
}) })
if (!saveResult.canceled) { if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
@@ -107,15 +114,18 @@ export function registerIpcHandlers(
} }
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 另存为 // 另存为
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { ipcMain.handle(
IPC_CHANNELS.FILE_SAVE_AS,
async (_event: IpcMainInvokeEvent, data: { content: string }) => {
const win = getMainWindow() const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' } if (!win) return { success: false, error: '窗口不可用' }
try { try {
const result = await dialog.showSaveDialog(win, { const result = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
}) })
if (!result.canceled) { if (!result.canceled) {
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
@@ -133,7 +143,8 @@ export function registerIpcHandlers(
fileWatcher.setSelfWriting(false) fileWatcher.setSelfWriting(false)
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 获取当前路径 // 获取当前路径
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
@@ -159,7 +170,9 @@ export function registerIpcHandlers(
}) })
// 目录树 // 目录树
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { ipcMain.handle(
IPC_CHANNELS.DIR_READ_TREE,
async (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) { if (!validatePath(dirPath)) {
return { success: false, error: '无效的目录路径' } return { success: false, error: '无效的目录路径' }
} }
@@ -169,7 +182,8 @@ export function registerIpcHandlers(
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 打开文件夹对话框 // 打开文件夹对话框
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
@@ -193,7 +207,9 @@ export function registerIpcHandlers(
}) })
// 标签切换 // 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { ipcMain.handle(
IPC_CHANNELS.TAB_SWITCHED,
(_event: IpcMainInvokeEvent, filePath: string | null) => {
const normalizedPath = filePath || null const normalizedPath = filePath || null
if (normalizedPath && !validatePath(normalizedPath)) { if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop() fileWatcher.stop()
@@ -205,7 +221,8 @@ export function registerIpcHandlers(
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite') win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
} }
}) },
)
// 窗口控制 // 窗口控制
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
@@ -225,4 +242,43 @@ export function registerIpcHandlers(
state.closeTimeout = null state.closeTimeout = null
} }
}) })
// v0.6.0: 数据备份导出 — 保存对话框 + 写 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_EXPORT, async (_event: IpcMainInvokeEvent, content: string) => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const dateStr = new Date().toISOString().slice(0, 10)
const result = await dialog.showSaveDialog(win, {
title: '导出数据备份',
defaultPath: `marklite-backup-${dateStr}.json`,
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled) return { success: false, canceled: true }
await writeFile(result.filePath, content, 'utf8')
return { success: true, filePath: result.filePath }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// v0.6.0: 数据备份导入 — 打开对话框 + 读 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_IMPORT, async () => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const result = await dialog.showOpenDialog(win, {
title: '导入数据备份',
properties: ['openFile'],
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
const content = await readFile(result.filePaths[0], 'utf8')
return { success: true, content }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
} }
+4 -4
View File
@@ -13,16 +13,16 @@ export function createWindow(): BrowserWindow {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true sandbox: true,
}, },
titleBarStyle: 'default', titleBarStyle: 'default',
show: false show: false,
}) })
mainWindow.setMenu(null) mainWindow.setMenu(null)
// H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP // H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP
mainWindow.webContents.on('will-navigate', (event) => { mainWindow.webContents.on('will-navigate', event => {
event.preventDefault() event.preventDefault()
}) })
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
@@ -35,7 +35,7 @@ export function createWindow(): BrowserWindow {
} }
export function setupSingleInstanceLock( export function setupSingleInstanceLock(
onSecondInstance: (filePath: string | null) => void onSecondInstance: (filePath: string | null) => void,
): boolean { ): boolean {
const gotTheLock = app.requestSingleInstanceLock() const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) { if (!gotTheLock) {
+26 -11
View File
@@ -7,8 +7,8 @@ const api: ElectronAPI = {
// File operations // File operations
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), saveFile: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), saveFileAs: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
@@ -38,27 +38,42 @@ const api: ElectronAPI = {
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath), watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// v0.6.0: 数据备份导出/导入
exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
importData: () => ipcRenderer.invoke(IPC_CHANNELS.DATA_IMPORT),
// Events from main process — 返回取消订阅函数 // Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback) => { onFileOpenInTab: callback => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) const handler = (
_event: Electron.IpcRendererEvent,
data: { filePath: string; content: string },
) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } return () => {
ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
}
}, },
onExternalModification: (callback) => { onExternalModification: callback => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } return () => {
ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
}
}, },
onDirChanged: (callback) => { onDirChanged: callback => {
const handler = () => callback() const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } return () => {
ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
}
}, },
onConfirmClose: (callback) => { onConfirmClose: callback => {
const handler = () => callback() const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } return () => {
ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
} }
},
} }
contextBridge.exposeInMainWorld('electronAPI', api) contextBridge.exposeInMainWorld('electronAPI', api)
+41 -70
View File
@@ -3,7 +3,6 @@ import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore' import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore' import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useSettingsInit } from './hooks/useSettingsInit' import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard' import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning' import { useUnsavedWarning } from './hooks/useUnsavedWarning'
@@ -12,45 +11,17 @@ import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop' import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners' import { useIpcListeners } from './hooks/useIpcListeners'
import { useToast } from './hooks/useToast' import { MeToast } from './lib/toast'
import { useConfirm } from './hooks/useConfirm' import { closeDatabase } from './db/schema'
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 { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar' import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor' import { Editor } from './components/Editor/Editor'
import { SourceEditor } from './components/SourceEditor/SourceEditor'
import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar' import { Sidebar } from './components/Sidebar/Sidebar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ToastContainer } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay' import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog' import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
/** 状态栏 — 自动保存开关组件 */
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() { export function App() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
@@ -62,46 +33,41 @@ export function App() {
const viewMode = useEditorStore(s => s.viewMode) const viewMode = useEditorStore(s => s.viewMode)
const externallyModified = useEditorStore(s => s.externallyModified) const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified) const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { darkMode, toggleDarkMode } = useTheme() const { themeMode, cycleTheme } = useTheme()
const { saveViewMode } = useSettings()
const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false) const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), []) const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit() useSettingsInit()
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup]) useEffect(() => {
loadFromDB()
}, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
useDragDrop(showToast) const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useDragDrop()
useFileWatch() useFileWatch()
useAutoSave() // useAutoSave() already called above to get state for toolbar
// UX-01: 传入 confirm 函数替代原生 confirm() // UX-01: 传入 confirm 函数替代原生 confirm()
// v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false)
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => { const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return confirm({ return MeToast.confirm(message, {
title: '未保存的更改', title: '未保存的更改',
message, confirmText: '不保存',
variant: 'warning', cancelText: '取消',
confirmLabel: '不保存',
cancelLabel: '取消'
}) })
}, [confirm]) }, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) // v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
const handleBeforeForceClose = useCallback(async () => {
await flushSaveToDB()
await closeDatabase()
}, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs) useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners() useIpcListeners()
// ── 状态栏扩展点架构 ───────────────────────────────────
useDefaultStatusBarItems()
useStatusBarItem({
id: 'statusbar.auto-save',
alignment: 'right',
priority: 200,
Component: AutoSaveStatusItem
})
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
const activeTab = tabs.find(t => t.id === activeTabId) const activeTab = tabs.find(t => t.id === activeTabId)
@@ -111,9 +77,6 @@ export function App() {
const handleReloadModified = useCallback(async () => { const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return if (!externallyModified?.filePath || !window.electronAPI) return
const tab = tabs.find(t => t.filePath === externallyModified.filePath) 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?.isModified) return
if (!tab) return if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath) const result = await window.electronAPI.readFile(externallyModified.filePath)
@@ -128,34 +91,42 @@ export function App() {
<ErrorBoundary> <ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}> <div id="app" className={`mode-${viewMode}`}>
<Toolbar <Toolbar
onOpen={handleOpenFile} onSave={handleSave} onOpen={handleOpenFile}
viewMode={viewMode} onViewModeChange={saveViewMode} onSave={handleSave}
darkMode={darkMode} onToggleDark={toggleDarkMode} themeMode={themeMode}
onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)} onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/> />
<div id="workspace"> <div id="workspace">
<Sidebar /> <Sidebar />
<div id="main-content"> <div id="main-content">
<TabBar /> <TabBar />
{externallyModified && ( {externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} /> <ModifiedBanner
onReload={handleReloadModified}
onDismiss={() => setExternallyModified(null)}
/>
)} )}
{tabs.length > 0 ? ( {tabs.length > 0 ? (
<div id="content-wrapper"> <div id="content-wrapper">
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>} <div id="editor-panel">
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>} <Editor themeMode={themeMode} onAppSave={handleSave} />
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>} </div>
</div> </div>
) : ( ) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} /> <WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/>
)} )}
</div> </div>
</div> </div>
<StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay /> <DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />} {showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
) )
+14
View File
@@ -0,0 +1,14 @@
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare module '*.ico' {
const src: string
export default src
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

@@ -10,14 +10,20 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => { const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault() e.preventDefault()
if (window.electronAPI?.openExternal) { if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite') window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
} else { } else {
window.open('https://gitee.com/thzxx/MarkLite', '_blank') window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank')
} }
} }
return ( return (
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite"> <div
className="about-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="关于 MarkLite"
>
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}> <div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="about-header"> <div className="about-header">
<AppIcon size={64} /> <AppIcon size={64} />
@@ -35,18 +41,23 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span></span>
<span></span>
<span></span> <span></span>
</div> </div>
</div> </div>
<div className="about-footer"> <div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}> <a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} /> <Gitee size={16} />
<span>gitee.com/thzxx/MarkLite</span> <span>git.metona.cn/MetonaTeam/MarkLite</span>
</a> </a>
<p> Electron + React + TypeScript </p> <p> Electron + React + TypeScript </p>
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.4.4</p>
<p className="about-copyright">© 2026 thzxx</p> <p className="about-copyright">© 2026 thzxx</p>
</div> </div>
<button className="about-close-btn" onClick={onClose}></button> <button className="about-close-btn" onClick={onClose}>
</button>
</div> </div>
</div> </div>
) )
@@ -1,111 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog = React.memo(function ConfirmDialog({
open,
title,
message,
confirmLabel = '确定',
cancelLabel = '取消',
variant = 'warning',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => {
if (!open) {
// Only restore focus if the element is still in the DOM
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
previousFocusRef.current.focus()
}
return
}
previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => {
clearTimeout(timer)
}
}, [open])
// ESC 键关闭
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}, [onCancel])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
// 防止背景滚动
useEffect(() => {
if (!open) return
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = original }
}, [open])
if (!open) return null
return (
<div
className="confirm-overlay"
onClick={onCancel}
role="presentation"
>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
onClick={e => e.stopPropagation()}
>
<div className={`confirm-header confirm-${variant}`}>
<h3 id="confirm-title">{title}</h3>
</div>
<div className="confirm-body">
<p id="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn confirm-btn-cancel"
onClick={onCancel}
type="button"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
className={`confirm-btn confirm-btn-${variant}`}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
})
ConfirmDialog.displayName = 'ConfirmDialog'
@@ -1 +0,0 @@
export { ConfirmDialog } from './ConfirmDialog'
+251 -96
View File
@@ -1,127 +1,282 @@
import React, { useEffect, useCallback, useState, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import { callCommand } from '@milkdown/utils' import MeEditor from '@metona-team/metona-editor'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark' import type { MarkdownEditor } from '@metona-team/metona-editor'
import mermaid from 'mermaid'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown' import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { EditorToolbar } from './EditorToolbar' import { settingsRepository } from '../../db/settingsRepository'
import { SearchReplace } from '../SearchReplace' import { renderMarkdownSync } from '../../lib/markdown'
import { setEditorViewGetter } from '../../stores/editorStore' import type { ThemeMode } from '../../types/settings'
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
type EditMode = 'edit' | 'split' | 'preview'
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
// v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps { interface EditorProps {
darkMode: boolean themeMode: ThemeMode
onAppSave?: () => void
} }
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { /** 将应用 viewMode 映射到 MetonaEditor 的 mode */
const activeTab = useTabStore(s => s.getActiveTab()) 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
'shortcutHelp', // 按 ? 弹出快捷键面板
]
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
const updateTabContent = useTabStore(s => s.updateTabContent) const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified) const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll) const updateTabScroll = useTabStore(s => s.updateTabScroll)
const [showSearch, setShowSearch] = useState(false) const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const setZenMode = useEditorStore(s => s.setZenMode)
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null)
const currentContentRef = useRef('') const currentContentRef = useRef('')
const { // 稳定的回调引用
containerRef, const activeTabIdRef = useRef(activeTabId)
action, activeTabIdRef.current = activeTabId
setContent,
getScrollTop, // ── 初始化 MetonaEditor ──────────────────────────────────
setScrollTop, useEffect(() => {
getSelection, const container = containerRef.current
setSelection, if (!container) return
getView
} = useMilkdown({ const config = {
content: activeTab?.content ?? '', value: activeTab?.content ?? '',
onChange: useCallback((value: string) => { mode: mapViewMode(viewMode),
if (!activeTabId) return height: '100%',
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated), toolbar: [
// 避免将文档误标记为已修改 'bold',
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId) '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,
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
floatingToolbar: true,
// v0.6.1: 专注模式宽度使用 0.4.1 官方 API — 100% 全宽(默认 960px 偏窄)
zenMaxWidth: '100%',
// v0.2.4 新增配置项
syncScroll: true,
wordWrap: true,
outline: false, // 使用自研 OutlinePanel
historyLimit: 100,
historyDebounce: 400,
// 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
// v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
highlight: MeEditor.highlight,
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 if (tab?.content === value) return
updateTabContent(activeTabId, value) currentContentRef.current = value
setModified(activeTabId, true) updateTabContent(tabId, value)
}, [activeTabId, updateTabContent, setModified]), setModified(tabId, true)
darkMode },
})
// Load content when switching tabs (only trigger on tab switch, not content edits) // 模式切换 → 同步到 editorStore 并持久化
useEffect(() => { onModeChange: (mode: string) => {
if (!activeTab) return const mapped = reverseMapMode(mode as EditMode)
if (activeTab.content !== currentContentRef.current) { setViewMode(mapped)
currentContentRef.current = activeTab.content settingsRepository.save({ viewMode: mapped })
// Queue setContent after Milkdown editor has finished async create() },
requestAnimationFrame(() => {
setContent(activeTab.content) // Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
requestAnimationFrame(() => { onSave: () => {
setScrollTop(activeTab.scrollTop) onAppSave?.()
setSelection() },
})
}) // v0.2.4 新增回调: 链接点击 → 安全打开外部链接
onLinkClick: (href: string) => {
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} }
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change },
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]) // v0.2.4 新增回调: 焦点事件(预留扩展点)
onFocus: () => {
// 编辑器获得焦点
},
onBlur: () => {
// 编辑器失去焦点
},
}
const editor = MeEditor.create(container, config)
editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
const renderMermaid = () => {
try {
mermaid.run({ querySelector: '.me-mermaid .mermaid' })
} catch {
/* 容错 */
}
}
editor.on('afterRender', renderMermaid)
// 首次渲染后延迟触发一次
setTimeout(renderMermaid, 300)
// v0.6.0: Zen 状态同步 → editorStoreToolbar 消费)
// 文档统计由编辑器自带底栏展示(wordCount: true),无需应用层同步
const syncZen = (zen: boolean) => {
setZenMode(Boolean(zen))
}
editor.on('zenChange', syncZen)
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => { return () => {
if (!activeTabId) return editor.destroy()
// Snapshot the current editor state before the new tab replaces content editorRef.current = null
const scrollPos = getScrollTop() setMetonaEditorGetter(() => null)
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
})
})
} }
// 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]) }, [activeTabId])
// Register getView for OutlinePanel navigation // ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => { useEffect(() => {
setEditorViewGetter(getView) const c = containerRef.current
return () => setEditorViewGetter(() => null) return () => {
}, [getView]) 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 })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace // ── 主题同步 ──────────────────────────────────────────
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { try {
const isCtrl = e.ctrlKey || e.metaKey // 全局主题(documentElement + localStorage
if (isCtrl && e.key === 'b') { MeEditor.setTheme(themeMode)
e.preventDefault() // v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
action((editor) => { editorRef.current?.setTheme(themeMode)
editor.action(callCommand(toggleStrongCommand.key)) } catch {
}) // 容错
} }
if (isCtrl && e.key === 'i') { }, [themeMode])
e.preventDefault()
action((editor) => { // ── 视图模式同步 ──────────────────────────────────────────
editor.action(callCommand(toggleEmphasisCommand.key)) useEffect(() => {
}) const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
} }
if (isCtrl && (e.key === 'f' || e.key === 'h')) { // 预览模式设为只读
e.preventDefault() editor.setReadOnly(viewMode === 'preview')
setShowSearch(true) }, [viewMode])
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
return ( return (
<div className="editor-container" role="region" aria-label="Markdown编辑器"> <div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} /> <div ref={containerRef} className="metona-editor-wrapper" />
{showSearch && (
<SearchReplace
getView={getView}
onClose={() => setShowSearch(false)}
/>
)}
<div ref={containerRef} className="milkdown-wrapper" />
</div> </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,327 +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(() => {
// ProseMirror selection setting requires the view instance
// which we access lazily; for now we focus the editor
const container = containerRef.current
if (!container) return
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
pmEditor?.focus()
}, [])
// Get ProseMirror EditorView instance
const getView = useCallback(() => {
const editor = editorRef.current
if (!editor) return null
try {
return editor.action((ctx) => ctx.get(editorViewCtx))
} catch {
return null
}
}, [])
// Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
if (!editor) return
fn(editor)
}, [])
return {
containerRef,
editorRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
getView
}
}
@@ -43,11 +43,7 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="error-boundary-root" role="alert"> <div className="error-boundary-root" role="alert">
<h2 className="error-boundary-title"></h2> <h2 className="error-boundary-title"></h2>
{isDev && ( {isDev && <pre className="error-boundary-detail">{this.state.error?.message}</pre>}
<pre className="error-boundary-detail">
{this.state.error?.message}
</pre>
)}
<button className="error-boundary-reset" onClick={this.handleReset}> <button className="error-boundary-reset" onClick={this.handleReset}>
</button> </button>
@@ -18,7 +18,7 @@ export const FileTree = React.memo(function FileTree({
expandedDirs, expandedDirs,
toggleDir, toggleDir,
activeFilePath, activeFilePath,
onFileClick onFileClick,
}: FileTreeProps) { }: FileTreeProps) {
return ( return (
<> <>
@@ -30,7 +30,7 @@ export const FileTree = React.memo(function FileTree({
<React.Fragment key={node.path}> <React.Fragment key={node.path}>
<div <div
className={`tree-item ${isActive ? 'active' : ''}`} className={`tree-item ${isActive ? 'active' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }} style={{ paddingLeft: 8 + depth * 16 + 'px' }}
role="treeitem" role="treeitem"
aria-expanded={node.type === 'dir' ? isExpanded : undefined} aria-expanded={node.type === 'dir' ? isExpanded : undefined}
aria-selected={isActive} aria-selected={isActive}
@@ -43,7 +43,7 @@ export const FileTree = React.memo(function FileTree({
onFileClick(node.path) onFileClick(node.path)
} }
}} }}
onKeyDown={(e) => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
if (node.type === 'dir') { if (node.type === 'dir') {
+227 -115
View File
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import appIconUrl from '../assets/icon.png'
// 统一图标 Props // 统一图标 Props
interface IconProps { interface IconProps {
@@ -9,106 +10,90 @@ interface IconProps {
const defaultProps: Partial<IconProps> = { size: 18 } const defaultProps: Partial<IconProps> = { size: 18 }
// ===== 应用图标(现代化简洁风格) ===== // ===== 应用图标 =====
export function AppIcon({ size = 80 }: IconProps) { export function AppIcon({ size = 80 }: IconProps) {
return ( return <img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
<svg width={size} height={size} viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="app-bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#4F8EF7"/>
<stop offset="100%" stopColor="#2563EB"/>
</linearGradient>
<linearGradient id="app-highlight" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="white" stopOpacity="0.15"/>
<stop offset="50%" stopColor="white" stopOpacity="0"/>
</linearGradient>
</defs>
{/* 圆角矩形背景 */}
<rect x="4" y="4" width="72" height="72" rx="16" fill="url(#app-bg)"/>
{/* 左上角高光 */}
<rect x="4" y="4" width="72" height="72" rx="16" fill="url(#app-highlight)"/>
{/* MD 文字 */}
<text x="40" y="46" textAnchor="middle" dominantBaseline="central"
fontFamily="Georgia, 'Times New Roman', serif" fontWeight="bold" fontSize="24"
fill="white" letterSpacing="1.5">MD</text>
{/* 底部装饰线 */}
<rect x="20" y="58" width="40" height="2.5" rx="1.25" fill="white" fillOpacity="0.25"/>
</svg>
)
} }
// ===== 工具栏图标 ===== // ===== 工具栏图标 =====
export function FolderOpen({ size = defaultProps.size }: IconProps) { export function FolderOpen({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/> width={size}
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/> height={size}
<path d="M12 11h4" strokeDasharray="2 2"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1" />
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none" />
<path d="M12 11h4" strokeDasharray="2 2" />
</svg> </svg>
) )
} }
export function Save({ size = defaultProps.size }: IconProps) { export function Save({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> width={size}
<polyline points="17 21 17 13 7 13 7 21"/> height={size}
<polyline points="7 3 7 8 15 8"/> viewBox="0 0 24 24"
</svg> fill="none"
) stroke="currentColor"
} strokeWidth="1.75"
strokeLinecap="round"
export function EditMode({ size = defaultProps.size }: IconProps) { strokeLinejoin="round"
return ( >
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <polyline points="17 21 17 13 7 13 7 21" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <polyline points="7 3 7 8 15 8" />
<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> </svg>
) )
} }
export function Moon({ size = defaultProps.size }: IconProps) { export function Moon({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> width={size}
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5" />
</svg> </svg>
) )
} }
export function Sun({ size = defaultProps.size }: IconProps) { export function Sun({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<circle cx="12" cy="12" r="5"/> width={size}
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/> height={size}
<line x1="12" y1="1" x2="12" y2="3"/> viewBox="0 0 24 24"
<line x1="12" y1="21" x2="12" y2="23"/> fill="none"
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> stroke="currentColor"
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> strokeWidth="1.75"
<line x1="1" y1="12" x2="3" y2="12"/> strokeLinecap="round"
<line x1="21" y1="12" x2="23" y2="12"/> strokeLinejoin="round"
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> >
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> <circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg> </svg>
) )
} }
@@ -117,17 +102,26 @@ export function Sun({ size = defaultProps.size }: IconProps) {
export function Gitee({ size = defaultProps.size }: IconProps) { export function Gitee({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z"/> <path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z" />
</svg> </svg>
) )
} }
export function Info({ size = defaultProps.size }: IconProps) { export function Info({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<circle cx="12" cy="12" r="10"/> width={size}
<line x1="12" y1="16" x2="12" y2="12"/> height={size}
<line x1="12" y1="8" x2="12.01" y2="8"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg> </svg>
) )
} }
@@ -135,18 +129,34 @@ export function Info({ size = defaultProps.size }: IconProps) {
// ===== 标签栏图标 ===== // ===== 标签栏图标 =====
export function Close({ size = 10 }: IconProps) { export function Close({ size = 10 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <svg
<line x1="18" y1="6" x2="6" y2="18"/> width={size}
<line x1="6" y1="6" x2="18" y2="18"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
) )
} }
export function Plus({ size = 14 }: IconProps) { export function Plus({ size = 14 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg
<line x1="12" y1="5" x2="12" y2="19"/> width={size}
<line x1="5" y1="12" x2="19" y2="12"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
) )
} }
@@ -154,38 +164,74 @@ export function Plus({ size = 14 }: IconProps) {
// ===== 侧边栏图标 ===== // ===== 侧边栏图标 =====
export function Folder({ size = 14 }: IconProps) { export function Folder({ size = 14 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> width={size}
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4" />
</svg> </svg>
) )
} }
export function File({ size = 14 }: IconProps) { export function File({ size = 14 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> width={size}
<polyline points="14 2 14 8 20 8"/> height={size}
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/> viewBox="0 0 24 24"
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/> fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4" />
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3" />
</svg> </svg>
) )
} }
export function ChevronRight({ size = 10 }: IconProps) { export function ChevronRight({ size = 10 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg
<polyline points="9 18 15 12 9 6"/> width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg> </svg>
) )
} }
export function FolderPlus({ size = 14 }: IconProps) { export function FolderPlus({ size = 14 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> width={size}
<line x1="12" y1="11" x2="12" y2="17"/> height={size}
<line x1="9" y1="14" x2="15" y2="14"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg> </svg>
) )
} }
@@ -193,11 +239,59 @@ export function FolderPlus({ size = 14 }: IconProps) {
// ===== 拖拽覆盖层图标 ===== // ===== 拖拽覆盖层图标 =====
export function UploadCloud({ size = 64 }: IconProps) { export function UploadCloud({ size = 64 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/> width={size}
<polyline points="12 16 12 8"/> height={size}
<polyline points="8 12 12 8 16 12"/> viewBox="0 0 24 24"
<line x1="12" y1="16" x2="12" y2="22"/> fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<polyline points="12 16 12 8" />
<polyline points="8 12 12 8 16 12" />
<line x1="12" y1="16" x2="12" y2="22" />
</svg>
)
}
// ===== v0.6.0: 数据备份图标 =====
export function Download({ 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
export function Upload({ 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg> </svg>
) )
} }
@@ -205,21 +299,39 @@ export function UploadCloud({ size = 64 }: IconProps) {
// ===== 欢迎屏幕图标 ===== // ===== 欢迎屏幕图标 =====
export function WelcomeFile({ size = 20 }: IconProps) { export function WelcomeFile({ size = 20 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> width={size}
<line x1="12" y1="11" x2="12" y2="17"/> height={size}
<line x1="9" y1="14" x2="15" y2="14"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg> </svg>
) )
} }
export function WelcomeNew({ size = 20 }: IconProps) { export function WelcomeNew({ size = 20 }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> width={size}
<polyline points="14 2 14 8 20 8"/> height={size}
<line x1="12" y1="18" x2="12" y2="12"/> viewBox="0 0 24 24"
<line x1="9" y1="15" x2="15" y2="15"/> fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg> </svg>
) )
} }
@@ -1,70 +0,0 @@
import React from 'react'
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large'
label?: string
/** 是否全屏覆盖 */
overlay?: boolean
}
const sizeMap = {
small: 16,
medium: 24,
large: 36
}
/**
* UX-02: 通用加载指示器组件
*/
export const LoadingSpinner = React.memo(function LoadingSpinner({
size = 'medium',
label,
overlay = false
}: LoadingSpinnerProps) {
const px = sizeMap[size]
const spinner = (
<div
className={`loading-spinner loading-spinner-${size}`}
role="status"
aria-label={label || '加载中'}
>
<svg
className="loading-spinner-svg"
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
opacity="0.2"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{label && <span className="loading-spinner-label">{label}</span>}
</div>
)
if (!overlay) return spinner
return (
<div className="loading-overlay" aria-busy="true">
{spinner}
</div>
)
})
LoadingSpinner.displayName = 'LoadingSpinner'
@@ -1 +0,0 @@
export { LoadingSpinner } from './LoadingSpinner'
@@ -5,12 +5,19 @@ interface ModifiedBannerProps {
onDismiss: () => void onDismiss: () => void
} }
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) { export const ModifiedBanner = React.memo(function ModifiedBanner({
onReload,
onDismiss,
}: ModifiedBannerProps) {
return ( return (
<div id="modified-banner" role="alert" aria-live="assertive"> <div id="modified-banner" role="alert" aria-live="assertive">
<span></span> <span></span>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件"></button> <button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改"></button>
</button>
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">
</button>
</div> </div>
) )
}) })
@@ -20,7 +20,7 @@ const OutlineItem = memo(function OutlineItem({
heading, heading,
index, index,
isActive, isActive,
onNavigate onNavigate,
}: OutlineItemProps) { }: OutlineItemProps) {
return ( return (
<button <button
@@ -39,7 +39,7 @@ const OutlineItem = memo(function OutlineItem({
export const OutlinePanel = memo(function OutlinePanel({ export const OutlinePanel = memo(function OutlinePanel({
headings, headings,
onNavigate, onNavigate,
activeHeadingIndex activeHeadingIndex,
}: OutlinePanelProps) { }: OutlinePanelProps) {
if (headings.length === 0) { if (headings.length === 0) {
return ( return (
-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,447 +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
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'
+70 -46
View File
@@ -11,11 +11,14 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { useActiveHeading } from '../../hooks/useActiveHeading' import { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel' import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel' import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore' import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state'
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export const Sidebar = React.memo(function Sidebar() { export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
@@ -43,12 +46,11 @@ export const Sidebar = React.memo(function Sidebar() {
return parseHeadings(activeTab.content) return parseHeadings(activeTab.content)
}, [activeTab?.content]) }, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题 // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview
const previewRef = useRef<HTMLElement | null>(null) const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => { useEffect(() => {
// 仅在预览模式时获取 preview DOM 节点
if (viewMode === 'preview') { if (viewMode === 'preview') {
previewRef.current = document.getElementById('preview') previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
} else { } else {
previewRef.current = null previewRef.current = null
} }
@@ -56,46 +58,39 @@ export const Sidebar = React.memo(function Sidebar() {
const activeHeadingIndex = useActiveHeading( const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null }, viewMode === 'preview' ? previewRef : { current: null },
headings headings,
) )
// Navigate to heading in ProseMirror document tree // Navigate to heading in MetonaEditor
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => { // v0.6.0: 使用官方 APIscrollToLine + setCursorPosition)替代 DOM hack
const view = getEditorView() const handleHeadingNavigate = useCallback((heading: Heading) => {
if (!view) return const editor = getMetonaEditor()
if (!editor) return
try { 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) => { // 按行号导航 — 官方 API 处理滚动与光标
if (foundPos !== null) return false const line = content.substring(0, match.index).split('\n').length
if (node.type.name === 'heading' && node.attrs.level === heading.level) { editor.scrollToLine(line)
if (node.textContent.trim() === heading.text) { editor.setCursorPosition(line, 0)
if (currentIndex === index) { editor.focus()
foundPos = pos
return false
}
currentIndex++
}
}
return true
})
if (foundPos === null) return
const tr = view.state.tr
.setSelection(TextSelection.create(view.state.doc, foundPos, foundPos))
.scrollIntoView()
view.dispatch(tr)
view.focus()
} catch { } catch {
// Position may be invalid if content has changed // 导航失败,静默忽略
} }
}, []) }, [])
const handleFileClick = useCallback(async (path: string) => { const handleFileClick = useCallback(
async (path: string) => {
const existing = tabs.find(t => t.filePath === path) const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return } if (existing) {
switchToTab(existing.id)
return
}
if (!window.electronAPI) return if (!window.electronAPI) return
setLoading('file-open', true) setLoading('file-open', true)
try { try {
@@ -107,7 +102,9 @@ export const Sidebar = React.memo(function Sidebar() {
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
}, [tabs, switchToTab, createTab, setLoading]) },
[tabs, switchToTab, createTab, setLoading],
)
const independentFiles = tabs.filter(t => { const independentFiles = tabs.filter(t => {
if (!t.filePath) return false if (!t.filePath) return false
@@ -133,9 +130,12 @@ export const Sidebar = React.memo(function Sidebar() {
<nav id="sidebar-tree" role="tree" aria-label="文件树"> <nav id="sidebar-tree" role="tree" aria-label="文件树">
{independentFiles.length > 0 && ( {independentFiles.length > 0 && (
<div className="independent-files-section" role="group" aria-label="已打开的文件"> <div className="independent-files-section" role="group" aria-label="已打开的文件">
<div className="independent-files-header" id="independent-files-label"></div> <div className="independent-files-header" id="independent-files-label">
</div>
{independentFiles.map(tab => ( {independentFiles.map(tab => (
<div key={tab.id} <div
key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`} className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }} style={{ paddingLeft: '8px' }}
role="treeitem" role="treeitem"
@@ -143,22 +143,46 @@ export const Sidebar = React.memo(function Sidebar() {
aria-selected={tab.id === activeTabId} aria-selected={tab.id === activeTabId}
aria-label={getFileName(tab.filePath!)} aria-label={getFileName(tab.filePath!)}
onClick={() => switchToTab(tab.id)} onClick={() => switchToTab(tab.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }} onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
switchToTab(tab.id)
}
}}
> >
<span className="tree-icon"><File size={14} /></span> <span className="tree-icon">
<File size={14} />
</span>
<span className="tree-name">{getFileName(tab.filePath!)}</span> <span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>} {tab.isModified && (
<span className="independent-modified-dot" aria-label="已修改">
{' '}
</span>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
{rootPath && ( {rootPath && (
<> <>
<div className="sidebar-section-header" id="folder-tree-label"></div> <div className="sidebar-section-header" id="folder-tree-label">
</div>
<FileTree <FileTree
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]} nodes={[
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir} {
activeFilePath={activeFilePath} onFileClick={handleFileClick} name: rootPath.split(/[/\\]/).pop() || rootPath,
path: rootPath,
type: 'dir' as const,
children: tree,
},
]}
depth={0}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeFilePath={activeFilePath}
onFileClick={handleFileClick}
/> />
</> </>
)} )}
@@ -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>
}
+47 -45
View File
@@ -1,9 +1,8 @@
import React, { useCallback, useState, useEffect, useRef } from 'react' import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useConfirm } from '../../hooks/useConfirm'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { MeToast } from '../../lib/toast'
import { Close, Plus } from '../Icons' import { Close, Plus } from '../Icons'
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
interface ContextMenuState { interface ContextMenuState {
visible: boolean visible: boolean
@@ -27,7 +26,6 @@ export const TabBar = React.memo(function TabBar() {
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' }) const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null) const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null) const dragTabIdRef = useRef<string | null>(null)
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签 // 滚动到活动标签
const scrollToActiveTab = useCallback(() => { const scrollToActiveTab = useCallback(() => {
@@ -41,11 +39,11 @@ export const TabBar = React.memo(function TabBar() {
// 如果标签在可视区域左侧之外 // 如果标签在可视区域左侧之外
if (tabRect.left < listRect.left) { if (tabRect.left < listRect.left) {
tabList.scrollLeft -= (listRect.left - tabRect.left + 20) tabList.scrollLeft -= listRect.left - tabRect.left + 20
} }
// 如果标签在可视区域右侧之外 // 如果标签在可视区域右侧之外
else if (tabRect.right > listRect.right) { else if (tabRect.right > listRect.right) {
tabList.scrollLeft += (tabRect.right - listRect.right + 20) tabList.scrollLeft += tabRect.right - listRect.right + 20
} }
}, []) }, [])
@@ -76,21 +74,23 @@ export const TabBar = React.memo(function TabBar() {
return () => tabList.removeEventListener('wheel', handleWheel) return () => tabList.removeEventListener('wheel', handleWheel)
}, []) }, [])
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => { const handleClose = useCallback(
async (e: React.MouseEvent, tabId: string) => {
e.stopPropagation() e.stopPropagation()
const tab = tabs.find(t => t.id === tabId) const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) { if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名' const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签', title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTab(tabId) closeTab(tabId)
}, [tabs, closeTab, confirm]) },
[tabs, closeTab],
)
// C-06: 右键菜单(带边界修正) // C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
@@ -114,67 +114,65 @@ export const TabBar = React.memo(function TabBar() {
const tab = tabs.find(t => t.id === menu.tabId) const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) { if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名' const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签', title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTab(menu.tabId) closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab, confirm]) }, [tabs, menu.tabId, closeTab])
const handleMenuCloseOthers = useCallback(async () => { const handleMenuCloseOthers = useCallback(async () => {
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
if (otherModified.length > 0) { if (otherModified.length > 0) {
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = otherModified
const confirmed = await confirm({ .map(t => (t.filePath ? getFileName(t.filePath) : '未命名'))
.join('、')
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭其他标签', title: '关闭其他标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeOtherTabs(menu.tabId) closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs, confirm]) }, [tabs, menu.tabId, closeOtherTabs])
const handleMenuCloseAll = useCallback(async () => { const handleMenuCloseAll = useCallback(async () => {
const modified = tabs.filter(t => t.isModified) const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) { if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭全部标签', title: '关闭全部标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeAllTabs() closeAllTabs()
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs, confirm]) }, [tabs, closeAllTabs])
const handleMenuCloseRight = useCallback(async () => { const handleMenuCloseRight = useCallback(async () => {
const index = tabs.findIndex(t => t.id === menu.tabId) const index = tabs.findIndex(t => t.id === menu.tabId)
const rightTabs = tabs.slice(index + 1) const rightTabs = tabs.slice(index + 1)
const modified = rightTabs.filter(t => t.isModified) const modified = rightTabs.filter(t => t.isModified)
if (modified.length > 0) { if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭右侧标签', title: '关闭右侧标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTabsToRight(menu.tabId) closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm]) }, [tabs, menu.tabId, closeTabsToRight])
// D1: 拖拽排序事件处理 // D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
@@ -198,7 +196,8 @@ export const TabBar = React.memo(function TabBar() {
setDragOverIndex(null) setDragOverIndex(null)
}, []) }, [])
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => { const handleDrop = useCallback(
(e: React.DragEvent, toIndex: number) => {
e.preventDefault() e.preventDefault()
setDragOverIndex(null) setDragOverIndex(null)
const fromId = dragTabIdRef.current const fromId = dragTabIdRef.current
@@ -208,7 +207,9 @@ export const TabBar = React.memo(function TabBar() {
dragTabIdRef.current = null dragTabIdRef.current = null
// 清理 dragging 类 // 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging')) document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, [moveTab]) },
[moveTab],
)
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDragOverIndex(null) setDragOverIndex(null)
@@ -216,7 +217,9 @@ export const TabBar = React.memo(function TabBar() {
dragTabIdRef.current = null dragTabIdRef.current = null
}, []) }, [])
const hasRightTabs = menu.visible && (() => { const hasRightTabs =
menu.visible &&
(() => {
const index = tabs.findIndex(t => t.id === menu.tabId) const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1 return index < tabs.length - 1
})() })()
@@ -237,11 +240,11 @@ export const TabBar = React.memo(function TabBar() {
data-tab-id={tab.id} data-tab-id={tab.id}
draggable draggable
onClick={() => switchToTab(tab.id)} onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)} onContextMenu={e => handleContextMenu(e, tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)} onDragStart={e => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={e => handleDragOver(e, index)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)} onDrop={e => handleDrop(e, index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<span className="tab-name"> <span className="tab-name">
@@ -249,7 +252,7 @@ export const TabBar = React.memo(function TabBar() {
</span> </span>
<button <button
className="tab-close" className="tab-close"
onClick={(e) => handleClose(e, tab.id)} onClick={e => handleClose(e, tab.id)}
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`} aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
> >
<Close size={10} /> <Close size={10} />
@@ -273,8 +276,8 @@ export const TabBar = React.memo(function TabBar() {
style={{ left: menu.x, top: menu.y }} style={{ left: menu.x, top: menu.y }}
role="menu" role="menu"
aria-label="标签操作" aria-label="标签操作"
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
> >
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}> <div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
@@ -296,7 +299,6 @@ export const TabBar = React.memo(function TabBar() {
</div> </div>
)} )}
</div> </div>
<ConfirmDialog {...confirmDialogProps} />
</> </>
) )
}) })
-103
View File
@@ -1,103 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
/** Toast 类型 */
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/** 单条 Toast 数据 */
export interface ToastItem {
id: string
message: string
type: ToastType
duration: number
}
/** Toast 容器组件属性 */
interface ToastContainerProps {
toasts: ToastItem[]
onDismiss: (id: string) => void
}
/** 单条 Toast 组件 */
const SingleToast = React.memo(function SingleToast({
toast,
onDismiss
}: {
toast: ToastItem
onDismiss: (id: string) => void
}) {
const [visible, setVisible] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
// 进入动画
requestAnimationFrame(() => setVisible(true))
// 自动消失
if (toast.duration > 0) {
timerRef.current = setTimeout(() => {
setVisible(false)
// 等待退出动画完成后再移除
setTimeout(() => onDismiss(toast.id), 300)
}, toast.duration)
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [toast.id, toast.duration, onDismiss])
const handleClose = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current)
setVisible(false)
setTimeout(() => onDismiss(toast.id), 300)
}, [toast.id, onDismiss])
const typeIcons: Record<ToastType, string> = {
success: '✓',
error: '✕',
warning: '⚠',
info: ''
}
return (
<div
className={`toast-item toast-${toast.type} ${visible ? 'toast-visible' : ''}`}
role="alert"
aria-live="assertive"
>
<span className="toast-icon" aria-hidden="true">
{typeIcons[toast.type]}
</span>
<span className="toast-message">{toast.message}</span>
<button
className="toast-close"
onClick={handleClose}
aria-label="关闭通知"
type="button"
>
</button>
</div>
)
})
/**
* UX-05: Toast 容器组件
* 支持多条堆叠、类型区分、关闭按钮和自动消失
*/
export const ToastContainer = React.memo(function ToastContainer({
toasts,
onDismiss
}: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="toast-container" aria-label="通知区域">
{toasts.map(toast => (
<SingleToast key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
)
})
ToastContainer.displayName = 'ToastContainer'
-2
View File
@@ -1,2 +0,0 @@
export { ToastContainer } from './Toast'
export type { ToastType, ToastItem } from './Toast'
+142 -40
View File
@@ -1,66 +1,168 @@
import React from 'react' import React, { useCallback } from 'react'
import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons' import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
import type { ViewMode } from '../../types/settings' import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { backupRepository } from '../../db/backupRepository'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { ThemeMode } from '../../types/settings'
interface ToolbarProps { interface ToolbarProps {
onOpen: () => void onOpen: () => void
onSave: () => void onSave: () => void
viewMode: ViewMode themeMode: ThemeMode
onViewModeChange: (mode: ViewMode) => void onCycleTheme: () => void
darkMode: boolean
onToggleDark: () => void
onShowAbout: () => 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] ?? '主题'
const zenMode = useEditorStore(s => s.zenMode)
// v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
const handleToggleZen = useCallback(() => {
const editor = getMetonaEditor()
if (!editor) return
editor.toggleZen()
}, [])
// v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
const handleExport = useCallback(async () => {
if (!window.electronAPI) return
const data = await backupRepository.exportAll()
if (!data) return
const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
if (result.success) {
showToast('备份已导出', 'success')
} else if (!result.canceled) {
showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
}
}, [])
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable
const handleImport = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.importData()
if (!result.success) return
if (result.canceled || !result.content) return
try {
const data = JSON.parse(result.content) as Record<string, Record<string, unknown>[]>
const ok = await backupRepository.importAll(data)
if (ok) {
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
showToast('备份已恢复', 'success')
setTimeout(() => window.location.reload(), 800)
} else {
showToast('恢复备份失败', 'error')
}
} catch (error) {
logError('解析备份文件失败', error)
showToast('备份文件格式无效', 'error')
}
}, [])
return ( return (
<div id="toolbar" role="toolbar" aria-label="工具栏"> <div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作"> <div className="toolbar-left" role="group" aria-label="文件操作">
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件"> <button
className="toolbar-btn"
onClick={onOpen}
title="打开文件 (Ctrl+O)"
aria-label="打开文件"
>
<FolderOpen size={18} /> <FolderOpen size={18} />
<span></span> <span></span>
</button> </button>
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件"> <button
className="toolbar-btn"
onClick={onSave}
title="保存文件 (Ctrl+S)"
aria-label="保存文件"
>
<Save size={18} /> <Save size={18} />
<span></span> <span></span>
</button> </button>
<div className="toolbar-divider" role="separator" /> <div className="toolbar-divider" role="separator" />
<button <button
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={() => onViewModeChange('editor')} onClick={onToggleAutoSave}
title="WYSIWYG 编辑 (Ctrl+1)" title={
aria-label="WYSIWYG 编辑模式" isAutoSaving
aria-pressed={viewMode === 'editor'} ? '正在自动保存...'
: autoSaveEnabled
? '自动保存已开启 — 点击关闭'
: '自动保存已关闭 — 点击开启'
}
aria-label={
isAutoSaving ? '正在自动保存' : autoSaveEnabled ? '关闭自动保存' : '开启自动保存'
}
> >
<EditMode size={18} /> <span>{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动' : '手动'}</span>
<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>
</button> </button>
</div> </div>
<div className="toolbar-right" role="group" aria-label="设置"> <div className="toolbar-right" role="group" aria-label="设置">
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}> <button
{darkMode ? <Sun size={18} /> : <Moon size={18} />} className={`toolbar-btn${zenMode ? ' active' : ''}`}
onClick={handleToggleZen}
title={zenMode ? '退出专注模式' : '专注模式 — 隐藏编辑器工具栏'}
aria-label={zenMode ? '退出专注模式' : '进入专注模式'}
aria-pressed={zenMode}
>
<span>🧘 {zenMode ? '专注中' : '专注'}</span>
</button> </button>
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite"> <button
className="toolbar-btn"
onClick={handleExport}
title="导出数据备份"
aria-label="导出数据备份"
>
<Download size={16} />
</button>
<button
className="toolbar-btn"
onClick={handleImport}
title="导入数据备份"
aria-label="导入数据备份"
>
<Upload size={16} />
</button>
<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} /> <Info size={18} />
</button> </button>
</div> </div>
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons' import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository' import { recentFilesRepository } from '../../db/recentFilesRepository'
import { getDb } from '../../db/schema'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
interface WelcomeScreenProps { interface WelcomeScreenProps {
@@ -9,13 +10,47 @@ interface WelcomeScreenProps {
onOpenRecent?: (filePath: string) => void onOpenRecent?: (filePath: string) => void
} }
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) { export const WelcomeScreen = React.memo(function WelcomeScreen({
onOpen,
onNew,
onOpenRecent,
}: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState<string[]>([]) const [recentFiles, setRecentFiles] = useState<string[]>([])
// v0.6.0: 订阅 recentFiles 表变更,文件打开/删除时自动刷新
useEffect(() => { useEffect(() => {
recentFilesRepository.getAll(10).then((files: string[]) => { let cancelled = false
setRecentFiles(files) let unsubscribe: (() => void) | null = null
const refresh = async () => {
const files = await recentFilesRepository.getAll(10)
if (!cancelled) setRecentFiles(files)
}
refresh()
getDb()
.then(db => {
if (cancelled) return
unsubscribe = db.subscribe('recentFiles', event => {
if (
event.type === 'insert' ||
event.type === 'update' ||
event.type === 'delete' ||
event.type === 'external'
) {
refresh()
}
}) })
})
.catch(() => {
/* 订阅失败不阻塞页面 */
})
return () => {
cancelled = true
unsubscribe?.()
}
}, []) }, [])
return ( return (
@@ -51,7 +86,14 @@ export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew,
aria-label={`打开 ${getFileName(filePath)}`} aria-label={`打开 ${getFileName(filePath)}`}
> >
<span className="recent-icon" aria-hidden="true"> <span className="recent-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
</svg> </svg>
@@ -0,0 +1,34 @@
import 'fake-indexeddb/auto'
import { describe, it, expect } from 'vitest'
import { recentFilesRepository } from '../recentFilesRepository'
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
it('should add and read recent files', async () => {
await recentFilesRepository.add('/test/a.md')
const files = await recentFilesRepository.getAll(10)
expect(files).toContain('/test/a.md')
})
it('should update lastOpened on re-add', async () => {
await recentFilesRepository.add('/test/b.md')
await new Promise(r => setTimeout(r, 5))
await recentFilesRepository.add('/test/b.md')
const files = await recentFilesRepository.getAll(10)
expect(files[0]).toBe('/test/b.md')
})
it('should enforce 50 item limit', async () => {
for (let i = 0; i < 55; i++) {
await recentFilesRepository.add(`/test/f${String(i).padStart(2, '0')}.md`)
}
const files = await recentFilesRepository.getAll(60)
expect(files.length).toBeLessThanOrEqual(50)
})
it('should remove a file', async () => {
await recentFilesRepository.add('/test/remove-me.md')
await recentFilesRepository.remove('/test/remove-me.md')
const files = await recentFilesRepository.getAll(60)
expect(files).not.toContain('/test/remove-me.md')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { getDb } from './schema'
import { logError } from '../lib/errorHandler'
/** v0.6.0: 备份数据格式 — { 表名: 行[] }(与 db.exportAll() 一致) */
export type BackupData = Record<string, Record<string, unknown>[]>
const BACKUP_TABLES = ['tabSnapshots', 'settings', 'recentFiles', 'activeTab']
/**
* v0.6.0: 数据备份/恢复 — 基于 sqlark 的 exportAll / importTable。
*/
export const backupRepository = {
async exportAll(): Promise<BackupData | null> {
try {
const db = await getDb()
return (await db.exportAll()) as BackupData
} catch (error) {
logError('导出备份失败', error)
return null
}
},
async importAll(data: BackupData): Promise<boolean> {
try {
const db = await getDb()
// 先清空现有四表,再按表导入(清空走事务保证原子性)
await db.transaction(async trx => {
for (const tableName of BACKUP_TABLES) {
await trx.table(tableName).clear()
}
})
for (const tableName of BACKUP_TABLES) {
const rows = data[tableName]
if (Array.isArray(rows) && rows.length > 0) {
await db.importTable(tableName, rows)
}
}
return true
} catch (error) {
logError('导入备份失败', error)
return false
}
},
}
+41 -15
View File
@@ -1,21 +1,41 @@
import { db, type RecentFile } from './schema' import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
import { getDb, type RecentFile } from './schema'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
/**
* v0.6.0: 本地写操作完成后 emit 表变更事件 —
* sqlark 的 Table 写操作只通过 BroadcastChannel 通知其他标签页,
* 本地订阅(subscribe)需要手动 emitWelcomeScreen 借此自动刷新最近文件。
*/
async function emitChange(db: MetonaSqlark, type: string): Promise<void> {
try {
db.emit('recentFiles', { type })
} catch {
// 通知失败不影响主流程
}
}
export const recentFilesRepository = { export const recentFilesRepository = {
async add(filePath: string): Promise<void> { async add(filePath: string): Promise<void> {
try { try {
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first() const db = await getDb()
if (existing) { const tbl = db.table('recentFiles') as Table<RecentFile>
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() }) const existing = await tbl.select().where({ filePath }).execute()
if (existing.length > 0) {
await tbl.update({ lastOpened: Date.now() }).where({ filePath }).execute()
} else { } else {
await db.recentFiles.add({ filePath, lastOpened: Date.now() }) await tbl.insert({ filePath, lastOpened: Date.now() })
} }
// L-06: 清理超过 50 条的旧记录 // L-06: 清理超过 50 条的旧记录
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray() const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
if (all.length > 50) { if (all.length > 50) {
const toDelete = all.slice(50) const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!)) await tbl
.delete()
.where({ filePath: { $in: toDelete } })
.execute()
} }
await emitChange(db, 'update')
} catch (error) { } catch (error) {
logError('添加最近文件失败', error) logError('添加最近文件失败', error)
} }
@@ -23,11 +43,13 @@ export const recentFilesRepository = {
async getAll(limit: number = 20): Promise<string[]> { async getAll(limit: number = 20): Promise<string[]> {
try { try {
const files: RecentFile[] = await db.recentFiles const db = await getDb()
.orderBy('lastOpened') const files = (await db
.reverse() .table('recentFiles')
.select(['filePath'])
.orderBy('lastOpened', 'desc')
.limit(limit) .limit(limit)
.toArray() .execute()) as RecentFile[]
return files.map((f: RecentFile) => f.filePath) return files.map((f: RecentFile) => f.filePath)
} catch (error) { } catch (error) {
logError('读取最近文件失败', error) logError('读取最近文件失败', error)
@@ -37,7 +59,9 @@ export const recentFilesRepository = {
async remove(filePath: string): Promise<void> { async remove(filePath: string): Promise<void> {
try { try {
await db.recentFiles.where('filePath').equals(filePath).delete() const db = await getDb()
await db.table('recentFiles').delete().where({ filePath }).execute()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('删除最近文件失败', error) logError('删除最近文件失败', error)
} }
@@ -45,9 +69,11 @@ export const recentFilesRepository = {
async clear(): Promise<void> { async clear(): Promise<void> {
try { try {
await db.recentFiles.clear() const db = await getDb()
await db.table('recentFiles').clear()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('清空最近文件失败', error) logError('清空最近文件失败', error)
} }
} },
} }
+206 -19
View File
@@ -1,6 +1,12 @@
import Dexie, { type EntityTable } from 'dexie' import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
import { logError } from '../lib/errorHandler'
import { MeToast } from '../lib/toast'
export interface TabSnapshot { // 注意: 使用 type 别名而非 interface —
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>
// type 别名具备隐式索引签名,interface 没有(TS 索引签名规则)
export type TabSnapshot = {
id: string id: string
filePath: string | null filePath: string | null
content: string content: string
@@ -11,37 +17,218 @@ export interface TabSnapshot {
updatedAt: number updatedAt: number
} }
export interface ActiveTabRecord { export type ActiveTabRecord = {
id: string // 固定为 'current' id: string // 固定为 'current'
activeTabId: string | null activeTabId: string | null
} }
export interface SettingsRecord { export type SettingsRecord = {
id: string // 固定为 'default' id: string // 固定为 'default'
darkMode: boolean themeMode: 'light' | 'dark' | 'warm'
viewMode: 'editor' | 'preview' | 'source' viewMode: 'editor' | 'preview' | 'source'
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
export interface RecentFile { export type RecentFile = {
id?: number // v0.5.0: sqlark 无自增主键,改用 filePath 作主键(业务上天然唯一)
filePath: string filePath: string
lastOpened: number lastOpened: number
} }
const db = new Dexie('MarkLite') as Dexie & { // v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
tabSnapshots: EntityTable<TabSnapshot, 'id'> // v0.6.1: 存储引擎回归 AriaEngineLSM-Tree + WAL + MVCC,功能最强)。
settings: EntityTable<SettingsRecord, 'id'> // sqlark 0.4.4 修复 SSTable 大 value 编码缺陷(v2 格式 "SSTC"
recentFiles: EntityTable<RecentFile, 'id'> // u32 长度字段 + UTF-8 字节精确估算 + 超大条目独立成块,兼容旧 v1 格式)。
activeTab: EntityTable<ActiveTabRecord, 'id'> const DB_NAME = 'MarkLiteV2'
/**
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
* 使用标准配置 version: 1 即可,不再需要手动解析 IDB 版本号。
* 未来 schema 变更: 在 createDatabase 的幂等建表段追加增量变更逻辑。
*/
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true },
filePath: { type: 'string' },
content: { type: 'string' },
scrollTop: { type: 'number' },
selectionStart: { type: 'number' },
selectionEnd: { type: 'number' },
isModified: { type: 'boolean' },
updatedAt: { type: 'number', index: true },
} }
db.version(1).stores({ const SETTINGS_COLUMNS: Record<string, ColumnDef> = {
tabSnapshots: 'id, filePath, updatedAt', id: { type: 'string', primaryKey: true },
settings: 'id', themeMode: { type: 'string' },
recentFiles: '++id, filePath, lastOpened', viewMode: { type: 'string' },
activeTab: 'id' sidebarCollapsed: { type: 'boolean' },
}) sidebarWidth: { type: 'number' },
}
export { db } const RECENT_FILES_COLUMNS: Record<string, ColumnDef> = {
filePath: { type: 'string', primaryKey: true },
lastOpened: { type: 'number', index: true },
}
const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true },
activeTabId: { type: 'string' },
}
/**
* v0.6.0: 数据损坏自愈 —
* 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
*
* 注意:自愈(删库/重载)只允许在页面生命周期内首次初始化时触发;
* 运行中(如应用关闭流程的 flushSaveToDBgetDb 失败时直接抛错,
* 绝不删库/重载,避免关闭流程被数据库操作阻塞。
*/
let dbPromise: Promise<MetonaSqlark> | null = null
let initFailedNotified = false
let healTriggered = false
// v0.6.1: 关闭流程标记 — closeDatabase 后禁止 getDb 重新打开库,
// 避免 forceClose 前其他组件重新建立连接(未关闭即被进程终止)
let isClosing = false
// v0.6.1: 初始化串行链 — 防止 dbPromise 失败重置后多个 getDb 并发触发 createDatabase
let initChain: Promise<unknown> = Promise.resolve()
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
// AriaEngine 的 IndexedDBBackend 内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}`
/** 删除损坏数据库(无活动连接时立即成功) */
function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => {
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
req.onsuccess = () => resolve()
req.onerror = () => resolve()
req.onblocked = () => resolve()
})
}
/** 自愈入口:存在重置标记时删除损坏库(保留标记,create 成功后才清除) */
async function resetCorruptDatabaseIfNeeded(): Promise<void> {
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') return
await deleteStorageDatabase()
try {
MeToast?.info('数据库已重置(原数据损坏,无法恢复)')
} catch {
/* 提示失败不影响主流程 */
}
} catch {
/* 重置失败不阻塞启动 */
}
}
async function createDatabase(): Promise<MetonaSqlark> {
const db = await create({
name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离(sqlark 0.4.4 修复大 value 编码)
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
onError: (err: Error) => logError('数据库错误', err),
})
// 幂等建表(getTableNames 基于持久化 schema,重启后不会重复建表)
const tables = await db.getTableNames()
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
return db
}
async function initDb(): Promise<MetonaSqlark> {
// 页面生命周期内仅首次初始化走自愈流程(删损坏库 + 失败重载)
if (!healTriggered) {
healTriggered = true
await resetCorruptDatabaseIfNeeded()
try {
const db = await createDatabase()
// 创建成功:清除重置标记(自愈完成)
try {
localStorage.removeItem(RESET_PENDING_KEY)
} catch {
/* 忽略 */
}
return db
} catch (err) {
logError('数据库打开失败', err)
// 启动早期失败:标记 + 重载页面(干净环境删除损坏库重建)— 仅此一次
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
localStorage.setItem(RESET_PENDING_KEY, '1')
window.location.reload()
}
} catch {
/* 忽略 */
}
throw err
}
}
// 运行中(自愈已尝试过):不再删库/重载,直接尝试创建,失败抛给调用方
return createDatabase()
}
/**
* v0.6.1: 关闭数据库 — 应用退出前调用,确保 WAL/SSTable 干净落盘,
* 避免渲染进程被销毁时 flush 中断留下半写文件导致下次启动损坏。
*/
export async function closeDatabase(): Promise<void> {
isClosing = true
const current = dbPromise
dbPromise = null
if (!current) return
try {
const db = await current.catch(() => null)
if (db) await db.close()
} catch (err) {
logError('关闭数据库失败', err)
}
}
export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) {
if (isClosing) {
// 关闭流程中禁止重开(返回 rejected Promise,调用方 catch 吞掉)
return Promise.reject(new Error('数据库正在关闭'))
}
// 串行化初始化:失败后的重试排队执行,避免并发 createDatabase
initChain = initChain.catch(() => {
/* 前序失败不影响后续 */
})
dbPromise = initChain
.then(() => initDb())
.catch(err => {
// v0.6.1: 初始化失败不再静默 — 记录日志并提示用户(否则所有数据持久化静默失效)
logError('数据库初始化失败', err)
if (!initFailedNotified) {
initFailedNotified = true
try {
const code = (err as { code?: string })?.code ?? 'UNKNOWN'
const msg = err instanceof Error ? err.message : String(err)
// ARIA_OPEN_ERROR 等包装错误的具体原因在 details 里(原始异常)
const details = (err as { details?: { message?: string } })?.details?.message
const detailText = details ? ` | 详情: ${details}` : ''
setTimeout(() => {
MeToast?.error(`数据库初始化失败(${code}):${msg}${detailText}`)
}, 0)
} catch {
/* 提示失败不影响主流程 */
}
}
throw err
})
// 初始化失败时允许下次重试
dbPromise.catch(() => {
dbPromise = null
})
}
return dbPromise
}
+20 -7
View File
@@ -1,17 +1,20 @@
import { db, type SettingsRecord } from './schema' import { type Table } from '@metona-team/metona-sqlark'
import { getDb, type SettingsRecord } from './schema'
import { DEFAULT_SETTINGS, type Settings } from '../types/settings' import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
export const settingsRepository = { export const settingsRepository = {
async load(): Promise<Settings> { async load(): Promise<Settings> {
try { try {
const record: SettingsRecord | undefined = await db.settings.get('default') const db = await getDb()
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
const record = rows[0] as SettingsRecord | undefined
if (record) { if (record) {
return { return {
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode, themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode, viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed, sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
} }
} }
} catch (error) { } catch (error) {
@@ -20,15 +23,25 @@ export const settingsRepository = {
return { ...DEFAULT_SETTINGS } return { ...DEFAULT_SETTINGS }
}, },
// C-01: 先 load 再 merge 再 put,避免部分字段丢失 // C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
// M-04: 添加错误处理与日志 // M-04: 添加错误处理与日志
async save(partial: Partial<Settings>): Promise<void> { async save(partial: Partial<Settings>): Promise<void> {
try { try {
const db = await getDb()
const current: Settings = await this.load() const current: Settings = await this.load()
const merged: SettingsRecord = { id: 'default', ...current, ...partial } const merged: SettingsRecord = { id: 'default', ...current, ...partial }
await db.settings.put(merged) const tbl = db.table('settings') as Table<SettingsRecord>
const rows = await tbl.select().where({ id: 'default' }).execute()
if (rows.length > 0) {
await tbl
.update({ ...merged })
.where({ id: 'default' })
.execute()
} else {
await tbl.insert(merged)
}
} catch (error) { } catch (error) {
logError('保存设置失败', error) logError('保存设置失败', error)
} }
} },
} }
+26 -10
View File
@@ -1,12 +1,17 @@
import { db, type TabSnapshot } from './schema' import { type Table } from '@metona-team/metona-sqlark'
import { getDb, type TabSnapshot, type ActiveTabRecord } from './schema'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
export const tabRepository = { export const tabRepository = {
async saveAll(tabs: TabSnapshot[]): Promise<void> { async saveAll(tabs: TabSnapshot[]): Promise<void> {
try { try {
await db.transaction('rw', db.tabSnapshots, async () => { const db = await getDb()
await db.tabSnapshots.clear() // v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
await db.tabSnapshots.bulkAdd(tabs) await db.transaction(async trx => {
await trx.table('tabSnapshots').clear()
if (tabs.length > 0) {
await trx.table('tabSnapshots').insertMany(tabs)
}
}) })
} catch (error) { } catch (error) {
logError('保存标签快照失败', error) logError('保存标签快照失败', error)
@@ -15,7 +20,9 @@ export const tabRepository = {
async loadAll(): Promise<TabSnapshot[]> { async loadAll(): Promise<TabSnapshot[]> {
try { try {
return await db.tabSnapshots.orderBy('updatedAt').toArray() const db = await getDb()
const rows = await db.table('tabSnapshots').select().orderBy('updatedAt', 'asc').execute()
return rows as TabSnapshot[]
} catch (error) { } catch (error) {
logError('加载标签快照失败', error) logError('加载标签快照失败', error)
return [] return []
@@ -24,7 +31,8 @@ export const tabRepository = {
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
try { try {
await db.tabSnapshots.clear() const db = await getDb()
await db.table('tabSnapshots').clear()
} catch (error) { } catch (error) {
logError('清空标签快照失败', error) logError('清空标签快照失败', error)
} }
@@ -32,7 +40,14 @@ export const tabRepository = {
async saveActiveTabId(tabId: string | null): Promise<void> { async saveActiveTabId(tabId: string | null): Promise<void> {
try { try {
await db.activeTab.put({ id: 'current', activeTabId: tabId }) const db = await getDb()
const tbl = db.table('activeTab') as Table<ActiveTabRecord>
const rows = await tbl.select().where({ id: 'current' }).execute()
if (rows.length > 0) {
await tbl.update({ activeTabId: tabId }).where({ id: 'current' }).execute()
} else {
await tbl.insert({ id: 'current', activeTabId: tabId })
}
} catch (error) { } catch (error) {
logError('保存活动标签ID失败', error) logError('保存活动标签ID失败', error)
} }
@@ -40,11 +55,12 @@ export const tabRepository = {
async loadActiveTabId(): Promise<string | null> { async loadActiveTabId(): Promise<string | null> {
try { try {
const record = await db.activeTab.get('current') const db = await getDb()
return record?.activeTabId ?? null const rows = await db.table('activeTab').select().where({ id: 'current' }).execute()
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
} catch (error) { } catch (error) {
logError('加载活动标签ID失败', error) logError('加载活动标签ID失败', error)
return null return null
} }
} },
} }
@@ -1,76 +0,0 @@
import { describe, it, expect } from 'vitest'
import { computeDocStats } from '../useDocStats'
describe('computeDocStats', () => {
it('should return zeros for undefined content', () => {
expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for null content', () => {
expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for empty string', () => {
expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should count single word', () => {
const result = computeDocStats('hello')
expect(result.words).toBe(1)
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(5)
expect(result.lines).toBe(1)
})
it('should count multiple words', () => {
const result = computeDocStats('hello world')
expect(result.words).toBe(2)
expect(result.chars).toBe(11) // 'hello world' = 11 chars
expect(result.charsNoSpace).toBe(10) // without the space
})
it('should count characters without spaces', () => {
const result = computeDocStats('a b c')
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(3)
})
it('should count lines', () => {
const result = computeDocStats('line1\nline2\nline3')
expect(result.lines).toBe(3)
expect(result.words).toBe(3)
})
it('should handle Windows line endings', () => {
const result = computeDocStats('line1\r\nline2\r\nline3')
expect(result.lines).toBe(3)
})
it('should handle trailing newline', () => {
const result = computeDocStats('hello\n')
expect(result.lines).toBe(2)
expect(result.words).toBe(1)
})
it('should count markdown content correctly', () => {
const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
const result = computeDocStats(md)
expect(result.words).toBe(17)
expect(result.lines).toBe(7)
expect(result.chars).toBe(md.length)
})
it('should handle whitespace-only content', () => {
const result = computeDocStats(' \n \n ')
expect(result.words).toBe(0)
expect(result.chars).toBe(9)
expect(result.charsNoSpace).toBe(0)
expect(result.lines).toBe(3)
})
it('should handle content with non-ASCII characters', () => {
const result = computeDocStats('中文测试 日本語 한국어')
expect(result.words).toBe(3)
expect(result.chars).toBe(12)
})
})
+4 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
/** /**
* D4: 基于视口的活跃标题追踪 hook * D4: 基于视口的活跃标题追踪 hook
@@ -13,10 +13,9 @@ import { useEffect, useRef, useState, useCallback } from 'react'
*/ */
export function useActiveHeading( export function useActiveHeading(
containerRef: React.RefObject<HTMLElement | null>, containerRef: React.RefObject<HTMLElement | null>,
headings: { level: number; text: string }[] headings: { level: number; text: string }[],
): number | null { ): number | null {
const [activeIndex, setActiveIndex] = useState<number | null>(null) const [activeIndex, setActiveIndex] = useState<number | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = containerRef.current const container = containerRef.current
@@ -27,7 +26,7 @@ export function useActiveHeading(
// 收集容器内所有 h1-h6 元素的 offsetTop // 收集容器内所有 h1-h6 元素的 offsetTop
const headingElements = Array.from( const headingElements = Array.from(
container.querySelectorAll('h1, h2, h3, h4, h5, h6') container.querySelectorAll('h1, h2, h3, h4, h5, h6'),
) as HTMLElement[] ) as HTMLElement[]
if (headingElements.length === 0) { if (headingElements.length === 0) {
@@ -49,7 +48,7 @@ export function useActiveHeading(
// 找到 headings 中匹配的索引 // 找到 headings 中匹配的索引
const text = el.textContent?.trim() ?? '' const text = el.textContent?.trim() ?? ''
const matchIdx = headings.findIndex( const matchIdx = headings.findIndex(
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level) h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level),
) )
if (matchIdx >= 0) bestIndex = matchIdx if (matchIdx >= 0) bestIndex = matchIdx
} }
@@ -71,10 +70,8 @@ export function useActiveHeading(
// 初始计算 // 初始计算
handleScroll() handleScroll()
const observer = observerRef.current
return () => { return () => {
container.removeEventListener('scroll', handleScroll) container.removeEventListener('scroll', handleScroll)
observer?.disconnect()
} }
}, [containerRef, headings, handleScroll]) }, [containerRef, headings, handleScroll])
+7 -3
View File
@@ -23,7 +23,11 @@ export function toggleAutoSaveExternal(): void {
* Captures the tabId at debounce start so the timeout always saves the * Captures the tabId at debounce start so the timeout always saves the
* correct tab even if the user switches tabs during the debounce window. * correct tab even if the user switches tabs during the debounce window.
*/ */
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } { export function useAutoSave(): {
isAutoSaving: boolean
autoSaveEnabled: boolean
toggleAutoSave: () => void
} {
const [isAutoSaving, setIsAutoSaving] = useState(false) const [isAutoSaving, setIsAutoSaving] = useState(false)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true) const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -50,7 +54,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
mountedRef.current = true mountedRef.current = true
// Subscribe to Zustand store — fires on every state change // Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe((state) => { const unsub = useTabStore.subscribe(state => {
if (!enabledRef.current) return if (!enabledRef.current) return
const tab = state.getActiveTab() const tab = state.getActiveTab()
@@ -79,7 +83,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
if (!window.electronAPI) return if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({ const result = await window.electronAPI.saveFile({
filePath: tabToSave.filePath, filePath: tabToSave.filePath,
content: tabToSave.content content: tabToSave.content,
}) })
if (result.success && mountedRef.current) { if (result.success && mountedRef.current) {
currentState.setModified(tabToSave.id, false) currentState.setModified(tabToSave.id, false)
-71
View File
@@ -1,71 +0,0 @@
import { useState, useCallback, useRef } from 'react'
interface ConfirmOptions {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
}
interface ConfirmState extends ConfirmOptions {
open: boolean
resolve: ((value: boolean) => void) | null
}
/**
* UX-01: Promise-based 确认对话框 hook
* 替代原生 confirm(),返回 Promise<boolean>
*/
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
open: false,
title: '',
message: '',
resolve: null
})
// 使用 ref 确保回调中能拿到最新的 resolve
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
setState({
open: true,
title: options.title,
message: options.message,
confirmLabel: options.confirmLabel,
cancelLabel: options.cancelLabel,
variant: options.variant,
resolve
})
})
}, [])
const handleConfirm = useCallback(() => {
resolveRef.current?.(true)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
const handleCancel = useCallback(() => {
resolveRef.current?.(false)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
return {
confirm,
confirmDialogProps: {
open: state.open,
title: state.title,
message: state.message,
confirmLabel: state.confirmLabel,
cancelLabel: state.cancelLabel,
variant: state.variant,
onConfirm: handleConfirm,
onCancel: handleCancel
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import { useMemo } from 'react'
export interface DocStats {
/** 单词数(按空白字符分割) */
words: number
/** 总字符数(含空白字符) */
chars: number
/** 字符数(不含空白字符) */
charsNoSpace: number
/** 行数 */
lines: number
}
/**
* 计算文档统计信息:单词数、字符数(含/不含空格)、行数。
* 对空文档或 undefined 返回全零值。
*/
export function computeDocStats(content: string | undefined | null): DocStats {
if (!content) {
return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 }
}
const chars = content.length
const charsNoSpace = content.replace(/\s/g, '').length
const words = content.trim()
? content.trim().split(/\s+/).length
: 0
const lines = content === '' ? 0 : content.split(/\r?\n/).length
return { words, chars, charsNoSpace, lines }
}
/**
* Hook:根据文档内容实时计算统计信息。
* 使用 useMemo 避免不必要的重新计算。
*/
export function useDocStats(content: string | undefined | null): DocStats {
return useMemo(() => computeDocStats(content), [content])
}
+7 -3
View File
@@ -3,11 +3,13 @@ import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils' import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants' import { MAX_FILE_SIZE } from '../lib/constants'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
export function useDragDrop(showToast: (msg: string) => void) { export function useDragDrop() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => { const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@@ -50,7 +52,9 @@ export function useDragDrop(showToast: (msg: string) => void) {
if (rejected > 0) { if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`) showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
} }
}, [createTab, showToast]) },
[createTab],
)
useEffect(() => { useEffect(() => {
const prevent = (e: DragEvent) => { const prevent = (e: DragEvent) => {
+54 -18
View File
@@ -1,71 +1,105 @@
import { useCallback } from 'react' import { useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository' import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import type { ToastType } from '../components/Toast/Toast' import { MeToast, showToast } from '../lib/toast'
/** /**
* AR-01: 从 App.tsx 提取的文件操作逻辑 * AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示 * UX-02: 添加 loading 状态指示
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
* v0.6.0: loading 改用 MeToast.loading 链式转换,保存结果用 MeToast.promise 提示
*/ */
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) { export function useFileOperations() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab) const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading) const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const savingGate = useRef(false)
const handleOpenFile = useCallback(async (): Promise<void> => { const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return if (!window.electronAPI) return
const loading = MeToast.loading('打开文件...')
try {
setLoading('file-open', true) setLoading('file-open', true)
const result = await window.electronAPI.openFile() const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) { if (result && 'filePath' in result) {
createTab(result.filePath, result.content) createTab(result.filePath, result.content)
if (result.filePath) recentFilesRepository.add(result.filePath) if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100) setTimeout(() => useTabStore.getState().saveToDB(), 100)
loading.success('文件已打开')
} else {
loading.dismiss()
} }
} catch (error) { } catch (error) {
logError('打开文件失败', error) logError('打开文件失败', error)
showToast('打开文件失败', 'error') loading.error('打开文件失败')
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
}, [createTab, showToast, setLoading]) }, [createTab, setLoading])
const handleSave = useCallback(async (): Promise<void> => { const handleSave = useCallback(async (): Promise<void> => {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
if (savingGate.current) return
savingGate.current = true
try { try {
const tab = getActiveTab() const tab = getActiveTab()
if (!tab || !window.electronAPI) return if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFile({ // v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
const result = await MeToast.promise(
window.electronAPI.saveFile({
filePath: tab.filePath, filePath: tab.filePath,
content: tab.content content: tab.content,
}) }),
{
loading: '保存中...',
success: '已保存',
error: '保存失败',
},
)
if (result.success) { if (result.success) {
useTabStore.getState().setModified(tab.id, false) useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success') } else {
// IPC 返回 success:false 不 rejectpromise 的 error 文案不会触发,需显式提示
showToast('保存失败', 'error')
} }
} catch (error) { } catch (error) {
logError('保存文件失败', error) logError('保存文件失败', error)
showToast('保存失败', 'error') } finally {
// 300ms 后释放锁,允许下次保存
setTimeout(() => {
savingGate.current = false
}, 300)
} }
}, [getActiveTab, showToast]) }, [getActiveTab])
const handleSaveAs = useCallback(async (): Promise<void> => { const handleSaveAs = useCallback(async (): Promise<void> => {
try { try {
const tab = getActiveTab() const tab = getActiveTab()
if (!tab || !window.electronAPI) return if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFileAs({ content: tab.content }) const result = await MeToast.promise(
window.electronAPI.saveFileAs({ content: tab.content }),
{
loading: '另存为...',
success: '已保存',
error: '另存为失败',
},
)
if (result.success) { if (result.success) {
useTabStore.getState().setModified(tab.id, false) useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success') } else if (!result.canceled) {
showToast('另存为失败', 'error')
} }
} catch (error) { } catch (error) {
logError('另存为失败', error) logError('另存为失败', error)
showToast('另存为失败', 'error')
} }
}, [getActiveTab, showToast]) }, [getActiveTab])
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => { const handleOpenRecent = useCallback(
async (filePath: string): Promise<void> => {
if (!window.electronAPI) return if (!window.electronAPI) return
setLoading('file-open', true) setLoading('file-open', true)
try { try {
@@ -78,7 +112,9 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
}, [createTab, setLoading]) },
[createTab, setLoading],
)
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
} }
+40 -18
View File
@@ -1,49 +1,71 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) { /**
const setViewMode = useEditorStore(s => s.setViewMode) * 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
const handleKeydown = useCallback((e: KeyboardEvent) => { * v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
*/
export function useKeyboard(
handleOpenFile: () => void,
handleSave: () => void,
handleSaveAs: () => void,
) {
const handleKeydown = useCallback(
(e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return } if (isCtrl && e.key === 'o') {
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return } e.preventDefault()
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return } handleOpenFile()
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return } return
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return } }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return } // 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) {
e.preventDefault()
handleSave()
return
}
if (isCtrl && e.shiftKey && e.key === 'S') {
e.preventDefault()
handleSaveAs()
return
}
const tabState = useTabStore.getState() const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } if (isCtrl && e.key === 't') {
e.preventDefault()
tabState.createTab(null, '')
return
}
if (isCtrl && e.key === 'w') { if (isCtrl && e.key === 'w') {
e.preventDefault() e.preventDefault()
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId) if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
return return
} }
// Ctrl+Tab / Ctrl+Shift+Tab // Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
if (isCtrl && e.key === 'Tab') { if (isCtrl && e.key === 'Tab') {
e.preventDefault() e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) { if (tabs.length > 1) {
if (e.shiftKey) {
if (mruStack.length > 0) { if (mruStack.length > 0) {
const targetId = mruStack[0] const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) { if (tabs.find(t => t.id === targetId)) {
tabState.switchToTab(targetId) tabState.switchToTab(targetId)
return
} }
} }
} else {
const idx = tabs.findIndex(t => t.id === activeTabId) const idx = tabs.findIndex(t => t.id === activeTabId)
const next = (idx + 1) % tabs.length const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length
tabState.switchToTab(tabs[next].id) tabState.switchToTab(tabs[next].id)
} }
}
return return
} }
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode]) },
[handleOpenFile, handleSave, handleSaveAs],
)
useEffect(() => { useEffect(() => {
document.addEventListener('keydown', handleKeydown) 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 }
}
+11 -11
View File
@@ -6,11 +6,11 @@ import { logError } from '../lib/errorHandler'
/** /**
* AR-04: 统一设置加载 hook * AR-04: 统一设置加载 hook
* 一次性从 IndexedDB 加载所有设置,分发到各 store * 一次性从 MetonaSqlark 加载所有设置,分发到各 store
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式 * 替代各 hook 各自独立加载设置的模式
*/ */
export function useSettingsInit() { export function useSettingsInit() {
const setDarkMode = useEditorStore(s => s.setDarkMode) const setThemeMode = useEditorStore(s => s.setThemeMode)
const setViewMode = useEditorStore(s => s.setViewMode) const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false) const isInitialized = useRef(false)
@@ -18,13 +18,13 @@ export function useSettingsInit() {
if (isInitialized.current) return if (isInitialized.current) return
isInitialized.current = true isInitialized.current = true
settingsRepository.load() settingsRepository
.then((settings) => { .load()
// 主题 .then(settings => {
// 主题:优先使用保存的设置,否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
setDarkMode(darkMode) setThemeMode(themeMode)
document.documentElement.classList.toggle('dark', darkMode)
// 视图模式 // 视图模式
setViewMode(settings.viewMode ?? 'editor') setViewMode(settings.viewMode ?? 'editor')
@@ -35,14 +35,14 @@ export function useSettingsInit() {
useSidebarStore.setState({ useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed, isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth, sidebarWidth: settings.sidebarWidth,
_loaded: true _loaded: true,
}) })
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
logError('加载设置失败', error) logError('加载设置失败', error)
}) })
}, [setDarkMode, setViewMode]) }, [setThemeMode, setViewMode])
return { isInitialized } return { isInitialized }
} }
+13 -1
View File
@@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
/** /**
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑 * AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
*
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
*/ */
export function useSidebarResize() { export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
@@ -17,7 +21,15 @@ export function useSidebarResize() {
} }
} }
const handleMouseUp = (): void => setIsResizing(false) const handleMouseUp = (): void => {
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB
if (sidebarRef.current) {
const width = sidebarRef.current.offsetWidth
const clamped = Math.max(180, Math.min(500, width))
useSidebarStore.getState().setSidebarWidth(clamped)
}
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
-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])
}
+87 -11
View File
@@ -1,21 +1,97 @@
import { useEffect } from 'react' import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository' import { settingsRepository } from '../db/settingsRepository'
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 {
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
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 * 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
* 不再独立加载设置(由 useSettingsInit 统一加载) * 应用颜色自动同步编辑器主题,保持一致。
* 仅负责主题切换时的同步和保存
*/ */
export function useTheme() { export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode) const themeMode = useEditorStore(s => s.themeMode)
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode) 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(() => { useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode) // 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
settingsRepository.save({ darkMode }) document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
}, [darkMode]) syncAppColorsToEditor()
}, [themeMode])
return { darkMode, toggleDarkMode } return { themeMode, cycleTheme: handleCycleTheme }
} }
-45
View File
@@ -1,45 +0,0 @@
import { useState, useCallback } from 'react'
import type { ToastItem, ToastType } from '../components/Toast/Toast'
/** 默认自动消失时间 (ms) */
const DEFAULT_DURATION = 3000
/** 最大同时显示数量 */
const MAX_TOASTS = 5
// Module-level counter survives component remounts
let toastCounter = 0
/**
* UX-05: 升级版 Toast 状态管理 hook
* 支持多条堆叠、类型区分、自动消失
*/
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([])
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
const id = `toast-${++toastCounter}`
const newToast: ToastItem = { id, message: msg, type, duration }
setToasts(prev => {
const next = [...prev, newToast]
// 超过最大数量时移除最旧的
if (next.length > MAX_TOASTS) {
return next.slice(next.length - MAX_TOASTS)
}
return next
})
return id
}, [])
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const cleanup = useCallback(() => {
setToasts([])
}, [])
return { toasts, showToast, dismissToast, cleanup }
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function useUnsavedWarning(
hasUnsaved: () => boolean, hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean>, confirmFn?: (message: string) => Promise<boolean>,
// D5: 关闭前回调(flush 待保存数据) // D5: 关闭前回调(flush 待保存数据)
onBeforeForceClose?: () => Promise<void> onBeforeForceClose?: () => Promise<void>,
) { ) {
const confirmFnRef = useRef(confirmFn) const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn confirmFnRef.current = confirmFn
+93 -19
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { renderMarkdown } from '../markdown' import { renderMarkdown, renderMarkdownSync } from '../markdown'
describe('renderMarkdown', () => { describe('renderMarkdown (MetonaEditor 内置解析器)', () => {
it('should render a simple heading', async () => { it('should render a simple heading', async () => {
const result = await renderMarkdown('# Hello World') const result = await renderMarkdown('# Hello World')
expect(result).toContain('<h1') expect(result).toContain('<h1')
@@ -26,7 +26,7 @@ describe('renderMarkdown', () => {
expect(result).toContain('code') expect(result).toContain('code')
}) })
it('should render code blocks with syntax highlighting', async () => { it('should render code blocks', async () => {
const md = '```javascript\nconst x = 1;\n```' const md = '```javascript\nconst x = 1;\n```'
const result = await renderMarkdown(md) const result = await renderMarkdown(md)
expect(result).toContain('<pre') expect(result).toContain('<pre')
@@ -75,32 +75,106 @@ describe('renderMarkdown', () => {
expect(result).toContain('deleted') expect(result).toContain('deleted')
}) })
it('should render task list', async () => {
const result = await renderMarkdown('- [x] done\n- [ ] todo')
expect(result).toContain('me-task-item')
expect(result).toContain('checked')
})
it('should render mermaid as .me-mermaid container', async () => {
const md = '```mermaid\ngraph TD\nA-->B\n```'
const result = await renderMarkdown(md)
expect(result).toContain('me-mermaid')
expect(result).toContain('class="mermaid"')
expect(result).toContain('graph TD')
})
it('should handle empty content', async () => { it('should handle empty content', async () => {
const result = await renderMarkdown('') const result = await renderMarkdown('')
expect(result).toBe('') expect(result).toBe('')
}) })
it('should return error HTML on rendering failure with invalid input', async () => { it('should handle null and undefined filePath', async () => {
// unified should still handle gracefully, but verify error path works expect(await renderMarkdown('# No File', null)).toContain('No File')
const result = await renderMarkdown('normal text') expect(await renderMarkdown('# No File')).toContain('No File')
expect(result).not.toContain('渲染错误')
}) })
it('should cache processors for same filePath', async () => { it('should be sync-callable via renderMarkdownSync', () => {
// Call twice with same filePath to test caching const result = renderMarkdownSync('# Sync')
const result1 = await renderMarkdown('# Test', '/test/file.md') expect(result).toContain('<h1')
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
expect(result1).toContain('<h1')
expect(result2).toContain('Test 2')
}) })
})
it('should handle null filePath', async () => { describe('fixImageSrcs — 相对路径图片修复', () => {
const result = await renderMarkdown('# No File', null) const FILE = '/home/user/docs/note.md'
expect(result).toContain('No File')
it('should fix relative image paths to file://', async () => {
const result = await renderMarkdown('![img](./pic.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/pic.png"')
})
it('should fix nested relative paths', async () => {
const result = await renderMarkdown('![img](assets/a.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/assets/a.png"')
})
it('should fix unix absolute paths', async () => {
const result = await renderMarkdown('![img](/abs/pic.png)', FILE)
expect(result).toContain('src="file:///abs/pic.png"')
})
it('should not touch http/https/data:/file: URLs', async () => {
const md = '![a](https://x.com/a.png) ![b](data:image/png;base64,xx) ![c](file:///c.png)'
const result = await renderMarkdown(md, FILE)
expect(result).toContain('https://x.com/a.png')
expect(result).toContain('data:image/png;base64,xx')
expect(result).toContain('file:///c.png')
})
it('should skip out-of-directory traversal (../ and ../../)', async () => {
const md = '![a](../outside.png) ![b](../../outside.png)'
const result = await renderMarkdown(md, FILE)
// 越界路径保持原样(不注入 file://)
expect(result).not.toContain('file:')
expect(result).toContain('../outside.png')
expect(result).toContain('../../outside.png')
})
it('should not treat sibling directory with same prefix as inside', async () => {
// /home/user/docs-other/ 与 /home/user/docs/ 前缀相似但目录不同
const result = await renderMarkdown('![img](../docs-other/pic.png)', FILE)
expect(result).not.toContain('file:')
expect(result).toContain('../docs-other/pic.png')
})
it('should not touch images when filePath is null', async () => {
const result = await renderMarkdown('![img](./pic.png)')
expect(result).toContain('./pic.png')
expect(result).not.toContain('file:')
})
it('should fix relative image paths from root-level file', async () => {
const result = await renderMarkdown('![img](./pic.png)', '/note.md')
expect(result).toContain('src="file:///pic.png"')
})
it('should escape special chars in fixed path', async () => {
const result = await renderMarkdown('![img](./a&b.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/a&amp;b.png"')
})
})
describe('XSS 防护(内置 safeUrl', () => {
it('should not emit anchor for javascript: URLs', async () => {
const result = await renderMarkdown('[x](javascript:alert(1))')
// 内置解析器将危险 URL 转义原样输出(不生成 <a>),文本不构成可执行链接
expect(result).not.toContain('<a')
expect(result).not.toContain('href=')
}) })
it('should handle undefined filePath', async () => { it('should not emit img for javascript: image src', async () => {
const result = await renderMarkdown('# No File') const result = await renderMarkdown('![x](javascript:alert(1))')
expect(result).toContain('No File') expect(result).not.toContain('<img')
expect(result).not.toContain('src=')
}) })
}) })
+62 -103
View File
@@ -1,12 +1,9 @@
import { unified, type Plugin } from 'unified' import { parseMarkdown } from '@metona-team/metona-editor'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm' // v0.6.0: 渲染管线迁移至 MetonaEditor 内置解析器(parseMarkdown),
import remarkRehype from 'remark-rehype' // 移除 unified/remark/rehype 自研管线。
import rehypeRaw from 'rehype-raw' // 内置解析器原生支持:GFM / 任务列表 / 脚注 / 数学公式 / 定义列表 / emoji /
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' // mermaid(输出 .me-mermaid 容器)/ XSS 防护(escapeHTML + safeUrl)。
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块) // 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判 // E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
@@ -29,112 +26,74 @@ function resolveRelativePath(base: string, rel: string): string {
return parts.join('/') return parts.join('/')
} }
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 /** 属性值转义(与内置解析器 escapeAttr 行为一致) */
function rehypeFixImages(filePath: string | null): Plugin<[], Root> { function escapeAttr(s: string): string {
return () => (tree: Root) => { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (!filePath) return }
/**
* 图片路径修复 — HTML 后处理。
*
* v0.6.0: 内置解析器的 safeUrl 会过滤 file: 协议,且 sanitize 钩子不参与预览渲染,
* 因此相对路径图片的修复必须在 parseMarkdown 之后、输出到 DOM 之前完成。
* 内置解析器输出的 img 标签格式固定(src 属性在最前、属性值经 escapeAttr 转义),
* 用正则精确匹配 src 属性即可安全替换。
*
* CQ-09: 保持与原 rehypeFixImages 一致的安全策略 —
* 解析完整路径并检查是否越界到 markdown 文件所在目录之外。
*/
function fixImageSrcs(html: string, filePath: string | null): string {
if (!filePath) return html
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
function visit(node: Element | Root): void { return html.replace(/<img\b[^>]*\bsrc="([^"]*)"/g, (full, src) => {
if (!node.children) return const raw = String(src).replace(/&amp;/g, '&') // 先还原转义,避免二次转义
for (const child of node.children) { if (!raw) return full
if (child.type === 'element' && child.tagName === 'img') { if (
const src = child.properties?.src as string | undefined raw.startsWith('http://') ||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { raw.startsWith('https://') ||
// 处理Unix风格绝对路径(以/开头) raw.startsWith('data:') ||
if (src.startsWith('/')) { raw.startsWith('file:')
child.properties = { ) {
...child.properties, return full
src: 'file://' + src
} }
continue // 处理 Unix 风格绝对路径(以 / 开头)
if (raw.startsWith('/')) {
return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
} }
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外 // 解析完整路径并检查是否越界
const resolvedPath = resolveRelativePath(dir, src) // 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
// E1: 统一规范化后比较,防止 ../ 路径越界 // 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
const resolvedPath = resolveRelativePath(filePath, raw)
const normalizedBase = normPath(dir) const normalizedBase = normPath(dir)
if (!normPath(resolvedPath).startsWith(normalizedBase)) return // 用 base + '/' 前缀判断,避免 /home/docs-other/ 误判为在 /home/docs/ 内
child.properties = { const boundary = normalizedBase === '' ? '/' : normalizedBase + '/'
...child.properties, if (!normPath(resolvedPath).startsWith(boundary)) return full
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/') const fixed = 'file://' + resolvedPath
} return full.replace(`src="${src}"`, `src="${escapeAttr(fixed)}"`)
}
}
if (child.type === 'element') {
visit(child as Element)
}
}
}
visit(tree)
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 20
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
.use(rehypeFixImages(filePath ?? null))
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
},
protocols: {
...(defaultSchema.protocols ?? {}),
src: [
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
'file:',
],
},
// H-05: 显式 strip 事件处理器属性,纵深防御
strip: ['script', 'on*', 'javascript:'],
}) })
.use(rehypeHighlight)
.use(rehypeStringify)
} }
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> { /**
const key: string = filePath ?? '__null__' * 渲染 Markdown — 使用 MetonaEditor 内置解析器。
*
if (processorCache.has(key)) { * @param content Markdown 源码
const cached = processorCache.get(key)! * @param filePath 文件路径(用于修复相对路径图片;null 时不做图片修复)
processorCache.delete(key) */
processorCache.set(key, cached) export function renderMarkdownSync(content: string, filePath?: string | null): string {
return cached
}
const processor = buildProcessor(filePath)
if (processorCache.size >= MAX_CACHE_SIZE) {
const oldestKey = processorCache.keys().next().value
if (oldestKey !== undefined) {
processorCache.delete(oldestKey)
}
}
processorCache.set(key, processor)
return processor
}
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try { try {
const processor = getCachedProcessor(filePath ?? null) const html = parseMarkdown(content)
const result = await processor.process(content) return fixImageSrcs(html, filePath ?? null)
return String(result)
} catch (e) { } catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e) const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>` return `<p style="color:red">渲染错误: ${errorMsg}</p>`
} }
} }
/**
* 异步版本 — 内置解析器为同步实现,此函数仅保持兼容签名。
*/
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
return renderMarkdownSync(content, filePath)
}
+56
View File
@@ -0,0 +1,56 @@
import MeToast from '@metona-team/metona-toast'
import { logError } from './errorHandler'
/**
* MeToast 全局配置
*
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
*
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
* v0.6.0: 接入 dedupe 去重插件与 onError 全局错误回调。
*/
MeToast.configure({
position: 'top-right',
duration: 3000,
max: 5,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: 'zh-CN',
width: 360,
// v0.6.0: 钩子/定时器异常统一走应用错误日志
onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
})
// 安装内置插件
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
MeToast.use('keyboard') // ESC 关闭所有 Toast
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
/** 与原 showToast 保持兼容的类型子集 */
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/**
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
*
* @param msg 消息内容
* @param type 通知类型(默认 info)
* @param duration 显示时长(ms),省略则用全局默认值
*/
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
}
/** 同步 MeToast 主题到当前暗色模式 */
export function syncToastTheme(darkMode: boolean): void {
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
}
export { MeToast }
+1 -1
View File
@@ -8,5 +8,5 @@ import './styles/markdown-body.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
) )
@@ -5,7 +5,7 @@ describe('editorStore', () => {
beforeEach(() => { beforeEach(() => {
useEditorStore.setState({ useEditorStore.setState({
viewMode: 'editor', viewMode: 'editor',
darkMode: false, themeMode: 'light',
externallyModified: null, externallyModified: null,
loadingStates: {}, loadingStates: {},
}) })
@@ -24,29 +24,36 @@ describe('editorStore', () => {
}) })
}) })
describe('setDarkMode', () => { describe('themeMode', () => {
it('should enable dark mode', () => { it('should default to light', () => {
useEditorStore.getState().setDarkMode(true) expect(useEditorStore.getState().themeMode).toBe('light')
expect(useEditorStore.getState().darkMode).toBe(true)
}) })
it('should disable dark mode', () => { it('should set theme mode', () => {
useEditorStore.setState({ darkMode: true }) useEditorStore.getState().setThemeMode('dark')
useEditorStore.getState().setDarkMode(false) expect(useEditorStore.getState().themeMode).toBe('dark')
expect(useEditorStore.getState().darkMode).toBe(false) })
it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm')
}) })
}) })
describe('toggleDarkMode', () => { describe('cycleTheme', () => {
it('should toggle from false to true', () => { it('should cycle light → dark', () => {
useEditorStore.getState().toggleDarkMode() expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().darkMode).toBe(true) expect(useEditorStore.getState().themeMode).toBe('dark')
}) })
it('should toggle from true to false', () => { it('should cycle dark → warm', () => {
useEditorStore.setState({ darkMode: true }) useEditorStore.setState({ themeMode: 'dark' })
useEditorStore.getState().toggleDarkMode() expect(useEditorStore.getState().cycleTheme()).toBe('warm')
expect(useEditorStore.getState().darkMode).toBe(false) })
it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light')
}) })
}) })
@@ -93,4 +100,17 @@ describe('editorStore', () => {
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true) expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
}) })
}) })
describe('editor live state (v0.6.0)', () => {
beforeEach(() => {
useEditorStore.setState({ zenMode: false })
})
it('should set zen mode', () => {
useEditorStore.getState().setZenMode(true)
expect(useEditorStore.getState().zenMode).toBe(true)
useEditorStore.getState().setZenMode(false)
expect(useEditorStore.getState().zenMode).toBe(false)
})
})
}) })
+2 -2
View File
@@ -17,8 +17,8 @@ interface AutoSaveStore extends AutoSaveState {
setState: (partial: Partial<AutoSaveState>) => void setState: (partial: Partial<AutoSaveState>) => void
} }
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({ export const useAutoSaveStore = create<AutoSaveStore>(set => ({
isAutoSaving: false, isAutoSaving: false,
autoSaveEnabled: true, autoSaveEnabled: true,
setState: (partial) => set(partial) setState: partial => set(partial),
})) }))
+31 -17
View File
@@ -1,48 +1,62 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { EditorView } from '@milkdown/prose/view' import type { ViewMode, ThemeMode } from '../types/settings'
import type { ViewMode } 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 // Used by OutlinePanel for heading navigation
let _getView: (() => EditorView | null) | null = null let _getEditor: (() => MarkdownEditor | null) | null = null
export function setEditorViewGetter(fn: () => EditorView | null) { export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getView = fn _getEditor = fn
} }
export function getEditorView(): EditorView | null { export function getMetonaEditor(): MarkdownEditor | null {
return _getView ? _getView() : null return _getEditor ? _getEditor() : null
} }
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
interface EditorState { interface EditorState {
viewMode: ViewMode viewMode: ViewMode
darkMode: boolean themeMode: ThemeMode
// AR-03: 外部修改检测状态,替代 DOM CustomEvent // AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null externallyModified: { filePath: string } | null
// UX-02: 全局加载状态 // UX-02: 全局加载状态
loadingStates: Record<string, boolean> loadingStates: Record<string, boolean>
// v0.6.0: Zen 模式状态(Toolbar 消费)
zenMode: boolean
setViewMode: (mode: ViewMode) => void setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void setThemeMode: (mode: ThemeMode) => void
toggleDarkMode: () => void cycleTheme: () => ThemeMode
setExternallyModified: (info: { filePath: string } | null) => void setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理 // UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean isLoading: (key: string) => boolean
setZenMode: (zen: boolean) => void
} }
export const useEditorStore = create<EditorState>((set, get) => ({ export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor', viewMode: 'editor',
darkMode: false, themeMode: 'light',
externallyModified: null, externallyModified: null,
loadingStates: {}, loadingStates: {},
zenMode: false,
setViewMode: (mode: ViewMode) => set({ viewMode: mode }), setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setDarkMode: (dark: boolean) => set({ darkMode: dark }), setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })), 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 }), setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) => set(state => ({ setLoading: (key: string, loading: boolean) =>
loadingStates: { ...state.loadingStates, [key]: loading } set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading },
})), })),
isLoading: (key: string) => get().loadingStates[key] ?? false isLoading: (key: string) => get().loadingStates[key] ?? false,
setZenMode: (zenMode: boolean) => set({ zenMode }),
})) }))
+4 -6
View File
@@ -18,7 +18,7 @@ interface SidebarState {
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
} }
export const useSidebarStore = create<SidebarState>((set) => ({ export const useSidebarStore = create<SidebarState>(set => ({
isVisible: true, isVisible: true,
rootPath: null, rootPath: null,
tree: [], tree: [],
@@ -36,17 +36,15 @@ export const useSidebarStore = create<SidebarState>((set) => ({
set(state => ({ set(state => ({
expandedDirs: state.expandedDirs.includes(path) expandedDirs: state.expandedDirs.includes(path)
? state.expandedDirs.filter(p => p !== path) ? state.expandedDirs.filter(p => p !== path)
: [...state.expandedDirs, path] : [...state.expandedDirs, path],
})), })),
expandDirs: (paths: string[]) => expandDirs: (paths: string[]) =>
set(state => { set(state => {
const newDirs = paths.filter(p => !state.expandedDirs.includes(p)) const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
return newDirs.length > 0 return newDirs.length > 0 ? { expandedDirs: [...state.expandedDirs, ...newDirs] } : state
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
: state
}), }),
setSidebarWidth: (width: number) => { setSidebarWidth: (width: number) => {
set({ sidebarWidth: width }) set({ sidebarWidth: width })
settingsRepository.save({ sidebarWidth: width }) settingsRepository.save({ sidebarWidth: width })
} },
})) }))
-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 })
}
}))
+27 -20
View File
@@ -4,7 +4,10 @@ import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository' import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数 // PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } { function debounce<F extends (...args: unknown[]) => void>(
fn: F,
delay: number,
): F & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: unknown[]) => { const debounced = ((...args: unknown[]) => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
@@ -38,13 +41,16 @@ interface TabState {
updateTabContent: (tabId: string, content: string) => void updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null getActiveTab: () => Tab | null
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void updateTabScroll: (
tabId: string,
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => void
loadFromDB: () => Promise<void> loadFromDB: () => Promise<void>
saveToDB: () => Promise<void> saveToDB: () => Promise<void>
} }
// PF-08: 模块级防抖保存函数(500ms延迟) // PF-08: 模块级防抖保存函数(500ms延迟)
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null let _debouncedSaveToDB: ((() => void) & { cancel: () => void }) | null = null
function getActualSaveToDB(get: () => TabState) { function getActualSaveToDB(get: () => TabState) {
return async () => { return async () => {
@@ -61,7 +67,7 @@ function getActualSaveToDB(get: () => TabState) {
scrollTop: t.scrollTop, scrollTop: t.scrollTop,
selectionStart: t.selectionStart, selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd, selectionEnd: t.selectionEnd,
updatedAt: Date.now() updatedAt: Date.now(),
})) }))
await tabRepository.saveAll(snapshots) await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId) await tabRepository.saveActiveTabId(get().activeTabId)
@@ -83,7 +89,7 @@ export const useTabStore = create<TabState>((set, get) => {
try { try {
const [snapshots, savedActiveTabId] = await Promise.all([ const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(), tabRepository.loadAll(),
tabRepository.loadActiveTabId() tabRepository.loadActiveTabId(),
]) ])
if (snapshots.length > 0) { if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({ const tabs: Tab[] = snapshots.map(s => ({
@@ -93,9 +99,10 @@ export const useTabStore = create<TabState>((set, get) => {
isModified: s.isModified, isModified: s.isModified,
scrollTop: s.scrollTop, scrollTop: s.scrollTop,
selectionStart: s.selectionStart, selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd selectionEnd: s.selectionEnd,
})) }))
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) const activeTabId =
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
? savedActiveTabId ? savedActiveTabId
: tabs[tabs.length - 1].id : tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true }) set({ tabs, activeTabId, _loaded: true })
@@ -131,12 +138,12 @@ export const useTabStore = create<TabState>((set, get) => {
isModified: false, isModified: false,
scrollTop: 0, scrollTop: 0,
selectionStart: 0, selectionStart: 0,
selectionEnd: 0 selectionEnd: 0,
} }
set(state => ({ set(state => ({
tabs: [...state.tabs, tab], tabs: [...state.tabs, tab],
activeTabId: tab.id activeTabId: tab.id,
})) }))
_debouncedSaveToDB?.() _debouncedSaveToDB?.()
@@ -192,13 +199,14 @@ export const useTabStore = create<TabState>((set, get) => {
const index = state.tabs.findIndex(t => t.id === tabId) const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1) const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) const newActiveId =
state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId ? state.activeTabId
: tabId : tabId
return { return {
tabs: newTabs, tabs: newTabs,
activeTabId: newActiveId, activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)),
} }
}) })
_debouncedSaveToDB?.() _debouncedSaveToDB?.()
@@ -237,15 +245,13 @@ export const useTabStore = create<TabState>((set, get) => {
// A3: 内容未变时不变更对象引用(避免误标记 isModified // A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t if (t.content === content) return t
return { ...t, content, isModified: true } return { ...t, content, isModified: true }
}) }),
})) }))
}, },
setModified: (tabId: string, modified: boolean) => { setModified: (tabId: string, modified: boolean) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t => (t.id === tabId ? { ...t, isModified: modified } : t)),
t.id === tabId ? { ...t, isModified: modified } : t
)
})) }))
}, },
@@ -254,13 +260,14 @@ export const useTabStore = create<TabState>((set, get) => {
return tabs.find(t => t.id === activeTabId) ?? null return tabs.find(t => t.id === activeTabId) ?? null
}, },
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => { updateTabScroll: (
tabId: string,
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t => (t.id === tabId ? { ...t, ...scroll } : t)),
t.id === tabId ? { ...t, ...scroll } : t
)
})) }))
} },
} }
}) })
+19 -759
View File
@@ -121,234 +121,27 @@ body {
overflow: hidden; overflow: hidden;
} }
/* Editor Toolbar */ /* MetonaEditor Wrapper */
.editor-toolbar { .metona-editor-wrapper {
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 {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
} }
.milkdown-wrapper .milkdown { /* FIX: Allow text selection in the preview pane.
height: 100%; global.css sets user-select:none on html/body to prevent
font-family: var(--font-mono); selection in UI chrome (toolbar, tabs, sidebar), but this
font-size: 13px; also blocked mouse-drag selection in the Markdown preview.
color: var(--text); Override back to the browser default for the preview area. */
background: var(--bg); .metona-editor-wrapper .me-preview,
} .metona-editor-wrapper .me-preview * {
.milkdown-wrapper .milkdown .editor {
overflow: auto;
height: 100%;
padding: 12px 16px;
outline: none;
user-select: text; user-select: text;
-webkit-user-select: text;
line-height: 1.7;
} }
.milkdown-wrapper .milkdown .editor p { /* Also restore selection in the editor textarea (MetonaEditor
margin: 0.5em 0; explicitly sets user-select:none on it in some themes) */
} .metona-editor-wrapper textarea {
.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; 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 */
@@ -388,14 +181,6 @@ body {
background: var(--text-secondary); background: var(--text-secondary);
} }
#tab-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
#tab-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
#tab-list::-webkit-scrollbar-track { #tab-list::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
@@ -579,53 +364,21 @@ body {
color: #ffd54f; color: #ffd54f;
} }
/* Status Bar */ /* Toolbar auto-save button */
#statusbar { .toolbar-autosave {
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;
font-size: 11px; font-size: 11px;
font-weight: 500;
color: var(--text-tertiary); color: var(--text-tertiary);
transition: color 0.2s ease; transition: color 0.2s ease;
border: none; padding: 4px 10px;
background: transparent;
cursor: pointer;
font-family: var(--font-ui);
padding: 0;
} }
.status-auto-save:hover { .toolbar-autosave:hover {
color: var(--primary); color: var(--primary);
} }
.status-auto-save.saving { .toolbar-autosave.saving {
color: var(--primary, #1a73e8); color: var(--primary);
animation: auto-save-pulse 1s ease-in-out infinite; animation: auto-save-pulse 1s ease-in-out infinite;
} }
@@ -998,43 +751,6 @@ body {
background: var(--primary); background: var(--primary);
} }
/* View Modes */
#app.mode-preview #editor-panel {
display: none;
}
#app.mode-editor #preview-panel {
display: none;
}
/* Toast */
#toast-notification {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--text);
color: var(--bg);
padding: 8px 20px;
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition:
opacity 0.3s ease,
transform 0.3s ease;
z-index: 9999;
max-width: 480px;
text-align: center;
}
#toast-notification.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -1190,319 +906,6 @@ body {
background: var(--primary-dark); background: var(--primary-dark);
} }
/* ===== UX-01: ConfirmDialog ===== */
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
animation: fadeIn 0.15s ease;
}
.confirm-dialog {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 380px;
max-width: 90vw;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.confirm-header {
padding: 20px 24px 0;
}
.confirm-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.confirm-header.confirm-danger h3 {
color: #d93025;
}
.confirm-header.confirm-warning h3 {
color: #e37400;
}
:root.dark .confirm-header.confirm-warning h3 {
color: #fdd663;
}
.confirm-body {
padding: 12px 24px 20px;
}
.confirm-body p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
}
.confirm-btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg);
color: var(--text);
}
.confirm-btn-cancel:hover {
background: var(--bg-tertiary);
}
.confirm-btn-danger {
background: #d93025;
border-color: #d93025;
color: white;
}
.confirm-btn-danger:hover {
background: #b5271d;
}
.confirm-btn-warning {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-warning:hover {
background: var(--primary-dark);
}
.confirm-btn-info {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-info:hover {
background: var(--primary-dark);
}
.confirm-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ===== UX-02: LoadingSpinner ===== */
.loading-spinner {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
.loading-spinner-label {
font-size: 13px;
font-family: var(--font-ui);
color: var(--text-secondary);
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
:root.dark .loading-overlay {
background: rgba(30, 30, 30, 0.8);
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--text-tertiary);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 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-05: Toast Container (升级版) ===== */
.toast-container {
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
pointer-events: none;
max-width: 480px;
width: 100%;
padding: 0 16px;
}
.toast-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
font-family: var(--font-ui);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateY(10px) scale(0.95);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
max-width: 100%;
}
.toast-item.toast-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Toast 类型样式 */
.toast-success {
background: #e6f4ea;
color: #137333;
border: 1px solid #a8dab5;
}
.toast-error {
background: #fce8e6;
color: #c5221f;
border: 1px solid #f28b82;
}
.toast-warning {
background: #fef7e0;
color: #945700;
border: 1px solid #fdd663;
}
.toast-info {
background: var(--text);
color: var(--bg);
border: 1px solid transparent;
}
:root.dark .toast-success {
background: #0d3a1a;
color: #81c995;
border-color: #1e5a2e;
}
:root.dark .toast-error {
background: #3c1214;
color: #f28b82;
border-color: #5c1a1a;
}
:root.dark .toast-warning {
background: #3a3000;
color: #fdd663;
border-color: #5a4a00;
}
:root.dark .toast-info {
background: var(--bg-secondary);
color: var(--text);
border-color: var(--border);
}
.toast-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.toast-message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toast-close {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 12px;
border-radius: 4px;
padding: 0;
transition: opacity 0.15s ease;
}
.toast-close:hover {
opacity: 1;
}
.toast-close:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
}
/* ===== UX-07: 通用可访问性增强 ===== */ /* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */ /* Focus visible 为所有交互元素提供清晰的焦点指示 */
@@ -1589,149 +992,6 @@ button:focus-visible,
background: var(--primary-dark); 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) ===== */ /* ===== Outline Panel (Table of Contents) ===== */
.sidebar-outline-section { .sidebar-outline-section {
+45 -16
View File
@@ -7,8 +7,12 @@
word-wrap: break-word; word-wrap: break-word;
} }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h1,
.markdown-body h4, .markdown-body h5, .markdown-body h6 { .markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px; margin-top: 24px;
margin-bottom: 16px; margin-bottom: 16px;
font-weight: 600; font-weight: 600;
@@ -28,10 +32,19 @@
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
} }
.markdown-body h3 { font-size: 1.25em; } .markdown-body h3 {
.markdown-body h4 { font-size: 1em; } font-size: 1.25em;
.markdown-body h5 { font-size: 0.875em; } }
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); } .markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
font-size: 0.85em;
color: var(--text-secondary);
}
.markdown-body p { .markdown-body p {
margin-top: 0; margin-top: 0;
@@ -44,8 +57,12 @@
cursor: pointer; cursor: pointer;
} }
.markdown-body a:hover { text-decoration: underline; } .markdown-body a:hover {
.markdown-body strong { font-weight: 600; } text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body img { .markdown-body img {
max-width: 100%; max-width: 100%;
@@ -71,16 +88,23 @@
border-radius: 0 var(--radius) var(--radius) 0; border-radius: 0 var(--radius) var(--radius) 0;
} }
.markdown-body blockquote p:last-child { margin-bottom: 0; } .markdown-body blockquote p:last-child {
margin-bottom: 0;
}
.markdown-body ul, .markdown-body ol { .markdown-body ul,
.markdown-body ol {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
padding-left: 2em; padding-left: 2em;
} }
.markdown-body li { margin-top: 4px; } .markdown-body li {
.markdown-body li + li { margin-top: 4px; } margin-top: 4px;
}
.markdown-body li + li {
margin-top: 4px;
}
.markdown-body code { .markdown-body code {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -91,7 +115,9 @@
color: #e83e8c; color: #e83e8c;
} }
:root.dark .markdown-body code { color: #f48fb1; } :root.dark .markdown-body code {
color: #f48fb1;
}
.markdown-body pre { .markdown-body pre {
margin-top: 0; margin-top: 0;
@@ -119,7 +145,8 @@
display: block; display: block;
} }
.markdown-body table th, .markdown-body table td { .markdown-body table th,
.markdown-body table td {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
text-align: left; text-align: left;
@@ -130,9 +157,11 @@
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); } .markdown-body table tr:nth-child(even) {
background: var(--bg-secondary);
}
.markdown-body input[type="checkbox"] { .markdown-body input[type='checkbox'] {
margin-right: 6px; margin-right: 6px;
accent-color: var(--primary); accent-color: var(--primary);
} }
+2 -19
View File
@@ -23,23 +23,6 @@
--sidebar-active: var(--primary-light); --sidebar-active: var(--primary-light);
--search-bg: var(--bg-secondary); --search-bg: var(--bg-secondary);
--search-border: var(--border); --search-border: var(--border);
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --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; --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);
} }
+15 -1
View File
@@ -6,7 +6,7 @@ import type {
SaveAsPayload, SaveAsPayload,
ReloadFileResult, ReloadFileResult,
FileStatsResult, FileStatsResult,
ReadDirTreeResult ReadDirTreeResult,
} from '../../shared/types' } from '../../shared/types'
export interface IpcInvokeMap { export interface IpcInvokeMap {
@@ -24,6 +24,11 @@ export interface IpcInvokeMap {
'dir:openDialog': [void, string | null] 'dir:openDialog': [void, string | null]
'dir:watch': [string, void] 'dir:watch': [string, void]
'dir:unwatch': [void, void] 'dir:unwatch': [void, void]
'data:export': [
string,
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
]
'data:import': [void, { success: boolean; canceled?: boolean; content?: string; error?: string }]
} }
export type Unsubscribe = () => void export type Unsubscribe = () => void
@@ -44,6 +49,15 @@ export interface ElectronAPI {
openFolderDialog: () => Promise<string | null> openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void> watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void> unwatchDir: () => Promise<void>
exportData: (
content: string,
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
importData: () => Promise<{
success: boolean
canceled?: boolean
content?: string
error?: string
}>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe onDirChanged: (callback: () => void) => Unsubscribe
+4 -3
View File
@@ -1,15 +1,16 @@
export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source' export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings { export interface Settings {
darkMode: boolean themeMode: ThemeMode
viewMode: ViewMode viewMode: ViewMode
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
darkMode: false, themeMode: 'light',
viewMode: 'editor', viewMode: 'editor',
sidebarCollapsed: false, sidebarCollapsed: false,
sidebarWidth: 240 sidebarWidth: 240,
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11 -3
View File
@@ -1,8 +1,16 @@
// 共享常量 — 主进程和渲染进程共用 // 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.12' export const APP_VERSION = 'v0.6.1'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([ export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out', 'node_modules',
'.next', '.nuxt', '__pycache__', '.DS_Store' '.git',
'.svn',
'.hg',
'dist',
'out',
'.next',
'.nuxt',
'__pycache__',
'.DS_Store',
]) ])
+5 -1
View File
@@ -16,11 +16,15 @@ export const IPC_CHANNELS = {
DIR_WATCH: 'dir:watch', DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch', DIR_UNWATCH: 'dir:unwatch',
// v0.6.0: 数据备份导出/导入(JSON
DATA_EXPORT: 'data:export',
DATA_IMPORT: 'data:import',
// 主进程 → 渲染进程 (send) // 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab', FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified', FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose', WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged' SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged',
} as const } as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS] export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]