19 Commits
Author SHA1 Message Date
thzxx 073978d3ca feat: v0.6.2 — sqlark 0.7.4 升级(KVStore) + 14 项缺陷修复 + 多文件搜索
- chore: 升级 @metona-team/metona-sqlark 0.4.4 → 0.7.4,存储后端迁移
  KVStore 引擎(内存索引+快照/日志,OPFS 落盘,jsdom 回退 memory),
  启动时自动删除旧 IndexedDB 库(aria-MarkLiteV2 / Dexie MarkLite),
  不做向下兼容
- fix: 保存竞态 — 快照比对后才清 isModified,防止保存期间的新输入被
  误清标记导致永不落盘(自动保存与手动保存均修复)
- fix: 另存为后标签重绑新路径(此前 Ctrl+S/自动保存仍写回旧文件),
  并同步最近文件与快照持久化
- fix: tabSwitched 只依赖活动文件路径(此前每次击键都触发 IPC 并
  重启主进程文件 watcher)
- fix: 主进程关闭兜底超时 5s→12s(长于 confirm 10s,防强杀丢编辑)
- fix: 外部修改检测覆盖非活动标签,banner 显示文件名,
  标签有未保存修改时显式提示不再静默
- fix: 导入备份后 flush + closeDatabase 再 reload(防 OPFS 未落盘丢数据),
  导入整体事务原子化(防半导入状态)
- fix: 文档大纲跳过代码块内标题;切换标签恢复光标位置
- fix: 首次启动主题跟随系统偏好(settings 无记录时 load 返回 null)
- fix: watcher error 恢复时重置 isSelfWriting,防外部修改通知被永久吞掉
- fix: 打开失败路径(最近文件/文件树/打开对话框)显式提示
- fix: 保存时保留原文件编码(UTF-16 LE/BE BOM 同编码写回)
- feat: 多文件搜索 — dir:search IPC + SearchPanel 弹层 + Ctrl+Shift+F,
  文件夹内递归搜索(大小写/正则,结果定位到行)
- feat: 标签恢复时与磁盘 mtime 比对,自动同步磁盘最新内容
- test: 新增 outlineUtils(6) / searchInDir(7) / updateTabFilePath(2) 测试
- docs: README/DESIGN 同步 kv 后端与版本号 v0.6.2
2026-08-15 21:18:05 +08:00
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
76 changed files with 6105 additions and 4712 deletions
+41 -49
View File
@@ -2,13 +2,13 @@
## 1. 项目概述 ## 1. 项目概述
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、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. 技术架构
@@ -22,9 +22,9 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 类型系统 | TypeScript | v5.6 | 全量类型安全 | | 类型系统 | TypeScript | v5.6 | 全量类型安全 |
| 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 | | 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
| 状态管理 | Zustand | v5 | 轻量级状态管理 | | 状态管理 | Zustand | v5 | 轻量级状态管理 |
| 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件(AriaEngine | | 持久化 | MetonaSqlark | v0.7.4 | 标签页状态 / 用户设置 / 最近文件(AriaEngine + KVStoreOPFS 落盘;v0.6.2 起不再使用 IndexedDB,旧库启动时自动删除 |
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) | | 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 | 通知提示组件 | | 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 安装包 |
@@ -86,7 +86,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | | Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 | | 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()` | 防止路径遍历攻击 |
@@ -162,11 +162,13 @@ interface SidebarState {
} }
``` ```
## 4. 数据持久化 — IndexedDB ## 4. 数据持久化 — MetonaSqlark (KVStore)
v0.5.0: 由 Dexie.js 迁移至 MetonaSqlarkAriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。 v0.5.0: 由 Dexie.js 迁移至 MetonaSqlarkAriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()), MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。 表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
v0.6.2: sqlark 升级 0.7.4,存储后端改用自研 KVStore(内存索引 + 快照/日志,OPFS 落盘);
旧 IndexedDB 库在启动时自动删除,不做向下兼容。
### 4.1 数据库 Schema ### 4.1 数据库 Schema
@@ -175,11 +177,11 @@ MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
let dbPromise: Promise<MetonaSqlark> | null = null let dbPromise: Promise<MetonaSqlark> | null = null
export function getDb(): Promise<MetonaSqlark> { export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) { if (!dbPromise) {
dbPromise = MetonaSqlark.create({ dbPromise = create({
name: 'MarkLiteV2', name: 'MarkLiteV2',
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离 mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory diskEngine: 'kv', // v0.6.2: 自研 KVStore 后端(OPFS 落盘;jsdom 测试回退 memory
version: 1, version: 0, // AriaEngine 忽略版本号
}).then(async (db) => { }).then(async (db) => {
if (!(await db.getTableNames()).includes('tabSnapshots')) { if (!(await db.getTableNames()).includes('tabSnapshots')) {
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... }) await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
@@ -221,6 +223,7 @@ export function getDb(): Promise<MetonaSqlark> {
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 | | `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
| `dir:watch` | `dirPath` | `void` | 监听目录变化 | | `dir:watch` | `dirPath` | `void` | 监听目录变化 |
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 | | `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
| `dir:search` | `{ dirPath, query, caseSensitive?, useRegex? }` | `SearchInDirResult` | v0.6.2: 文件夹内多文件内容搜索 |
### 5.2 主进程 → 渲染进程(send) ### 5.2 主进程 → 渲染进程(send)
@@ -265,26 +268,27 @@ MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向
### 6.4 渲染管线集成 ### 6.4 渲染管线集成
通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现: v0.6.0: 移除 unified/remark/rehype 自研管线,改用 MetonaEditor **内置解析器**parseMarkdown),
通过 `render` 钩子接入,实现:
- **相对路径图片解析**将相对路径转换为 `file://` 绝对路径 - **相对路径图片解析**内置解析器的 safeUrl 会过滤 `file:` 协议,因此渲染后做 HTML 后处理,将相对路径图片 src 转换为 `file://` 绝对路径(越界路径保持原样)
- **XSS 防护**rehype-sanitize 过滤危险标签 - **XSS 防护**内置 escapeHTML + safeUrl(过滤 javascript:/vbscript:/file:/data:+ 属性转义
- **代码高亮**rehype-highlight 语法高亮 - **代码高亮**内置零依赖高亮器(`highlight: MeEditor.highlight`16 种语言)
- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶 - **Mermaid 图表**:内置解析器原生输出 `.me-mermaid` 容器,`mermaid.run()` 直接渲染
``` ```
Markdown 源码 Markdown 源码
unified 管线(renderMarkdownSync parseMarkdownMetonaEditor 内置解析器
├── remark-parse 解析为 MDAST ├── GFM / 任务列表 / 表格 / 删除线
├── remark-gfm GFM 扩展 ├── 脚注 / 数学公式 / 定义列表 / emoji
├── remark-rehype 转换为 HAST ├── 引用链接 / 自动链接 / 上下标
├── rehype-raw 解析内联 HTML ├── mermaid → <div class="me-mermaid">…
── rehype-sanitize 安全过滤 ── XSS 防护(escapeHTML + safeUrl
├── rehype-fixImages 相对路径 → file://
├── rehype-highlight 代码高亮
└── rehype-stringify 序列化为 HTML fixImageSrcsHTML 后处理:相对路径 → file://)
MetonaEditor 预览区渲染 MetonaEditor 预览区渲染
@@ -306,35 +310,24 @@ MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变
## 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
Renderer (MetonaEditor preview / Preview component)
``` ```
## 8. UI 设计 ## 8. UI 设计
@@ -415,12 +408,11 @@ npm run build:portable # 便携版(免安装)
|------|------|------| |------|------|------|
| react / react-dom | ^18.3 | UI 框架 | | react / react-dom | ^18.3 | UI 框架 |
| zustand | ^5.0 | 状态管理 | | zustand | ^5.0 | 状态管理 |
| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(IndexedDB | | @metona-team/metona-sqlark | 0.7.4 | 前端关系型数据库(AriaEngine + KVStoreOPFS |
| nanoid | ^5.0 | 唯一 ID 生成 | | nanoid | ^5.0 | 唯一 ID 生成 |
| @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(零依赖 | | @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(内置解析器 + 高亮 |
| @metona-team/metona-toast | 0.5.0 | Toast 通知组件 | | @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 | | mermaid | ^10.9 | Mermaid 图表渲染 |
| rehype-highlight | ^7.0 | 代码语法高亮 |
### 开发依赖 ### 开发依赖
+291 -288
View File
@@ -1,288 +1,291 @@
<p align="center"> <p align="center">
<img src="assets/icon.ico" alt="MarkLite" width="128" height="128"> <img src="assets/icon.ico" alt="MarkLite" width="128" height="128">
</p> </p>
<h1 align="center">MarkLite</h1> <h1 align="center">MarkLite</h1>
<p align="center"> <p align="center">
<strong>轻量级 Windows 本地 Markdown 编辑器</strong> <strong>轻量级 Windows 本地 Markdown 编辑器</strong>
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform"> <img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron"> <img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript"> <img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React"> <img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.5.0-orange?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/Version-v0.6.2-orange?style=flat-square" alt="Version">
</p> </p>
<p align="center"> <p align="center">
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br> 基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。
</p> </p>
--- ---
## ✨ 功能特性 ## ✨ 功能特性
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 | | ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置解析器渲染、格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、Zen 专注模式、粘贴图片转 base64 |
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 | | 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,MetonaEditor 内置解析器渲染 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🔤 **代码高亮** | MetonaEditor 内置零依赖高亮器(js / ts / python / bash / css / html / json 等 16 种语言) |
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 | | 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
| 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 | | 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 | | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
| 📁 **文件** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | | 🔎 **文件搜索** | Ctrl+Shift+F 在已打开文件夹内递归搜索内容,结果定位到行 |
| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 | | 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlarkAriaEngine)持久化,关闭后可恢复 | | 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | | 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlarkAriaEngine)持久化,关闭后可恢复 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | | 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable |
| 🖼 **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 | | **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 | | 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
| 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 | | 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 |
## 🚀 快速开始 | 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 |
### 环境要求 ## 🚀 快速开始
- **Node.js** >= 18.x ### 环境要求
- **npm** >= 9.x
- **Windows** 10/11 x64 - **Node.js** >= 18.x
- **npm** >= 9.x
### 安装与运行 - **Windows** 10/11 x64
```bash ### 安装与运行
# 克隆仓库
git clone https://git.metona.cn/MetonaTeam/MarkLite.git ```bash
cd MarkLite # 克隆仓库
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
# 安装依赖 cd MarkLite
npm install
# 安装依赖
# 启动开发模式(带 HMR 热更新) npm install
npm run dev
``` # 启动开发模式(带 HMR 热更新)
npm run dev
### 打包为 exe 安装包 ```
```bash ### 打包为 exe 安装包
# 打包 Windows x64 NSIS 安装包
npm run build ```bash
# 打包 Windows x64 NSIS 安装包
# 打包为便携版(免安装) npm run build
npm run build:portable
``` # 打包为便携版(免安装)
npm run build:portable
打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。 ```
### 其他命令 打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。
```bash ### 其他命令
# TypeScript 类型检查
npm run typecheck ```bash
# TypeScript 类型检查
# ESLint 代码检查 npm run typecheck
npm run lint
# ESLint 代码检查
# 运行单元测试 npm run lint
npm run test
# 运行单元测试
# 监听模式运行测试 npm run test
npm run test:watch
# 监听模式运行测试
# 生成测试覆盖率报告 npm run test:watch
npm run test:coverage
``` # 生成测试覆盖率报告
npm run test:coverage
## 🧪 测试 ```
项目使用 **Vitest** + **React Testing Library** 作为测试框架。 ## 🧪 测试
```bash 项目使用 **Vitest** + **React Testing Library** 作为测试框架。
# 运行全部测试
npm run test ```bash
# 运行全部测试
# 监听模式(开发时持续运行) npm run test
npm run test:watch
# 监听模式(开发时持续运行)
# 生成覆盖率报告 npm run test:watch
npm run test:coverage
``` # 生成覆盖率报告
npm run test:coverage
测试覆盖以下核心模块: ```
- `stores/` — Zustand 状态管理(tabStore, editorStore
- `lib/` — 工具库(fileUtils, markdown, errorHandler 测试覆盖以下核心模块:
- `hooks/`自定义 Hooks - `stores/`Zustand 状态管理(tabStore, editorStore
- `lib/` — 工具库(fileUtils, markdown, errorHandler
## ⌨️ 快捷键 - `hooks/` — 自定义 Hooks
| 快捷键 | 功能 | ## ⌨️ 快捷键
|:-------|:-----|
| `Ctrl + T` | 新建标签页 | | 快捷键 | 功能 |
| `Ctrl + W` | 关闭当前标签页 | |:-------|:-----|
| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) | | `Ctrl + T` | 新建标签页 |
| `Ctrl + Shift + Tab` | 切换到上一个标签页 | | `Ctrl + W` | 关闭当前标签页 |
| `Ctrl + O` | 打开文件 | | `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) |
| `Ctrl + S` | 保存文件 | | `Ctrl + Shift + Tab` | 切换到上一个标签页 |
| `Ctrl + Shift + S` | 另存为 | | `Ctrl + O` | 打开文件 |
| `Ctrl + F` | 搜索 | | `Ctrl + S` | 保存文件 |
| `Ctrl + H` | 搜索并替换 | | `Ctrl + Shift + S` | 另存为 |
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 | | `Ctrl + F` | 搜索 |
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) | | `Ctrl + H` | 搜索并替换 |
| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置 | | `Ctrl + Shift + F` | 多文件搜索(当前文件夹内 |
| `Ctrl + 1/2/3` | 切换模式(编辑器内置) | | `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
| `?` | 快捷键帮助面板(编辑器内置) | | `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) |
## 🛠️ 技术栈 | `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
| `?` | 快捷键帮助面板(编辑器内置) |
| 组件 | 技术 | 说明 |
|:-----|:-----|:-----| ## 🛠️ 技术栈
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | | 组件 | 技术 | 说明 |
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | |:-----|:-----|:-----|
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 浮动格式栏 + 插件系统 | | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | | 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.1 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 | | 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | | 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | | 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v0.5.0 | 通知提示组件 | | 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.7.4 (AriaEngine + KVStore/OPFS) | 标签页状态 & 用户设置持久化 |
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | | Markdown 解析 | MetonaEditor 内置解析器 | 零依赖:GFM / 脚注 / 数学公式 / Mermaid |
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | | 代码高亮 | MetonaEditor 内置高亮器 | 零依赖,16 种语言 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 | | 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-builder](https://www.electron.build/) | 生成 exe 安装包 |
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
```
MarkLite/ ## 📁 项目结构
├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
├── tsconfig.json # TypeScript 配置 ```
├── tsconfig.node.json # Node 端 TypeScript 配置 MarkLite/
├── electron.vite.config.ts # electron-vite 构建配置 ├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
├── vitest.config.ts # Vitest 测试框架配置 ├── tsconfig.json # TypeScript 配置
├── eslint.config.mjs # ESLint + TypeScript 规则 ├── tsconfig.node.json # Node 端 TypeScript 配置
├── CONTRIBUTING.md # 贡献指南 ├── electron.vite.config.ts # electron-vite 构建配置
├── vitest.config.ts # Vitest 测试框架配置
├── src/ ├── eslint.config.mjs # ESLint + TypeScript 规则
│ ├── main/ # 主进程 (Node.js) ├── CONTRIBUTING.md # 贡献指南
│ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册 ├── src/
│ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离 │ ├── main/ # 主进程 (Node.js)
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听) │ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
│ │ ── window-manager.ts # 窗口创建、关闭拦截、单实例锁 │ │ ── ipc-handlers.ts # 所有 ipcMain.handle 注册
│ │ │ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离
│ ├── preload/ # 预加载脚本 │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听)
│ │ └── index.ts # contextBridge 类型安全暴露 │ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁
│ │ │ │
│ ├── renderer/ # 渲染进程 (React 18) │ ├── preload/ # 预加载脚本
│ │ ── index.html # 入口 HTML(含 CSP 策略) │ │ ── index.ts # contextBridge 类型安全暴露
│ │ ├── main.tsx # React 入口 │ │
│ ├── App.tsx # 根组件:布局编排、全局事件 │ ├── renderer/ # 渲染进程 (React 18)
│ │ │ │ ├── index.html # 入口 HTML(含 CSP 策略)
│ │ ├── components/ # UI 组件 │ │ ├── main.tsx # React 入口
│ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于) │ │ ├── App.tsx # 根组件:布局编排、全局事件
│ │ │ ├── TabBar/ # 标签页栏(含拖拽排序、右键菜单) │ │ │
│ │ │ ├── Editor/ # MetonaEditor 编辑器集成 │ │ ├── components/ # UI 组件
│ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲 │ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于)
│ │ │ ├── FileTree/ # 递归文件树 │ │ │ ├── TabBar/ # 标签页栏(含拖拽排序、右键菜单)
│ │ │ ├── OutlinePanel/ # 文档大纲(活跃标题高亮) │ │ │ ├── Editor/ # MetonaEditor 编辑器集成
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件) │ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲
│ │ │ ├── ConfirmDialog/ # 确认对话框 │ │ │ ├── FileTree/ # 递归文件树
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示 │ │ │ ├── OutlinePanel/ # 文档大纲(活跃标题高亮)
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层 │ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件)
│ │ │ ├── ErrorBoundary/ # 错误边界 │ │ │ ├── ConfirmDialog/ # 确认对话框
│ │ │ ├── AboutDialog/ # 关于对话框 │ │ │ ├── ModifiedBanner/ # 文件外部修改提示
│ │ │ ├── LoadingSpinner/ # 加载指示器 │ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
│ │ │ ── Icons.tsx # SVG 图标库 │ │ │ ── ErrorBoundary/ # 错误边界
│ │ │ │ │ │ ├── AboutDialog/ # 关于对话框
│ │ ├── stores/ # Zustand 状态管理 │ │ │ ├── LoadingSpinner/ # 加载指示器
│ │ │ ── tabStore.ts # 标签页状态 │ │ │ ── Icons.tsx # SVG 图标库
│ │ │ ├── editorStore.ts # 编辑器状态 │ │ │
│ │ ├── sidebarStore.ts # 侧边栏状态 │ │ ├── stores/ # Zustand 状态管理
│ │ │ ── autoSaveStore.ts # 自动保存状态 │ │ │ ── tabStore.ts # 标签页状态
│ │ │ │ │ │ ├── editorStore.ts # 编辑器状态
│ │ ├── hooks/ # 自定义 Hooks │ │ │ ├── sidebarStore.ts # 侧边栏状态
│ │ │ ── useTheme.ts # 暗色/亮色主题 │ │ │ ── autoSaveStore.ts # 自动保存状态
│ │ │ ├── useSettingsInit.ts # 设置初始加载 │ │ │
│ │ │ ├── useKeyboard.ts # 全局快捷键 │ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useDragDrop.ts # 拖拽打开 │ │ │ ├── useTheme.ts # 暗色/亮色主题
│ │ │ ├── useFileWatch.ts # 外部修改监听 │ │ │ ├── useSettingsInit.ts # 设置初始加载
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒 │ │ │ ├── useKeyboard.ts # 全局快捷键
│ │ │ ├── useAutoSave.ts # 自动保存(Electron IPC │ │ │ ├── useDragDrop.ts # 拖拽打开
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录 │ │ │ ├── useFileWatch.ts # 外部修改监听
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪 │ │ │ ├── useUnsavedWarning.ts # 未保存提醒
│ │ │ ├── useDocStats.ts # 文档统计 │ │ │ ├── useAutoSave.ts # 自动保存(Electron IPC
│ │ │ ── ... # 文件操作、IPC监听等 │ │ │ ── useAutoExpandDir.ts # 自动展开目录
│ │ │ │ │ │ ├── useActiveHeading.ts # 活跃标题追踪
│ │ ├── lib/ # 工具库 │ │ │ ├── useDocStats.ts # 文档统计
│ │ │ ── markdown.ts # Markdown 渲染管线(unified/rehype │ │ │ ── ... # 文件操作、IPC监听等
│ │ │ ├── fileUtils.ts # 文件工具函数 │ │ │
│ │ │ ├── errorHandler.ts # 错误处理 │ │ ├── lib/ # 工具库
│ │ │ ├── toast.ts # Toast 通知(MetonaToast │ │ │ ├── markdown.ts # Markdown 渲染管线(unified/rehype
│ │ │ ── constants.ts # 常量定义 │ │ │ ── fileUtils.ts # 文件工具函数
│ │ │ │ │ │ ├── errorHandler.ts # 错误处理
│ │ ├── db/ # MetonaSqlark 持久化层 │ │ │ ├── toast.ts # Toast 通知(MetonaToast
│ │ │ ── schema.ts # 数据库定义(懒初始化单例) │ │ │ ── constants.ts # 常量定义
│ │ │ ├── tabRepository.ts # 标签页 CRUD │ │ │
│ │ │ ├── settingsRepository.ts # 设置 CRUD │ │ ├── db/ # MetonaSqlark 持久化层
│ │ │ ── recentFilesRepository.ts # 最近文件 │ │ │ ── schema.ts # 数据库定义(懒初始化单例)
│ │ │ │ │ │ ├── tabRepository.ts # 标签页 CRUD
│ │ ├── types/ # TypeScript 类型 │ │ │ ├── settingsRepository.ts # 设置 CRUD
│ │ └── styles/ # 全局样式 │ │ │ └── recentFilesRepository.ts # 最近文件
│ │ │ │
└── shared/ # 主进程/渲染进程共享 │ ├── types/ # TypeScript 类型
── ipc-channels.ts # IPC 通道名常量 ── styles/ # 全局样式
├── types.ts # 共享类型定义
└── constants.ts # 共享常量(版本号等) └── shared/ # 主进程/渲染进程共享
├── ipc-channels.ts # IPC 通道名常量
├── assets/ │ ├── types.ts # 共享类型定义
│ └── icon.ico # 应用图标 └── constants.ts # 共享常量(版本号等)
├── DESIGN.md # 架构设计文档
├── DEVSETUP.md # 开发环境配置指南 ├── assets/
├── LICENSE # MIT 许可证 │ └── icon.ico # 应用图标
── README.md # 本文件 ── DESIGN.md # 架构设计文档
``` ├── DEVSETUP.md # 开发环境配置指南
├── LICENSE # MIT 许可证
## 📝 支持的 Markdown 语法 └── README.md # 本文件
```
- ✅ 标题(h1 ~ h6
-**粗体** / *斜体* / ~~删除线~~ ## 📝 支持的 Markdown 语法
- ✅ 高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
-有序列表 / 无序列表 -标题(h1 ~ h6
-任务列表 `- [x]` -**粗体** / *斜体* / ~~删除线~~
-代码块(围栏式 + 语法高亮,180+ 语言) -高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
-行内代码 -有序列表 / 无序列表
-链接 / 图片(支持相对路径、粘贴图片) -任务列表 `- [x]`
-表格(含列对齐 -代码块(围栏式 + 语法高亮,180+ 语言
-引用块 -行内代码
-水平线 -链接 / 图片(支持相对路径、粘贴图片)
-Emoji 短码 `:smile:` `:rocket:` -表格(含列对齐)
-Mermaid 图表 ` ```mermaid ` -引用块
-HTML 内联元素 -水平线
-GFMGitHub Flavored Markdown -Emoji 短码 `:smile:` `:rocket:`
- ✅ Mermaid 图表 ` ```mermaid `
## 🔧 Git Hooks - ✅ HTML 内联元素
- ✅ GFMGitHub Flavored Markdown
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
## 🔧 Git Hooks
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
```bash
# 初始化 git hooksnpm install 时自动执行) - **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
npm run prepare
``` ```bash
# 初始化 git hooksnpm install 时自动执行)
## 📄 许可证 npm run prepare
```
[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
## 📄 许可证
---
[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
<p align="center">
如果觉得有用,请点个 ⭐ Star 支持一下! ---
</p>
<p align="center">
如果觉得有用,请点个 ⭐ Star 支持一下!
</p>
+13 -10
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <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>"/> <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.0 — 全功能演示</title> <title>MetonaEditor v0.4.1 — 全功能演示</title>
<style> <style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::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} :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}
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<body> <body>
<header class="header"> <header class="header">
<h1><span class="grad">Metona</span>Editor v0.4.0</h1> <h1><span class="grad">Metona</span>Editor v0.4.1</h1>
<div class="badges"> <div class="badges">
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">826 tests</span> <span class="badge">841 tests</span>
<span class="badge">6 个插件</span> <span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
</div> </div>
@@ -85,7 +85,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<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>零依赖 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>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>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</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>快捷键面板</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>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>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
@@ -93,7 +93,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
</div> </div>
<footer class="footer"> <footer class="footer">
MetonaEditor v0.4.0 · 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 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> </footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -101,13 +101,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<script> <script>
(function(){ (function(){
var demoMd=[ var demoMd=[
'# 🚀 MetonaEditor v0.4.0 全功能演示', '# 🚀 MetonaEditor v0.4.1 全功能演示',
'', '',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,17 个源文件,826 个测试全部通过。', '> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。',
'', '',
'## ✨ v0.4.0 新特性', '## ✨ v0.4.1 新特性',
'', '',
'- **Zen 专注模式宽度可配置**`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽',
'- **修复 5 处问题**`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新',
'- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证', '- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证',
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言', '- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位', '- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
@@ -139,6 +141,7 @@ var demoMd=[
'', '',
'| 版本 | 日期 | 测试 | 主题 |', '| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: | --- |', '| :--- | :---: | ---: | --- |',
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |', '| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |', '| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |', '| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
@@ -200,8 +203,8 @@ var demoMd=[
'```', '```',
'', '',
'```bash', '```bash',
'# 安装与启动', '# 安装Gitea 私有源)',
'npm install @metona-team/metona-editor', 'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
'npm run dev', 'npm run dev',
'```', '```',
'', '',
+12 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <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>"/> <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.0 · 文档</title> <title>MetonaEditor v0.4.1 · 文档</title>
<style> <style>
*{box-sizing:border-box;margin:0;padding:0} *{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%)} :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%)}
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
<header class="docs-header"> <header class="docs-header">
<h1><span class="grad">API 文档</span></h1> <h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.4.0 · TypeScript · 完整配置、API 与使用指南</p> <p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、API 与使用指南</p>
</header> </header>
<div class="container"> <div class="container">
@@ -109,8 +109,11 @@ footer a{color:var(--accent);text-decoration:none}
<!-- 快速入门 --> <!-- 快速入门 -->
<section id="quickstart"> <section id="quickstart">
<h2>安装与引入</h2> <h2>安装与引入</h2>
<pre><span class="c-com"># npm 安装</span> <pre><span class="c-com"># npm 安装Gitea 私有源)</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <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-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">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
@@ -146,6 +149,8 @@ footer a{color:var(--accent);text-decoration:none}
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> 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> 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> 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> 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> floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
@@ -252,6 +257,8 @@ editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class=
<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> <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">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">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">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> 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>
@@ -487,7 +494,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer> <footer>
<div class="container"> <div class="container">
MetonaEditor v0.4.0 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div> </div>
</footer> </footer>
+95 -22
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 在线演示 — MetonaSqlark v0.4.1</title> <title>🧪 在线演示 — MetonaSqlark v0.7.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>"> <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> <style>
:root { :root {
@@ -84,7 +84,7 @@
<a href="demo.html" class="nav-active">演示</a> <a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a> <a href="benchmark.html">基准</a>
</nav> </nav>
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.1</div> <div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.7.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('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> <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> </header>
@@ -92,7 +92,7 @@
<div class="main"> <div class="main">
<div class="editor-panel"> <div class="editor-panel">
<div class="editor-area"> <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.1 在线演示 <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.7.4 在线演示
-- 已预置 users / orders / products 表数据 -- 已预置 users / orders / products 表数据
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护 -- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
@@ -131,7 +131,10 @@
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</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('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('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> <button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
<button class="btn btn-preset" onclick="loadPreset('maint')" style="color:#22c55e;border-color:#22c55e;">🛠 v0.5.1 维护语句</button>
</div> </div>
</div> </div>
</div> </div>
@@ -174,7 +177,7 @@ async function initDB() {
db = new DBClass({ name: 'demo', mode: engine }); db = new DBClass({ name: 'demo', mode: engine });
await db.init(); await db.init();
// v0.4.1: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性 // v0.4.2: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') { if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
await db.getEngine().clearAll(); await db.getEngine().clearAll();
} }
@@ -230,7 +233,7 @@ async function seedDemoData(db) {
]); ]);
} }
// v0.4.1: 切换存储引擎(重建数据库实例) // v0.4.2: 切换存储引擎(重建数据库实例)
async function switchEngine(engine) { async function switchEngine(engine) {
if (engine === currentEngine) return; if (engine === currentEngine) return;
currentEngine = engine; currentEngine = engine;
@@ -552,13 +555,13 @@ SELECT COUNT(*) as total FROM temp_logs;
-- 清理 -- 清理
DROP TABLE temp_logs;`, DROP TABLE temp_logs;`,
aria: `-- 🌲 AriaEngine 演示 (v0.4.1) aria: `-- 🌲 AriaEngine 演示 (v0.7.4)
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine -- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter -- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter
-- 基础 CRUD 与 Memory 引擎完全兼容 -- 基础 CRUD 与 Memory 引擎完全兼容
-- 当前引擎确认 -- 当前引擎确认
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB) -- (Aria 引擎实例,数据经 WAL + SSTable 持久化到 OPFS)
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id STRING PRIMARY KEY, id STRING PRIMARY KEY,
title STRING NOT NULL, title STRING NOT NULL,
@@ -587,17 +590,42 @@ DROP TABLE temp_logs;`,
-- AriaEngine 特性: -- AriaEngine 特性:
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引 -- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
-- • WAL: Write-Ahead Log 保证崩溃恢复 + 批量组提交 -- • WAL: 分片文件 + 标准 CRC32 + 空洞检测 + 崩溃恢复(残缺 SSTable 自动跳过)
-- • MVCC: 版本链 + 快照隔离 -- • MVCC: 版本链 + 快照隔离
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效 -- • 页面化物理存储: SSTable 4KB 页面 + BufferPool LRU 缓存 (v0.5.0)
-- • 完整性: 整文件 CRC-32 校验(数据腐坏自动清理自愈)
-- • Bloom Filter: FNV-1a + Murmur 双哈希 -- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX -- • 二级索引: 每列独立 LSM + 跨重启自动恢复
-- • 外键级联: CASCADE / SET NULL / RESTRICT (v0.4.1) -- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT
-- • 自愈: db.repair() 无需删库重建(含孤儿页面/残留清理)
-- • 维护语句: EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT (v0.5.1)
-- 生产环境 API(与演示页右上角切换等价) -- 生产环境 API(与演示页右上角切换等价)
-- const db = await MetonaSqlark.create({ -- const db = await MetonaSqlark.create({
-- name: 'my-app', mode: 'aria' -- name: 'my-app', mode: 'aria'
-- });`, -- });`,
maint: `-- 🛠 维护语句 (v0.5.1)
-- EXPLAIN: 输出查询计划(任何引擎可用)
EXPLAIN SELECT * FROM users WHERE age > 18;
-- ANALYZE: 收集表统计信息(仅 Aria 引擎)
ANALYZE TABLE users;
-- REINDEX: 重建二级索引(仅 Aria 引擎)
REINDEX TABLE users;
-- VACUUM: 压缩 LSM + 清理碎片(仅 Aria 引擎)
VACUUM;
-- SAVEPOINT: 嵌套事务保存点(仅 Aria 引擎)
BEGIN;
UPDATE users SET age = age + 1 WHERE id = '1';
SAVEPOINT sp1;
UPDATE users SET age = 0 WHERE id = '1';
ROLLBACK TO SAVEPOINT sp1;
RELEASE SAVEPOINT sp1;
COMMIT;
SELECT id, age FROM users WHERE id = '1';`,
casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2) casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2)
-- SELECT 列:多 WHEN + ELSE -- SELECT 列:多 WHEN + ELSE
@@ -674,19 +702,64 @@ WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 10
-- 为 orders.user_id 创建索引(已有数据自动回填) -- 为 orders.user_id 创建索引(已有数据自动回填)
CREATE INDEX idx_orders_user ON orders (user_id); CREATE INDEX idx_orders_user ON orders (user_id);
-- 索引查找(v0.4.1: JOIN 主表 WHERE 条件下推到引擎,真正走二级索引 -- 索引查找(v0.4.2: JOIN 主表 WHERE 条件下推 + 二级索引跨重启恢复
SELECT u.name, o.product, o.amount SELECT u.name, o.product, o.amount
FROM orders o JOIN users u ON u.id = o.user_id FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.user_id = '1'; WHERE o.user_id = '1';
-- 删除索引 -- 删除索引
DROP INDEX idx_orders_user ON orders (user_id); DROP INDEX idx_orders_user ON orders (user_id);
-- 删除后回退全表扫描(结果不变) -- 删除后回退全表扫描(结果不变)
SELECT * FROM orders WHERE user_id = '3'; SELECT * FROM orders WHERE user_id = '3';
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND -- DROP 不存在的索引会报错(INDEX_NOT_FOUND
-- DROP INDEX idx_nonexist ON orders (user_id);`, -- 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) multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
-- 分号分隔的多语句一次执行 -- 分号分隔的多语句一次执行
@@ -753,7 +826,7 @@ document.addEventListener('keydown', e => {
document.getElementById('btn-aria').style.opacity = '0.6'; document.getElementById('btn-aria').style.opacity = '0.6';
document.getElementById('engine-status').textContent = '⚡ Memory'; document.getElementById('engine-status').textContent = '⚡ Memory';
initDB().then(() => { initDB().then(() => {
console.log('✅ MetonaSqlark v0.4.1 demo ready'); console.log('✅ MetonaSqlark v0.4.2 demo ready');
setTimeout(runQuery, 300); setTimeout(runQuery, 300);
}).catch(err => { }).catch(err => {
renderError('初始化失败: ' + err.message); renderError('初始化失败: ' + err.message);
+120 -33
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.4.1</title> <title>📖 API 文档 — MetonaSqlark v0.7.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>"> <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> <style>
:root { :root {
@@ -95,6 +95,7 @@
<a href="#foreign-key">外键级联</a> <a href="#foreign-key">外键级联</a>
<a href="#connection-pool">连接池</a> <a href="#connection-pool">连接池</a>
<a href="#migration">数据迁移</a> <a href="#migration">数据迁移</a>
<a href="#selfheal">崩溃自愈 🆕</a>
<a href="#export">导入导出</a> <a href="#export">导入导出</a>
<a href="#plugin">插件 & 钩子</a> <a href="#plugin">插件 & 钩子</a>
<a href="#subscribe">发布订阅</a> <a href="#subscribe">发布订阅</a>
@@ -151,7 +152,7 @@ npm install @metona-team/metona-sqlark</pre>
<pre><span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({ <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">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">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">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 'opfs' | 'kv' | 'memory'v0.6.0: IndexedDB 已移除</span>
<span class="s">version</span>: <span class="n">1</span>, <span class="s">version</span>: <span class="n">1</span>,
<span class="s">plugins</span>: [], <span class="c">// MetonaPlugin[]</span> <span class="s">plugins</span>: [], <span class="c">// MetonaPlugin[]</span>
<span class="s">onReady</span>: (db) => {}, <span class="c">// 就绪回调</span> <span class="s">onReady</span>: (db) => {}, <span class="c">// 就绪回调</span>
@@ -197,8 +198,8 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<tr><td><code>maxLength</code></td><td><code>number</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>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>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>删除级联 🆕</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>更新级联 🆕</td></tr> <tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联(更新主键时触发)✅ v0.4.2</td></tr>
</table> </table>
<h2 id="sql-query">🔍 SQL 查询</h2> <h2 id="sql-query">🔍 SQL 查询</h2>
@@ -253,6 +254,14 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)</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> <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);</pre>
<h3>参数化查询 (v0.7.0)</h3>
<p>位置参数 <code>?</code> 在词法层安全绑定(仅替换字符串字面量与注释之外的占位符),值按 SQL 字面量编码 —— 杜绝 SQL 注入,无需手动转义。</p>
<pre><span class="c">// 位置参数绑定 — 字符串 '' 转义自动完成</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES (?, ?, ?, ?)"</span>, [<span class="s">'2'</span>, <span class="s">"O'Brien"</span>, <span class="n">25</span>, <span class="s">'ob@demo.com'</span>]);
<span class="k">const</span> row = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM users WHERE name = ?'</span>, [<span class="s">"O'Brien"</span>]);
<span class="c">// 参数数量不匹配 → PARAM_ERROR;对象/数组参数显式拒绝;NaN/Infinity → NULL</span></pre>
<h3>SQL 扩展 (v0.3.0+)</h3> <h3>SQL 扩展 (v0.3.0+)</h3>
<pre><span class="c">// 多语句 — 分号分隔一次执行(返回最后一条结果)</span> <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); <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE t (id STRING PRIMARY KEY);
@@ -282,7 +291,29 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">// CREATE / DROP INDEX — 动态二级索引</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">'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> <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);
<span class="c">// v0.7.4: 建表 UNIQUE 约束不可经 DROP INDEX 解除(NOT_SUPPORTED,重建表解除);
// 仅 CREATE UNIQUE INDEX 添加的唯一约束可随 DROP INDEX 一并删除(对齐 SQLite</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE UNIQUE INDEX idx_u ON users (email)'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_u ON users (email)'</span>); <span class="c">// unique 随之解除</span></pre>
<h3>维护语句 (v0.5.1 SQL 入口)</h3>
<pre><span class="c">// EXPLAIN — 查询计划(真实索引命中信息 v0.7.0)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"EXPLAIN SELECT * FROM users WHERE email = 'a@x.com'"</span>);
<span class="c">// { type, table, usingIndex: 'index:email', estimatedRows, actualTimeMs, ... }</span>
<span class="c">// ANALYZE / REINDEX / VACUUMAria 引擎;v0.7.3 统计含二级索引)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ANALYZE users'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'REINDEX users'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'VACUUM'</span>);
<span class="c">// SAVEPOINT — 嵌套事务保存点</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">'SAVEPOINT sp1'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK TO SAVEPOINT sp1'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'RELEASE SAVEPOINT sp1'</span>);
<span class="c">// 不支持的引擎执行维护语句抛 NOT_SUPPORTED</span></pre>
<h3>条件表达式</h3> <h3>条件表达式</h3>
<pre><span class="c">// 比较运算符</span> <pre><span class="c">// 比较运算符</span>
@@ -424,7 +455,7 @@ db.<span class="f">isReady</span>(); <span class="c">// true</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="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">// ✅ 全部成功 → 自动 commit</span>
<span class="c">// ❌ 任何一步失败 → 自动 rollback,数据恢复原状</span> <span class="c">// ❌ 任何一步失败 → 自动 rollback,数据恢复原状</span>
<span class="c">// Memory 引擎:快照回滚 | IndexedDB 引擎:延迟写入</span> <span class="c">// Memory 引擎:快照回滚 | KVStore 引擎:原子日志 flush</span>
});</pre> });</pre>
<h2 id="subquery">🔍 子查询</h2> <h2 id="subquery">🔍 子查询</h2>
@@ -440,7 +471,16 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">// NOT IN 子查询</span> <span class="c">// NOT IN 子查询</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users <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> WHERE id NOT IN (SELECT user_id FROM orders)`</span>);
<span class="c">// v0.7.4: UPDATE / DELETE 的 WHERE 同样支持子查询(四引擎)
// 此前未解析的 $subquery 在引擎层恒 false → 静默影响 0 行</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`UPDATE users SET role = 'vip'
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`DELETE FROM logs
WHERE ts < (SELECT MIN(ts) FROM keep_logs)`</span>);
<span class="c">// 写语句中关联引用($col / EXISTS 引用外层行)显式 NOT_SUPPORTED(不静默)</span></pre>
<h2 id="alter-table">🏗 ALTER TABLE (🆕 v0.2.5)</h2> <h2 id="alter-table">🏗 ALTER TABLE (🆕 v0.2.5)</h2>
<p>v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。</p> <p>v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。</p>
@@ -519,7 +559,7 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<pre><span class="c">// connect() — 获取或创建实例(单例复用)</span> <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> 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="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">// db1 === db2 — 复用已有实例,避免重复打开底层存储</span>
<span class="c">// disconnect() — 释放连接(引用计数 -1)</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> db2.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 2 → 1</span>
@@ -549,6 +589,19 @@ db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k"
<span class="c">// 执行迁移到目标版本</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> <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> <h2 id="export">📤 导入导出</h2>
<pre><span class="c">// 导出单表 — 返回 JSON 数组</span> <pre><span class="c">// 导出单表 — 返回 JSON 数组</span>
@@ -572,7 +625,7 @@ db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k"
<tr><td><code>beforeDropTable</code></td><td>删除表前</td><td>tableName</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>afterDropTable</code></td><td>删除表后</td><td>tableName</td></tr>
<tr><td><code>beforeInsert</code></td><td>插入前</td><td>rows[]</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>afterInsert</code></td><td>插入后</td><td>rows[], pks</td></tr>
<tr><td><code>beforeUpdate</code></td><td>更新前</td><td>query, updates</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>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>beforeDelete</code></td><td>删除前</td><td>query</td></tr>
@@ -723,13 +776,14 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr> <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>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>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>diskEngine</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>磁盘引擎类型'kv' = 自研 KVStore 后端;v0.6.0: IndexedDB 已移除)</td></tr>
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</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>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>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>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>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> <tr><td><code>multiTabSync</code></td><td><code>boolean</code></td><td><code>false</code></td><td>多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2</td></tr>
<tr><td><code>aria</code></td><td><code>AriaEngineConfig</code></td><td>-</td><td>AriaEngine 专属配置透传:walSyncMode / checkpointInterval / encryption / pageStorage / compression 等 🆕 v0.5.0</td></tr>
</table> </table>
<h2 id="engine">💾 存储引擎</h2> <h2 id="engine">💾 存储引擎</h2>
@@ -737,57 +791,77 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<table> <table>
<tr><th>引擎</th><th>模式</th><th>持久化</th><th>索引</th><th>事务</th><th>适用场景</th></tr> <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>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>KVStoreEngine</code> 🆕</td><td>disk</td><td>KVStore(OPFS)</td><td>哈希</td><td>原子日志 flush</td><td>标准持久化v0.6.0 替代 IndexedDB</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><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> <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> </table>
<p><strong>v0.6.0: IndexedDB 已完全移除</strong> — disk 模式改用自研 KVStore 引擎(多 key 原子写 + 快照/日志崩溃恢复),旧库可经 <code>migrateFromIndexedDB()</code> 一键迁移。</p>
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2> <h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br> <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.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.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
<strong>v0.4.1 Aria 级联与演示页引擎切换</strong> — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。</p> <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")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持。<br>
<strong>v0.5.0 存储后端生产级硬化</strong> — 真实 CRC-32 完整性校验(SSTable 整文件 + WAL 记录,旧文件兼容)· 全库 AES-256-GCM 透明加密(EncryptedBackend + PBKDF2 密钥派生 + 密码验证)· WAL 分片文件重构(真追加 + 空洞检测 + 旧格式迁移)· SSTable 4KB 页面化物理存储(BufferPool/FileManager 真实接入,meta 存 pageIds 兼容旧数据)· OPFS 后端 v2(append 真追加 / 写队列健壮性 / 残留清理)· Web Locks 多标签页独占锁(ARIA_LOCKED)· LZ4 v2 原始大小头 · Playwright 真实 Chromium e2e7 用例)· DatabaseConfig.aria 配置透传。<br>
<strong>v0.5.1 深度审查修复</strong> — 14 个生命周期钩子全部真实接线(此前 6 个 CRUD 钩子从未触发)· EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT SQL 入口补齐(此前仅有引擎方法无法触发)· db.backup() 公共方法 · 删除全部死代码(utils.ts 整文件 / MVCC 读侧 / estimateQueryCost 未接线优化器 / 40+ 统计辅助方法)。<br>
<strong>v0.6.0 完全移除 IndexedDB</strong> — 自研 KVStore 事务存储引擎(多 key 原子写 = 单日志记录原子追加 · 快照 checkpoint + 两阶段崩溃恢复 · CRC-32 自愈)· disk 模式切换 KVStoreEngine(替代 IndexedDBEngine + OPFSEngine)· 事务内 DDL / 外键级联 / 二级索引完整持久化 · migrateFromIndexedDB() 旧库一键迁移 · 10 万 key 压力验证 · e2e 崩溃注入 + KVStoreEngine 真实环境(12 用例)。<br>
<strong>v0.6.1 生产可用性深度审查</strong> — MemoryEngine 级联环(A→B→A)无限递归修复(visited 保护,与 Aria 对齐)· KVStore 快照损坏水位 bug 修复(metaSeq 误跳日志)· 多表事务 / 级联写入合并单条日志记录真原子(崩溃无部分提交)· 未 open 防护统一 · <strong>AriaEngine 可选自研 KVStore 后端</strong>storageBackend: 'kv'KVStore APPEND 日志支持 WAL 追加、writeMany 真原子、不再依赖浏览器 OPFS)· 二级索引范围扫描尾块漏读 P0 修复 · 批量插入性能悬崖修复(10 万行 kv 后端 353s → 12.5s)· 页面 id 崩溃回退 / flush 并发写 meta 两个崩溃恢复 P0 修复 · 10 万级 kv/opfs 双后端回归。<br>
<strong>v0.6.2 深度审计修复(数据正确性专项)</strong> — KVStoreEngine 数值主键 update 丢行 P0 · update 主键变更撞已有主键静默覆盖 P0 · Aria 二级索引范围查询边界算法错误(小数/字符串静默丢数据)P1 · 索引列 IS NULL 恒空(AriaP1 · Aria unique 约束未强制(批内互查 + 索引前缀扫描)· 非主键 update 索引旧值残留 · EXPLAIN 写语句产生真实副作用修复。<br>
<strong>v0.6.3 原子性 / 一致性 / 资源治理</strong> — KVStore 混合写单记录真原子(writeBatch,主键变更/级联全有或全无)· WAL full 模式写入失败抛错(不再吞错)· Memory SET NULL 级联索引残留 · delete 级联两阶段 RESTRICT 预检(无事务部分级联修复)· BufferPool 驱逐清理 pages Map(内存预算真实生效)· MVCC 已提交版本清理 · LSM.flush 重复入链 · rollbackToSavepoint 重建二级索引。<br>
<strong>v0.7.0 参数化查询 / 事务性能 / 语义硬化</strong><strong>参数化查询</strong><code>db.query(sql, params)</code> 位置参数 `?`,词法层绑定 + SQL 字面量安全编码,注入防护从根上成立)· EXPLAIN 真实索引命中信息(pk / index:col)· KVStoreEngine 事务行级增量 flush(大表事务改 1 行 commit 仅 1 条记录)· 移除每次 commit 强制全量 checkpoint · 复合主键显式拒绝(SCHEMA_ERRORv0.8 路线图)。<br>
<strong>v0.7.1 API 修复与防御统一</strong><code>MetonaSqlark.create</code> 静态工厂(README 示例在 ESM/Node 下可用)· close 未初始化崩溃防御 · AriaEngine 未 open 防护统一 · 事务回滚失败不掩盖原始错误 · <code>__proto__</code> 列名原型污染防护 · lint 清零。<br>
<strong>v0.7.2 语句级原子性 / 事务 DDL / 约束硬化</strong> — UPDATE 语句级两阶段原子(多行匹配第 N 行失败整句不执行 + 批内唯一互查)· 事务内 DDL 显式拒绝(四引擎对齐)· SET NULL 级联绕过 required 约束整体拒绝 · 参数绑定注释感知(注释中 `?`/引号不参与绑定)· 未闭合字符串显式 PARSE_ERROR · 未知 where 操作符抛 QUERY_ERROR(此前静默全匹配)· update undefined 语义化(保留旧值)· Hybrid write-through 失败补偿(磁盘失败自动重载内存对齐)。<br>
<strong>v0.7.3 深度审计第六阶段:INSERT 原子 / 索引一致性 / 边界窗口</strong> — INSERT 语句级两阶段原子(三引擎 + Aria PK 批内重复,此前部分提交)· 索引列 IS NULL 恒空修复(Memory/KVStore/Hybrid,对齐 Aria)· delete RESTRICT 预检不再破坏索引 · queryStream 子查询静默空结果修复($subquery/$exists/$col 回退物化)· ALTER DROP 索引列残留清理 · CREATE UNIQUE INDEX 存量重复数据校验(失败原子回滚)· <code>SELECT *, col AS alias</code> 解析与投影 · WAL BEGIN/ROLLBACK 写失败窗口修复(事务不泄漏/数据不复活)· aria $in 批级预加载(消除逐值 drainChain 性能悬崖)· 多条件 AND 等值下推(索引真正生效,EXPLAIN 同步)· ANALYZE 统计含二级索引 · React/Vue hooks 生命周期修复(config 变更重建 / 卸载关闭)· 迁移无主键旧库兜底 · 1256 测试 75 套件 · 90.0% 行覆盖率。<br>
<strong>v0.7.4 深度审计第七阶段:写语句子查询 / 约束硬化 / 真惰性流式</strong> — UPDATE/DELETE WHERE 子查询正确执行(四引擎,此前静默 0 行;关联引用显式 NOT_SUPPORTEDEXPLAIN 估算同步修复)· 主键 NULL/undefined 强制拒绝(SQL 语义 PK 隐含 NOT NULL,此前静默生成 "null"/"undefined" 主键)· DROP INDEX 保留建表 UNIQUE 约束(对齐 SQLite:需重建表解除;仅 CREATE UNIQUE INDEX 添加的可随索引删除)· GROUP BY / DISTINCT / UNION 键类型安全编码(null 与 'null' 字符串不再合并)· UPDATE 未知列显式 COLUMN_NOT_FOUND(此前脏列写入存储行)· queryStream 多语句显式 PARSE_ERROR(此前静默忽略后续语句)· KVStore 后台错误跨 reopen 清理 + Hybrid beginTransaction 失败补偿回滚 · <strong>Aria findStream 真惰性</strong>MergeIterator 迭代器化 + SSTable/MemTable 生成器扫描,limit 提前终止,大表流式内存 O(1)——此前内部 drain 全量物化)· REINDEX 单次全表扫描重建全部索引列(此前每列一次全扫描)· RB-Tree 删除双黑修复边界 + LSM 死代码清理 · 1304 测试 76 套件 · 90.1% 行覆盖率。</p>
<h3>存储模式对比</h3> <h3>存储模式对比</h3>
<table> <table>
<tr><th>特性</th><th>Memory</th><th>Disk(IDB)</th><th>Disk(OPFS)</th><th>Hybrid</th><th>Aria</th></tr> <tr><th>特性</th><th>Memory</th><th>Disk (KVStore)</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>KVStoreOPFS / 内存介质)</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>✅ 快照回滚</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>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>磁盘可用</td><td>内存</td></tr>
<tr><td>浏览器</td><td>全部</td><td>全部</td><td>Chromium</td><td>全部</td><td>全部</td></tr> <tr><td>多标签页锁</td><td></td><td></td><td></td><td>✅ Web Locks</td></tr>
<tr><td>全库加密</td><td></td><td></td><td></td><td>✅ AES-GCM</td></tr>
<tr><td>浏览器</td><td>全部</td><td>Chrome/Edge 102+ · Firefox 111+ · Safari 15.2+</td><td>同上</td><td>同上(后端决定)</td></tr>
</table> </table>
<h3>核心特性</h3> <h3>核心特性</h3>
<table> <table>
<tr><th>特性</th><th>说明</th></tr> <tr><th>特性</th><th>说明</th></tr>
<tr><td><strong>LSM-Tree 索引</strong></td><td>MemTable (红黑树) + 多级 SSTable,写优化,支持范围扫描</td></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>4KB 页面化物理存储</strong></td><td>SSTable 切分为 4KB 页面(FileManager 分配 pageId + BufferPool LRU 缓存 256 页 ≈ 1MB),save 即落盘,`SSTableMeta.pageIds` 持久化(旧整 value 数据兼容)✅ v0.5.0</td></tr>
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB)</td></tr> <tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB),脏页写回 ✅ v0.5.0 真实接入</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>WAL 日志</strong></td><td>分片文件(`__wal_%06d.bin`4MB 阈值切换 + 真追加)· 标准 CRC32 记录校验 · full/batch/none 三种同步模式(full 真正同步)· 空洞检测截断 · 16MB 阈值自动 checkpoint ✅ v0.5.0</td></tr>
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC,读写路径接入版本链 ✅ v0.2.5</td></tr> <tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC(引擎事务读取走 txnSnapshotMVCC 版本链作 undo log</td></tr>
<tr><td><strong>全库 AES-GCM 加密</strong></td><td>EncryptedBackend 透明加解密(WAL/SSTable/Schema/元数据全密文)· PBKDF2 密钥派生 + salt 持久化 + 密码验证 · 明文/加密库开关一致性检测 ✅ v0.5.0</td></tr>
<tr><td><strong>多标签页独占锁</strong></td><td>Web Locks API:第二个标签页打开同一库抛 `ARIA_LOCKED`open 失败自动释放 ✅ v0.5.0</td></tr>
<tr><td><strong>Bloom Filter</strong></td><td>快速判定 key 不存在,减少无效磁盘 I/OSSTableReader 二分查找统一 ✅ 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> <tr><td><strong>LZ4 压缩v2</strong></td><td>SSTable 级压缩,压缩流自带原始大小头(高压缩率不截断)✅ v0.5.0</td></tr>
</table> </table>
<h3>使用方式</h3> <h3>使用方式</h3>
<pre><span class="c">// 激活 AriaEngine</span> <pre><span class="c">// 激活 AriaEngineOPFS 后端默认页面化存储)</span>
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.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">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">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="s">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 底层存储后端(opfs | kv | memory</span>
<span class="s">aria</span>: { <span class="c">// AriaEngine 专属配置透传 🆕 v0.5.0</span>
<span class="s">walSyncMode</span>: <span class="s">'full'</span>, <span class="c">// WAL 同步模式</span>
<span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库 AES-GCM 加密 🆕 v0.5.0</span>
},
}); });
<span class="c">// 或直接实例化 — 支持细粒度配置</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">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="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">// Buffer Pool 页面数量</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">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">'full'</span>, <span class="c">// 'full' | 'batch' | 'none'(默认 full</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">'opfs'</span>, <span class="c">// 存储后端(opfs | kv | memory</span>
<span class="s">storageBackend</span>: <span class="s">'indexeddb'</span>, <span class="c">// 存储后端</span> <span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库加密</span>
});</pre> });</pre>
<h3>AriaEngine 配置项</h3> <h3>AriaEngine 配置项</h3>
@@ -799,11 +873,13 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><td><code>levelSizeMultiplier</code></td><td><code>number</code></td><td><code>10</code></td><td>LSM 层级容量倍数</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>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>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>walSyncMode</code></td><td><code>'full'|'batch'|'none'</code></td><td><code>'full'</code></td><td>WAL 同步策略(full 真正同步 ✅ v0.2.4 起默认 full</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>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>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>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> <tr><td><code>storageBackend</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>存储后端类型'kv' = 自研 KVStore 后端 🆕 v0.6.1</td></tr>
<tr><td><code>encryption</code></td><td><code>{ password: string }</code></td><td>-</td><td>全库 AES-256-GCM 透明加密(PBKDF2 派生 + salt 持久化 + 密码验证)🆕 v0.5.0</td></tr>
<tr><td><code>pageStorage</code></td><td><code>boolean</code></td><td>opfs/kv 自动启用</td><td>SSTable 4KB 页面化物理存储(BufferPool 缓存)🆕 v0.5.0</td></tr>
</table> </table>
<h2 id="errors">⚠️ 错误处理</h2> <h2 id="errors">⚠️ 错误处理</h2>
@@ -833,6 +909,17 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<tr><td><code>TRANSACTION_ERROR</code></td><td>事务执行失败</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>COMPILE_ERROR</code></td><td>编译 AST 到查询计划失败</td></tr>
<tr><td><code>CONFIG_ERROR</code></td><td>配置错误</td></tr> <tr><td><code>CONFIG_ERROR</code></td><td>配置错误</td></tr>
<tr><td><code>ARIA_LOCKED</code></td><td>数据库已被其他标签页打开(Web Locks 独占锁)🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_DECRYPT_ERROR</code></td><td>解密失败:密码错误 / 密钥元数据损坏 / 数据被篡改 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_ENCRYPT_REQUIRED</code></td><td>库已加密但未提供密码 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_ENCRYPT_CONFIG_ERROR</code></td><td>明文库无法以加密模式打开 / 空密码 🆕 v0.5.0</td></tr>
<tr><td><code>ARIA_BACKGROUND_ERROR</code></td><td>后台 flush/compaction 失败(flush/close 时显式报告)</td></tr>
<tr><td><code>SAVEPOINT_EXISTS</code> / <code>SAVEPOINT_NOT_FOUND</code></td><td>保存点已存在 / 回滚到不存在的保存点 🆕 v0.5.1 SQL 入口</td></tr>
<tr><td><code>NOT_SUPPORTED</code></td><td>引擎不支持的操作(如 Memory 引擎执行 ANALYZE / VACUUM / SAVEPOINT)🆕 v0.5.1</td></tr>
<tr><td><code>PARAM_ERROR</code></td><td>参数绑定错误:数量不匹配 / 对象数组参数 🆕 v0.7.0</td></tr>
<tr><td><code>QUERY_ERROR</code></td><td>未知 where 操作符(如拼错的 <code>$betwen</code>)🆕 v0.7.2</td></tr>
<tr><td><code>COLUMN_NOT_FOUND</code></td><td>列不存在:UPDATE 未知列 / ALTER DROP 不存在列(v0.7.4 UPDATE 未知列显式报错)</td></tr>
<tr><td><code>INDEX_NOT_FOUND</code></td><td>DROP 不存在的索引</td></tr>
</table> </table>
</main> </main>
+3 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.5.0", "version": "0.6.2",
"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,22 +28,13 @@
"author": "MarkLite", "author": "MarkLite",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@metona-team/metona-editor": "^0.4.0", "@metona-team/metona-editor": "0.4.1",
"@metona-team/metona-sqlark": "^0.4.1", "@metona-team/metona-sqlark": "0.7.4",
"@metona-team/metona-toast": "^0.5.0", "@metona-team/metona-toast": "^0.5.0",
"mermaid": "^10.9.6", "mermaid": "^10.9.6",
"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": {
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { searchInDir } from '../file-system'
describe('searchInDir (主进程多文件搜索)', () => {
let dir: string
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'marklite-search-'))
mkdirSync(join(dir, 'sub'))
mkdirSync(join(dir, 'node_modules'))
writeFileSync(join(dir, 'a.md'), '# Hello\nline two\nWorld hello again\n')
writeFileSync(join(dir, 'sub', 'b.markdown'), 'nothing here\nHELLO UPPER\n')
writeFileSync(join(dir, 'skip.js'), 'hello in js\n')
writeFileSync(join(dir, 'node_modules', 'c.md'), 'hello in deps\n')
writeFileSync(join(dir, '.hidden.md'), 'hello hidden\n')
})
afterAll(() => {
rmSync(dir, { recursive: true, force: true })
})
it('should find matches across files (case-insensitive default)', async () => {
const result = await searchInDir({ dirPath: dir, query: 'hello' })
expect(result.success).toBe(true)
// a.md: 2 处(# Hello / World hello again + b.markdown: 1 处(HELLO UPPER
expect(result.matches).toHaveLength(3)
expect(result.totalFiles).toBe(2)
})
it('should respect case-sensitive option', async () => {
const result = await searchInDir({ dirPath: dir, query: 'HELLO', caseSensitive: true })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(1)
expect(result.matches?.[0].filePath).toContain('b.markdown')
})
it('should support regex search', async () => {
const result = await searchInDir({ dirPath: dir, query: '^line', useRegex: true })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(1)
expect(result.matches?.[0].line).toBe(2)
})
it('should reject invalid regex with error', async () => {
const result = await searchInDir({ dirPath: dir, query: '([', useRegex: true })
expect(result.success).toBe(false)
expect(result.error).toBe('无效的正则表达式')
})
it('should skip node_modules and dotfiles', async () => {
const result = await searchInDir({ dirPath: dir, query: 'hidden' })
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(0)
})
it('should reject empty query', async () => {
const result = await searchInDir({ dirPath: dir, query: '' })
expect(result.success).toBe(false)
})
it('should return error for invalid directory', async () => {
const result = await searchInDir({ dirPath: join(dir, 'nope'), query: 'x' })
// 目录不存在:walk 内部吞掉 readdir 错误,返回 0 结果(success: true
expect(result.success).toBe(true)
expect(result.matches).toHaveLength(0)
})
})
+137 -10
View File
@@ -1,7 +1,13 @@
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises' import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
import { join, extname, dirname, basename } from 'path' import { join, extname, dirname, basename } from 'path'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' import type {
ReadFileResult,
SaveFileResult,
FileNode,
SearchInDirPayload,
SearchInDirResult,
} from '../shared/types'
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS) const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
@@ -10,26 +16,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) {
@@ -39,16 +48,43 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败) // C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink // M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
// B14-fix: 检测原文件编码(UTF-16 LE/BE BOM),保存时保持同编码,
// 避免 UTF-16 文件被强制改写为 UTF-8 造成乱码
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> { export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
const randomSuffix = randomBytes(8).toString('hex') const randomSuffix = randomBytes(8).toString('hex')
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`) const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
try { try {
await writeFile(tmpFile, content, 'utf-8') let data: Buffer
try {
const existing = await readFile(filePath)
if (existing.length >= 2 && existing[0] === 0xfe && existing[1] === 0xff) {
// 原文件 UTF-16 BEBOM + BE 编码写回
const bom = Buffer.from([0xfe, 0xff])
const body = Buffer.from(content, 'utf16le')
body.swap16()
data = Buffer.concat([bom, body])
} else if (existing.length >= 2 && existing[0] === 0xff && existing[1] === 0xfe) {
// 原文件 UTF-16 LEBOM + LE 编码写回
const bom = Buffer.from([0xff, 0xfe])
const body = Buffer.from(content, 'utf16le')
data = Buffer.concat([bom, body])
} else {
data = Buffer.from(content, 'utf-8')
}
} catch {
// 原文件不存在/不可读:按 UTF-8 写新文件
data = Buffer.from(content, 'utf-8')
}
await writeFile(tmpFile, data)
await rename(tmpFile, filePath) await rename(tmpFile, filePath)
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 +94,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 []
@@ -109,3 +145,94 @@ export async function buildDirTree(
} }
return children return children
} }
// v0.6.2: 多文件搜索 — 递归遍历目录,逐行匹配 md/markdown/txt 文件内容
const SEARCH_MAX_MATCHES = 500
const SEARCH_MAX_DEPTH = 10
const SEARCH_LINE_PREVIEW_LEN = 200
function buildSearchMatcher(
query: string,
caseSensitive: boolean,
useRegex: boolean,
): { test: (line: string) => boolean } | null {
if (useRegex) {
try {
const re = new RegExp(query, caseSensitive ? '' : 'i')
return { test: (line: string) => re.test(line) }
} catch {
return null // 无效正则
}
}
const needle = caseSensitive ? query : query.toLowerCase()
return {
test: (line: string) =>
(caseSensitive ? line : line.toLowerCase()).includes(needle),
}
}
export async function searchInDir(
payload: SearchInDirPayload,
): Promise<SearchInDirResult> {
const { dirPath, query, caseSensitive = false, useRegex = false } = payload
if (!query) {
return { success: false, error: '搜索内容为空' }
}
const matcher = buildSearchMatcher(query, caseSensitive, useRegex)
if (!matcher) {
return { success: false, error: '无效的正则表达式' }
}
const matches: SearchInDirResult['matches'] = []
let totalFiles = 0
let truncated = false
const walk = async (dir: string, depth: number): Promise<void> => {
if (truncated || depth > SEARCH_MAX_DEPTH) return
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (truncated) return
const name = entry.name
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue
const childPath = join(dir, name)
if (entry.isDirectory()) {
await walk(childPath, depth + 1)
continue
}
if (!ALLOWED_EXTENSIONS_SET.has(extname(name).toLowerCase())) continue
totalFiles++
try {
const fileStat = await stat(childPath)
if (fileStat.size > MAX_FILE_SIZE) continue
const content = await readFile(childPath, 'utf-8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
if (!matcher.test(lines[i])) continue
matches?.push({
filePath: childPath,
line: i + 1,
lineText: lines[i].slice(0, SEARCH_LINE_PREVIEW_LEN),
})
if ((matches?.length ?? 0) >= SEARCH_MAX_MATCHES) {
truncated = true
return
}
}
} catch {
// 单文件读取失败(编码/权限)跳过,不中断整体搜索
}
}
}
try {
await walk(dirPath, 0)
return { success: true, matches, totalFiles, truncated }
} catch (err) {
return { success: false, error: (err as Error).message }
}
}
+5 -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()
@@ -27,6 +27,10 @@ export class FileWatcher {
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态 // L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
this.watcher.on('error', () => { this.watcher.on('error', () => {
const originalPath = this.currentPath const originalPath = this.currentPath
// B12-fix: watcher 已失效,必然不再处于"自写"状态 —
// 若 error 恰好发生在保存流程 start() 与 setSelfWriting(false) 之间,
// 残留的 isSelfWriting=true 会永久吞掉轮询恢复后的外部修改通知
this.isSelfWriting = false
this.stop() this.stop()
if (originalPath) { if (originalPath) {
this.pollTimer = setInterval(() => { this.pollTimer = setInterval(() => {
+143 -140
View File
@@ -1,140 +1,143 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { join } from 'path' import { join } from 'path'
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager' import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
import { FileWatcher, SidebarWatcher } from './file-watcher' import { FileWatcher, SidebarWatcher } from './file-watcher'
import { registerIpcHandlers } from './ipc-handlers' import { registerIpcHandlers } from './ipc-handlers'
import { readFileContent } from './file-system' import { readFileContent } from './file-system'
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
const state = { const state = {
activeFilePath: null as string | null, activeFilePath: null as string | null,
pendingFilePath: null as string | null, pendingFilePath: null as string | null,
isClosing: false, isClosing: false,
closeTimeout: null as NodeJS.Timeout | null closeTimeout: null as NodeJS.Timeout | null,
} }
const fileWatcher = new FileWatcher(() => mainWindow) const fileWatcher = new FileWatcher(() => mainWindow)
const sidebarWatcher = new SidebarWatcher(() => mainWindow) const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void { function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => { readFileContent(filePath)
if (result.success && mainWindow && !mainWindow.isDestroyed()) { .then(result => {
state.activeFilePath = filePath if (result.success && mainWindow && !mainWindow.isDestroyed()) {
fileWatcher.start(filePath) state.activeFilePath = filePath
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) fileWatcher.start(filePath)
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
} mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
}).catch((err) => { }
// eslint-disable-next-line no-console -- IPC file open error })
console.error('openFileInTab failed:', err) .catch(err => {
}) // eslint-disable-next-line no-console -- IPC file open error
} console.error('openFileInTab failed:', err)
})
const lockOk = setupSingleInstanceLock((filePath) => { }
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore() const lockOk = setupSingleInstanceLock(filePath => {
mainWindow.focus() if (mainWindow && !mainWindow.isDestroyed()) {
if (filePath) openFileInTab(filePath) if (mainWindow.isMinimized()) mainWindow.restore()
} mainWindow.focus()
}) if (filePath) openFileInTab(filePath)
}
if (!lockOk) { })
app.quit()
} else { if (!lockOk) {
app.quit()
function setupCloseHandler(): void { } else {
if (!mainWindow) return function setupCloseHandler(): void {
mainWindow.on('close', (e) => { if (!mainWindow) return
if (state.isClosing) return mainWindow.on('close', e => {
state.isClosing = true if (state.isClosing) return
e.preventDefault() state.isClosing = true
try { e.preventDefault()
if (mainWindow && !mainWindow.webContents.isDestroyed()) { try {
mainWindow.webContents.send('window:confirmClose') if (mainWindow && !mainWindow.webContents.isDestroyed()) {
} else { mainWindow.webContents.send('window:confirmClose')
mainWindow?.removeAllListeners('close') } else {
mainWindow?.close() mainWindow?.removeAllListeners('close')
return mainWindow?.close()
} return
} catch { }
mainWindow?.removeAllListeners('close') } catch {
mainWindow?.close() mainWindow?.removeAllListeners('close')
return mainWindow?.close()
} return
state.closeTimeout = setTimeout(() => { }
state.closeTimeout = null // B4-fix: 超时 12s — 需长于渲染进程 MeToast.confirm 的 10 秒安全超时,
if (mainWindow && !mainWindow.isDestroyed()) { // 否则用户尚未确认就被强杀(丢失最后 500ms 防抖窗口内的编辑)
mainWindow.removeAllListeners('close') state.closeTimeout = setTimeout(() => {
mainWindow.close() state.closeTimeout = null
} if (mainWindow && !mainWindow.isDestroyed()) {
}, 5000) mainWindow.removeAllListeners('close')
}) mainWindow.close()
} }
}, 12000)
// C-05: 窗口重建时重置状态 })
function initWindow(): void { }
state.isClosing = false
if (state.closeTimeout) { // C-05: 窗口重建时重置状态
clearTimeout(state.closeTimeout) function initWindow(): void {
state.closeTimeout = null state.isClosing = false
} if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
mainWindow = createWindow() state.closeTimeout = null
}
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow = createWindow()
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) if (process.env.ELECTRON_RENDERER_URL) {
} mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.webContents.on('did-finish-load', () => { mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
if (state.pendingFilePath) { }
openFileInTab(state.pendingFilePath)
state.pendingFilePath = null mainWindow.webContents.on('did-finish-load', () => {
} if (state.pendingFilePath) {
}) openFileInTab(state.pendingFilePath)
state.pendingFilePath = null
setupCloseHandler() }
})
mainWindow.on('closed', () => {
fileWatcher.stop() setupCloseHandler()
sidebarWatcher.stop()
mainWindow = null mainWindow.on('closed', () => {
}) fileWatcher.stop()
} sidebarWatcher.stop()
mainWindow = null
app.whenReady().then(() => { })
app.on('open-file', (event, filePath) => { }
event.preventDefault()
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { app.whenReady().then(() => {
state.pendingFilePath = filePath app.on('open-file', (event, filePath) => {
} else if (mainWindow && !mainWindow.isDestroyed()) { event.preventDefault()
openFileInTab(filePath) if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
} else { state.pendingFilePath = filePath
state.pendingFilePath = filePath } else if (mainWindow && !mainWindow.isDestroyed()) {
} openFileInTab(filePath)
}) } else {
state.pendingFilePath = filePath
// C-03: IPC 处理器只注册一次 }
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) })
initWindow() // C-03: IPC 处理器只注册一次
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
const cmdFile = getFilePathFromArgs(process.argv)
if (cmdFile) { initWindow()
state.pendingFilePath = cmdFile
} const cmdFile = getFilePathFromArgs(process.argv)
}) if (cmdFile) {
state.pendingFilePath = cmdFile
app.on('window-all-closed', () => { }
if (process.platform !== 'darwin') app.quit() })
})
app.on('window-all-closed', () => {
// C-03: activate 只重建窗口,不重复注册 IPC if (process.platform !== 'darwin') app.quit()
app.on('activate', () => { })
if (BrowserWindow.getAllWindows().length === 0) {
initWindow() // C-03: activate 只重建窗口,不重复注册 IPC
} app.on('activate', () => {
}) if (BrowserWindow.getAllWindows().length === 0) {
} initWindow()
}
})
}
+302 -228
View File
@@ -1,228 +1,302 @@
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron' import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { readFileContent, saveFileContent, buildDirTree } from './file-system' import { readFileContent, saveFileContent, buildDirTree, searchInDir } 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 type { SearchInDirPayload } from '../shared/types'
import { basename, isAbsolute } from 'path' import { stat, readFile, writeFile } from 'fs/promises'
import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击
function validatePath(filePath: string): boolean { // 安全校验:拒绝路径遍历攻击
if (!filePath || typeof filePath !== 'string') return false function validatePath(filePath: string): boolean {
// 拒绝空字节 if (!filePath || typeof filePath !== 'string') return false
if (filePath.includes('\0')) return false // 拒绝空字节
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段 if (filePath.includes('\0')) return false
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径 // 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..'
const sepPattern = /[/\\]/ // H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
const parts = filePath.split(sepPattern) const sepPattern = /[/\\]/
if (parts.some(part => part === '..')) return false const parts = filePath.split(sepPattern)
// 必须是绝对路径 if (parts.some(part => part === '..')) return false
if (!isAbsolute(filePath)) return false // 必须是绝对路径
return true if (!isAbsolute(filePath)) return false
} return true
}
export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null, export function registerIpcHandlers(
fileWatcher: FileWatcher, getMainWindow: () => BrowserWindow | null,
sidebarWatcher: SidebarWatcher, fileWatcher: FileWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } sidebarWatcher: SidebarWatcher,
): void { state: {
// 打开文件对话框 activeFilePath: string | null
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { pendingFilePath: string | null
const win = getMainWindow() isClosing: boolean
if (!win) return null closeTimeout: NodeJS.Timeout | null
try { },
const result = await dialog.showOpenDialog(win, { ): void {
properties: ['openFile'], // 打开文件对话框
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
}) const win = getMainWindow()
if (!result.canceled && result.filePaths.length > 0) { if (!win) return null
const filePath = result.filePaths[0] try {
const fileResult = await readFileContent(filePath) const result = await dialog.showOpenDialog(win, {
if (fileResult.success) { properties: ['openFile'],
state.activeFilePath = filePath filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
fileWatcher.start(filePath) })
win.setTitle(`MarkLite - ${basename(filePath)}`) if (!result.canceled && result.filePaths.length > 0) {
return { filePath, content: fileResult.content } const filePath = result.filePaths[0]
} const fileResult = await readFileContent(filePath)
return { error: fileResult.error } if (fileResult.success) {
} state.activeFilePath = filePath
return null fileWatcher.start(filePath)
} catch (err) { win.setTitle(`MarkLite - ${basename(filePath)}`)
return { error: (err as Error).message } return { filePath, content: fileResult.content }
} }
}) return { error: fileResult.error }
}
// 读取文件 return null
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => { } catch (err) {
if (!validatePath(filePath)) { return { error: (err as Error).message }
return { success: false, error: '无效的文件路径' } }
} })
return readFileContent(filePath)
}) // 读取文件
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
// 保存文件 if (!validatePath(filePath)) {
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { return { success: false, error: '无效的文件路径' }
if (data.filePath && !validatePath(data.filePath)) { }
return { success: false, error: '无效的文件路径' } return readFileContent(filePath)
} })
const win = getMainWindow()
try { // 保存文件
if (data.filePath) { ipcMain.handle(
// Mark self-writing before stopping watcher to suppress any events IPC_CHANNELS.FILE_SAVE,
// that arrive between stop and start async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
fileWatcher.setSelfWriting(true) if (data.filePath && !validatePath(data.filePath)) {
fileWatcher.stop() return { success: false, error: '无效的文件路径' }
const result = await saveFileContent(data.filePath, data.content) }
// Restart watcher while selfWriting is still true so any immediate 'change' const win = getMainWindow()
// event from the restart is suppressed — prevents overwriting editor content try {
fileWatcher.start(data.filePath) if (data.filePath) {
fileWatcher.setSelfWriting(false) // Mark self-writing before stopping watcher to suppress any events
state.activeFilePath = data.filePath // that arrive between stop and start
if (win && !win.isDestroyed()) { fileWatcher.setSelfWriting(true)
win.setTitle(`MarkLite - ${basename(data.filePath)}`) fileWatcher.stop()
} const result = await saveFileContent(data.filePath, data.content)
return result // Restart watcher while selfWriting is still true so any immediate 'change'
} else { // event from the restart is suppressed — prevents overwriting editor content
if (!win) return { success: false, error: '窗口不可用' } fileWatcher.start(data.filePath)
const saveResult = await dialog.showSaveDialog(win, { fileWatcher.setSelfWriting(false)
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] state.activeFilePath = data.filePath
}) if (win && !win.isDestroyed()) {
if (!saveResult.canceled) { win.setTitle(`MarkLite - ${basename(data.filePath)}`)
fileWatcher.setSelfWriting(true) }
const result = await saveFileContent(saveResult.filePath, data.content) return result
if (result.success) { } else {
state.activeFilePath = saveResult.filePath if (!win) return { success: false, error: '窗口不可用' }
fileWatcher.start(saveResult.filePath) const saveResult = await dialog.showSaveDialog(win, {
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`) filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
} })
fileWatcher.setSelfWriting(false) if (!saveResult.canceled) {
return result fileWatcher.setSelfWriting(true)
} const result = await saveFileContent(saveResult.filePath, data.content)
return { success: false, canceled: true } if (result.success) {
} state.activeFilePath = saveResult.filePath
} catch (err) { fileWatcher.start(saveResult.filePath)
fileWatcher.setSelfWriting(false) win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
if (state.activeFilePath) { }
fileWatcher.start(state.activeFilePath) fileWatcher.setSelfWriting(false)
} return result
return { success: false, error: (err as Error).message } }
} return { success: false, canceled: true }
}) }
} catch (err) {
// 另存为 fileWatcher.setSelfWriting(false)
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { if (state.activeFilePath) {
const win = getMainWindow() fileWatcher.start(state.activeFilePath)
if (!win) return { success: false, error: '窗口不可用' } }
try { return { success: false, error: (err as Error).message }
const result = await dialog.showSaveDialog(win, { }
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] },
}) )
if (!result.canceled) {
fileWatcher.setSelfWriting(true) // 另存为
const saveResult = await saveFileContent(result.filePath, data.content) ipcMain.handle(
if (saveResult.success) { IPC_CHANNELS.FILE_SAVE_AS,
state.activeFilePath = result.filePath async (_event: IpcMainInvokeEvent, data: { content: string }) => {
fileWatcher.start(result.filePath) const win = getMainWindow()
win.setTitle(`MarkLite - ${basename(result.filePath)}`) if (!win) return { success: false, error: '窗口不可用' }
} try {
fileWatcher.setSelfWriting(false) const result = await dialog.showSaveDialog(win, {
return saveResult filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
} })
return { success: false, canceled: true } if (!result.canceled) {
} catch (err) { fileWatcher.setSelfWriting(true)
fileWatcher.setSelfWriting(false) const saveResult = await saveFileContent(result.filePath, data.content)
return { success: false, error: (err as Error).message } if (saveResult.success) {
} state.activeFilePath = result.filePath
}) fileWatcher.start(result.filePath)
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
// 获取当前路径 }
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) fileWatcher.setSelfWriting(false)
return saveResult
// 文件统计 }
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { return { success: false, canceled: true }
if (!validatePath(filePath)) { } catch (err) {
return { success: false, error: '无效的文件路径' } fileWatcher.setSelfWriting(false)
} return { success: false, error: (err as Error).message }
try { }
const fileStat = await stat(filePath) },
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } )
} catch (err) {
return { success: false, error: (err as Error).message } // 获取当前路径
} ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
})
// 文件统计
// 重新加载 ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => { if (!validatePath(filePath)) {
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' } return { success: false, error: '无效的文件路径' }
const result = await readFileContent(state.activeFilePath) }
return { ...result, filePath: state.activeFilePath } try {
}) const fileStat = await stat(filePath)
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
// 目录树 } catch (err) {
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { return { success: false, error: (err as Error).message }
if (!validatePath(dirPath)) { }
return { success: false, error: '无效的目录路径' } })
}
try { // 重新加载
const tree = await buildDirTree(dirPath) ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
return { success: true, tree, rootPath: dirPath } if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
} catch (err) { const result = await readFileContent(state.activeFilePath)
return { success: false, error: (err as Error).message } return { ...result, filePath: state.activeFilePath }
} })
})
// 目录树
// 打开文件夹对话框 ipcMain.handle(
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { IPC_CHANNELS.DIR_READ_TREE,
const win = getMainWindow() async (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!win) return null if (!validatePath(dirPath)) {
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) return { success: false, error: '无效的目录路径' }
if (!result.canceled && result.filePaths.length > 0) { }
return result.filePaths[0] try {
} const tree = await buildDirTree(dirPath)
return null return { success: true, tree, rootPath: dirPath }
}) } catch (err) {
return { success: false, error: (err as Error).message }
// 目录监听 }
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { },
if (!validatePath(dirPath)) return )
sidebarWatcher.start(dirPath)
}) // 打开文件夹对话框
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => { const win = getMainWindow()
sidebarWatcher.stop() if (!win) return null
}) const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
// 标签切换 return result.filePaths[0]
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { }
const normalizedPath = filePath || null return null
if (normalizedPath && !validatePath(normalizedPath)) { })
fileWatcher.stop()
return // 目录监听
} ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
state.activeFilePath = normalizedPath if (!validatePath(dirPath)) return
fileWatcher.start(normalizedPath || '') sidebarWatcher.start(dirPath)
const win = getMainWindow() })
if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite') ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
} sidebarWatcher.stop()
}) })
// 窗口控制 // v0.6.2: 多文件搜索 — 目录内递归搜索匹配行
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { ipcMain.handle(
const win = getMainWindow() IPC_CHANNELS.DIR_SEARCH,
if (win && !win.isDestroyed()) { async (_event: IpcMainInvokeEvent, payload: SearchInDirPayload) => {
fileWatcher.stop() if (!payload || typeof payload !== 'object') {
win.removeAllListeners('close') return { success: false, error: '无效的搜索参数' }
win.close() }
} if (!validatePath(payload.dirPath)) {
}) return { success: false, error: '无效的目录路径' }
}
// B-01: 重置关闭状态 + 清除超时定时器 if (typeof payload.query !== 'string') {
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { return { success: false, error: '无效的搜索内容' }
state.isClosing = false }
if (state.closeTimeout) { return searchInDir(payload)
clearTimeout(state.closeTimeout) },
state.closeTimeout = null )
}
}) // 标签切换
} ipcMain.handle(
IPC_CHANNELS.TAB_SWITCHED,
(_event: IpcMainInvokeEvent, filePath: string | null) => {
const normalizedPath = filePath || null
if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop()
return
}
state.activeFilePath = normalizedPath
fileWatcher.start(normalizedPath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
}
},
)
// 窗口控制
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
fileWatcher.stop()
win.removeAllListeners('close')
win.close()
}
})
// B-01: 重置关闭状态 + 清除超时定时器
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
state.isClosing = false
if (state.closeTimeout) {
clearTimeout(state.closeTimeout)
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) {
+81 -64
View File
@@ -1,64 +1,81 @@
import { contextBridge, ipcRenderer, shell } from 'electron' import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { ElectronAPI } from '../renderer/types/ipc' import type { ElectronAPI } from '../renderer/types/ipc'
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致 // C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
const api: ElectronAPI = { 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),
// Tab management // Tab management
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath), tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
// Window control // Window control
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE), forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE), cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
// Shell — 仅允许 http/https 协议 // Shell — 仅允许 http/https 协议
openExternal: (url: string) => { openExternal: (url: string) => {
try { try {
const parsed = new URL(url) const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url) shell.openExternal(url)
} }
} catch { } catch {
// 无效 URL,忽略 // 无效 URL,忽略
} }
}, },
// File Tree (Sidebar) // File Tree (Sidebar)
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath), readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG), openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
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),
searchInDir: (payload: import('../shared/types').SearchInDirPayload) =>
// Events from main process — 返回取消订阅函数 ipcRenderer.invoke(IPC_CHANNELS.DIR_SEARCH, payload),
onFileOpenInTab: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) // v0.6.0: 数据备份导出/导入
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } importData: () => ipcRenderer.invoke(IPC_CHANNELS.DATA_IMPORT),
},
onExternalModification: (callback) => { // Events from main process — 返回取消订阅函数
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) onFileOpenInTab: callback => {
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) const handler = (
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } _event: Electron.IpcRendererEvent,
}, data: { filePath: string; content: string },
onDirChanged: (callback) => { ) => callback(data)
const handler = () => callback() ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) return () => {
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
}, }
onConfirmClose: (callback) => { },
const handler = () => callback() onExternalModification: callback => {
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
} return () => {
} ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
}
contextBridge.exposeInMainWorld('electronAPI', api) },
onDirChanged: callback => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
}
},
onConfirmClose: callback => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
}
},
}
contextBridge.exposeInMainWorld('electronAPI', api)
+16 -16
View File
@@ -1,16 +1,16 @@
/** /**
* DX-02: Preload 类型安全声明 * DX-02: Preload 类型安全声明
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义, * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
* preload/index.ts 引入该类型做编译期契约检查。 * preload/index.ts 引入该类型做编译期契约检查。
* 本文件仅保留全局 Window 增强声明。 * 本文件仅保留全局 Window 增强声明。
*/ */
import type { ElectronAPI } from '../renderer/types/ipc' import type { ElectronAPI } from '../renderer/types/ipc'
declare global { declare global {
interface Window { interface Window {
electronAPI: ElectronAPI electronAPI: ElectronAPI
} }
} }
export {} export {}
+190 -120
View File
@@ -1,120 +1,190 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useTabStore } from './stores/tabStore' import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore' import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore' import { useEditorStore, getMetonaEditor } from './stores/editorStore'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
import { useSettingsInit } from './hooks/useSettingsInit' import { useSettingsInit } from './hooks/useSettingsInit'
import { useKeyboard } from './hooks/useKeyboard' import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning' import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch' import { useFileWatch } from './hooks/useFileWatch'
import { useFileOperations } from './hooks/useFileOperations' import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop' import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners' import { useIpcListeners } from './hooks/useIpcListeners'
import { useConfirm } from './hooks/useConfirm' import { MeToast, showToast } from './lib/toast'
import { Toolbar } from './components/Toolbar/Toolbar' import { closeDatabase } from './db/schema'
import { TabBar } from './components/TabBar/TabBar' import { recentFilesRepository } from './db/recentFilesRepository'
import { Editor } from './components/Editor/Editor' import { Toolbar } from './components/Toolbar/Toolbar'
import { Sidebar } from './components/Sidebar/Sidebar' import { TabBar } from './components/TabBar/TabBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' import { Editor } from './components/Editor/Editor'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' import { Sidebar } from './components/Sidebar/Sidebar'
import { DropOverlay } from './components/DropOverlay/DropOverlay' import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ErrorBoundary } from './components/ErrorBoundary' import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { AboutDialog } from './components/AboutDialog' import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog' import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
export function App() { import { SearchPanel } from './components/SearchPanel'
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) export function App() {
const createTab = useTabStore(s => s.createTab) const tabs = useTabStore(s => s.tabs)
const setModified = useTabStore(s => s.setModified) const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent) const createTab = useTabStore(s => s.createTab)
const loadFromDB = useTabStore(s => s.loadFromDB) const setModified = useTabStore(s => s.setModified)
const viewMode = useEditorStore(s => s.viewMode) const updateTabContent = useTabStore(s => s.updateTabContent)
const externallyModified = useEditorStore(s => s.externallyModified) const loadFromDB = useTabStore(s => s.loadFromDB)
const setExternallyModified = useEditorStore(s => s.setExternallyModified) const viewMode = useEditorStore(s => s.viewMode)
const { themeMode, cycleTheme } = useTheme() const externallyModified = useEditorStore(s => s.externallyModified)
const { confirm, confirmDialogProps } = useConfirm() const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const [showAbout, setShowAbout] = useState(false) const { themeMode, cycleTheme } = useTheme()
const handleCloseAbout = useCallback(() => setShowAbout(false), []) const [showAbout, setShowAbout] = useState(false)
const [showSearch, setShowSearch] = useState(false)
useSettingsInit() const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useEffect(() => { loadFromDB() }, [loadFromDB]) const handleCloseSearch = useCallback(() => setShowSearch(false), [])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations() useSettingsInit()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave() useEffect(() => {
useDragDrop() loadFromDB()
useFileWatch() }, [loadFromDB])
// useAutoSave() already called above to get state for toolbar
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
// UX-01: 传入 confirm 函数替代原生 confirm() const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => { useDragDrop()
return confirm({ useFileWatch()
title: '未保存的更改', // useAutoSave() already called above to get state for toolbar
message,
variant: 'warning', // UX-01: 传入 confirm 函数替代原生 confirm()
confirmLabel: '不保存', // v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false)
cancelLabel: '取消' const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
}) return MeToast.confirm(message, {
}, [confirm]) title: '未保存的更改',
confirmText: '不保存',
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) cancelText: '取消',
useKeyboard(handleOpenFile, handleSave, handleSaveAs) })
useIpcListeners() }, [])
useEffect(() => { // v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
if (!window.electronAPI) return const handleBeforeForceClose = useCallback(async () => {
const activeTab = tabs.find(t => t.id === activeTabId) await flushSaveToDB()
window.electronAPI.tabSwitched(activeTab?.filePath ?? null) await closeDatabase()
}, [activeTabId, tabs]) }, [])
const handleReloadModified = useCallback(async () => { useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
if (!externallyModified?.filePath || !window.electronAPI) return useKeyboard(handleOpenFile, handleSave, handleSaveAs, () => setShowSearch(true))
const tab = tabs.find(t => t.filePath === externallyModified.filePath) useIpcListeners()
if (tab?.isModified) return
if (!tab) return // v0.6.2: 多文件搜索 — 打开结果文件并定位到匹配行
const result = await window.electronAPI.readFile(externallyModified.filePath) const handleSearchOpenResult = useCallback(
if (result.success && result.content !== undefined) { async (filePath: string, line: number) => {
updateTabContent(tab.id, result.content) const existing = tabs.find(t => t.filePath === filePath)
setModified(tab.id, false) if (existing) {
} useTabStore.getState().switchToTab(existing.id)
setExternallyModified(null) } else if (window.electronAPI) {
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified]) const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content !== undefined) {
return ( createTab(filePath, result.content)
<ErrorBoundary> recentFilesRepository.add(filePath)
<div id="app" className={`mode-${viewMode}`}> } else {
<Toolbar showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
onOpen={handleOpenFile} onSave={handleSave} return
themeMode={themeMode} onCycleTheme={cycleTheme} }
onShowAbout={() => setShowAbout(true)} }
isAutoSaving={isAutoSaving} setShowSearch(false)
autoSaveEnabled={autoSaveEnabled} // 等待编辑器完成标签切换后再定位
onToggleAutoSave={toggleAutoSave} setTimeout(() => {
/> const editor = getMetonaEditor()
<div id="workspace"> if (editor) {
<Sidebar /> editor.scrollToLine(line)
<div id="main-content"> editor.setCursorPosition(line, 0)
<TabBar /> editor.focus()
{externallyModified && ( }
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} /> }, 150)
)} },
{tabs.length > 0 ? ( [tabs, createTab],
<div id="content-wrapper"> )
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
</div> // B3-fix: 只依赖活动文件的路径 — 此前依赖 tabs 数组导致每次击键
) : ( // updateTabContent)都触发 tabSwitched IPC 并重启主进程文件 watcher
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} /> const activeFilePath = useMemo(
)} () => tabs.find(t => t.id === activeTabId)?.filePath ?? null,
</div> [tabs, activeTabId],
</div> )
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />} useEffect(() => {
<ConfirmDialog {...confirmDialogProps} /> if (!window.electronAPI) return
</div> window.electronAPI.tabSwitched(activeFilePath)
</ErrorBoundary> }, [activeFilePath])
)
} const handleReloadModified = useCallback(async () => {
if (!externallyModified?.filePath || !window.electronAPI) return
App.displayName = 'App' const tab = tabs.find(t => t.filePath === externallyModified.filePath)
export default App if (!tab) {
setExternallyModified(null)
return
}
// B6-fix: 标签有未保存修改时不再静默无反应,显式提示
if (tab.isModified) {
showToast('当前标签有未保存的修改,请先保存或放弃修改后再重新加载', 'warning')
return
}
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content !== undefined) {
updateTabContent(tab.id, result.content)
setModified(tab.id, false)
} else {
showToast(`重新加载失败: ${result.error ?? '未知错误'}`, 'error')
}
setExternallyModified(null)
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
return (
<ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
onOpen={handleOpenFile}
onSave={handleSave}
themeMode={themeMode}
onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
onShowSearch={() => setShowSearch(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner
filePath={externallyModified.filePath}
onReload={handleReloadModified}
onDismiss={() => setExternallyModified(null)}
/>
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
<div id="editor-panel">
<Editor themeMode={themeMode} onAppSave={handleSave} />
</div>
</div>
) : (
<WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/>
)}
</div>
</div>
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
{showSearch && (
<SearchPanel onClose={handleCloseSearch} onOpenResult={handleSearchOpenResult} />
)}
</div>
</ErrorBoundary>
)
}
App.displayName = 'App'
export default App
+14 -14
View File
@@ -1,14 +1,14 @@
declare module '*.png' { declare module '*.png' {
const src: string const src: string
export default src export default src
} }
declare module '*.svg' { declare module '*.svg' {
const src: string const src: string
export default src export default src
} }
declare module '*.ico' { declare module '*.ico' {
const src: string const src: string
export default src export default src
} }
@@ -1,57 +1,66 @@
import React from 'react' import React from 'react'
import { AppIcon, Gitee } from '../Icons' import { AppIcon, Gitee } from '../Icons'
import { APP_VERSION } from '../../lib/constants' import { APP_VERSION } from '../../lib/constants'
interface AboutDialogProps { interface AboutDialogProps {
onClose: () => void onClose: () => void
} }
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) { export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => { const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault() e.preventDefault()
if (window.electronAPI?.openExternal) { if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite') window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
} else { } else {
window.open('https://git.metona.cn/MetonaTeam/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
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}> className="about-overlay"
<div className="about-header"> onClick={onClose}
<AppIcon size={64} /> role="dialog"
<h2>MarkLite</h2> aria-modal="true"
<span className="about-version">{APP_VERSION}</span> aria-label="关于 MarkLite"
</div> >
<div className="about-body"> <div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<p> Windows Markdown </p> <div className="about-header">
<div className="about-features"> <AppIcon size={64} />
<span></span> <h2>MarkLite</h2>
<span></span> <span className="about-version">{APP_VERSION}</span>
<span></span> </div>
<span></span> <div className="about-body">
<span></span> <p> Windows Markdown </p>
<span></span> <div className="about-features">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
</div> <span></span>
</div> <span></span>
<div className="about-footer"> <span></span>
<a className="about-link" href="#" onClick={handleLinkClick}> <span></span>
<Gitee size={16} /> <span></span>
<span>git.metona.cn/MetonaTeam/MarkLite</span> <span></span>
</a> <span></span>
<p> Electron + React + TypeScript </p> </div>
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</p> </div>
<p className="about-copyright">© 2026 thzxx</p> <div className="about-footer">
</div> <a className="about-link" href="#" onClick={handleLinkClick}>
<button className="about-close-btn" onClick={onClose}></button> <Gitee size={16} />
</div> <span>git.metona.cn/MetonaTeam/MarkLite</span>
</div> </a>
) <p> Electron + React + TypeScript </p>
}) <p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.7.4</p>
<p className="about-copyright">© 2026 thzxx</p>
AboutDialog.displayName = 'AboutDialog' </div>
<button className="about-close-btn" onClick={onClose}>
</button>
</div>
</div>
)
})
AboutDialog.displayName = 'AboutDialog'
@@ -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'
+294 -249
View File
@@ -1,249 +1,294 @@
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor' import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor } from '@metona-team/metona-editor' import type { MarkdownEditor } from '@metona-team/metona-editor'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore' import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository' import { settingsRepository } from '../../db/settingsRepository'
import { renderMarkdownSync } from '../../lib/markdown' import { renderMarkdownSync } from '../../lib/markdown'
import type { ThemeMode } from '../../types/settings' import type { ThemeMode } from '../../types/settings'
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全 // v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
type EditMode = 'edit' | 'split' | 'preview' type EditMode = 'edit' | 'split' | 'preview'
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
// v0.4.5: Mermaid 初始化(全局一次性配置) // v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' }) mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps { interface EditorProps {
themeMode: ThemeMode themeMode: ThemeMode
onAppSave?: () => void onAppSave?: () => void
} }
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */ /** 将应用 viewMode 映射到 MetonaEditor 的 mode */
function mapViewMode(vm: string): EditMode { function mapViewMode(vm: string): EditMode {
if (vm === 'source') return 'edit' if (vm === 'source') return 'edit'
if (vm === 'preview') return 'preview' if (vm === 'preview') return 'preview'
return 'split' return 'split'
} }
/** 将 MetonaEditor mode 反向映射到应用 viewMode */ /** 将 MetonaEditor mode 反向映射到应用 viewMode */
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' { function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
if (mode === 'edit') return 'source' if (mode === 'edit') return 'source'
if (mode === 'preview') return 'preview' if (mode === 'preview') return 'preview'
return 'editor' return 'editor'
} }
/** 预设插件(字符串形式,与 demo 一致) */ /** 预设插件(字符串形式,与 demo 一致) */
const EDITOR_PLUGINS: string[] = [ const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H 'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片 'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML 'exportTool', // 导出 MD/HTML
'shortcutHelp', // 按 ? 弹出快捷键面板 'shortcutHelp', // 按 ? 弹出快捷键面板
] ]
/** /**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。 * Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/ */
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) { export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
const tabs = useTabStore(s => s.tabs) 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() // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染 // 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo( const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
() => tabs.find(t => t.id === activeTabId) ?? null, const updateTabContent = useTabStore(s => s.updateTabContent)
[tabs, activeTabId] const setModified = useTabStore(s => s.setModified)
) const updateTabScroll = useTabStore(s => s.updateTabScroll)
const updateTabContent = useTabStore(s => s.updateTabContent) const viewMode = useEditorStore(s => s.viewMode)
const setModified = useTabStore(s => s.setModified) const setViewMode = useEditorStore(s => s.setViewMode)
const updateTabScroll = useTabStore(s => s.updateTabScroll) const setZenMode = useEditorStore(s => s.setZenMode)
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode) const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const currentContentRef = useRef('')
const editorRef = useRef<MarkdownEditor | null>(null)
const currentContentRef = useRef('') // 稳定的回调引用
const activeTabIdRef = useRef(activeTabId)
// 稳定的回调引用 activeTabIdRef.current = activeTabId
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId // ── 初始化 MetonaEditor ──────────────────────────────────
useEffect(() => {
// ── 初始化 MetonaEditor ────────────────────────────────── const container = containerRef.current
useEffect(() => { if (!container) return
const container = containerRef.current
if (!container) return const config = {
value: activeTab?.content ?? '',
const config = { mode: mapViewMode(viewMode),
value: activeTab?.content ?? '', height: '100%',
mode: mapViewMode(viewMode), toolbar: [
height: '100%', 'bold',
toolbar: [ 'italic',
'bold', 'italic', 'strikethrough', 'underline', 'code', '|', 'strikethrough',
'h1', 'h2', 'h3', '|', 'underline',
'quote', 'ul', 'ol', 'indent', 'outdent', '|', 'code',
'link', 'image', 'table', 'hr', '|', '|',
'undo', 'redo', '|', 'h1',
'edit', 'split', 'preview', 'fullscreen' 'h2',
], 'h3',
locale: 'zh-CN', '|',
theme: themeMode as ThemeName, 'quote',
placeholder: '在此输入 Markdown 内容...', 'ul',
spellcheck: false, 'ol',
tabSize: 2, 'indent',
wordCount: true, 'outdent',
autofocus: true, '|',
lineNumbers: true, 'link',
autoBrackets: true, 'image',
readOnly: viewMode === 'preview', 'table',
plugins: EDITOR_PLUGINS, 'hr',
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮) '|',
floatingToolbar: true, 'undo',
// v0.2.4 新增配置项 'redo',
syncScroll: true, '|',
wordWrap: true, 'edit',
outline: false, // 使用自研 OutlinePanel 'split',
historyLimit: 100, 'preview',
historyDebounce: 400, 'fullscreen',
],
// 使用 unified 管线渲染,保留图片路径修复能力 locale: 'zh-CN',
render: (md: string) => { theme: themeMode as ThemeName,
const tabId = activeTabIdRef.current placeholder: '在此输入 Markdown 内容...',
const filePath = tabId spellcheck: false,
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null) tabSize: 2,
: null wordCount: true,
return renderMarkdownSync(md, filePath) autofocus: true,
}, lineNumbers: true,
autoBrackets: true,
// 内容变化 → 同步到 tabStore readOnly: viewMode === 'preview',
onChange: (value: string) => { plugins: EDITOR_PLUGINS,
const tabId = activeTabIdRef.current // v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
if (!tabId) return floatingToolbar: true,
const tab = useTabStore.getState().tabs.find(t => t.id === tabId) // v0.6.1: 专注模式宽度使用 0.4.1 官方 API — 100% 全宽(默认 960px 偏窄)
if (tab?.content === value) return zenMaxWidth: '100%',
currentContentRef.current = value // v0.2.4 新增配置项
updateTabContent(tabId, value) syncScroll: true,
setModified(tabId, true) wordWrap: true,
}, outline: false, // 使用自研 OutlinePanel
historyLimit: 100,
// 模式切换 → 同步到 editorStore 并持久化 historyDebounce: 400,
onModeChange: (mode: string) => {
const mapped = reverseMapMode(mode as EditMode) // 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
setViewMode(mapped) // v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
settingsRepository.save({ viewMode: mapped }) highlight: MeEditor.highlight,
}, render: (md: string) => {
const tabId = activeTabIdRef.current
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统) const filePath = tabId
onSave: () => { ? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
onAppSave?.() : null
}, return renderMarkdownSync(md, filePath)
},
// v0.2.4 新增回调: 链接点击 → 安全打开外部链接
onLinkClick: (href: string) => { // 内容变化 → 同步到 tabStore
if (window.electronAPI?.openExternal) { onChange: (value: string) => {
window.electronAPI.openExternal(href) const tabId = activeTabIdRef.current
} if (!tabId) return
}, const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
if (tab?.content === value) return
// v0.2.4 新增回调: 焦点事件(预留扩展点) currentContentRef.current = value
onFocus: () => { updateTabContent(tabId, value)
// 编辑器获得焦点 setModified(tabId, true)
}, },
onBlur: () => {
// 编辑器失去焦点 // 模式切换 → 同步到 editorStore 并持久化
}, onModeChange: (mode: string) => {
} const mapped = reverseMapMode(mode as EditMode)
setViewMode(mapped)
const editor = MeEditor.create(container, config) settingsRepository.save({ viewMode: mapped })
},
editorRef.current = editor
setMetonaEditorGetter(() => editor) // Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
currentContentRef.current = activeTab?.content ?? '' onSave: () => {
onAppSave?.()
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染 },
const renderMermaid = () => {
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ } // v0.2.4 新增回调: 链接点击 → 安全打开外部链接
} onLinkClick: (href: string) => {
editor.on('afterRender', renderMermaid) if (window.electronAPI?.openExternal) {
// 首次渲染后延迟触发一次 window.electronAPI.openExternal(href)
setTimeout(renderMermaid, 300) }
},
return () => {
editor.destroy() // v0.2.4 新增回调: 焦点事件(预留扩展点)
editorRef.current = null onFocus: () => {
setMetonaEditorGetter(() => null) // 编辑器获得焦点
} },
// 仅在挂载时创建一次 onBlur: () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // 编辑器失去焦点
}, []) },
}
// ── 标签切换:同步内容到编辑器 ──────────────────────────
useEffect(() => { const editor = MeEditor.create(container, config)
if (!activeTab || !editorRef.current) return
if (activeTab.content === currentContentRef.current) return editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab.content currentContentRef.current = activeTab?.content ?? ''
// silent: true — 不触发 onChange,避免重复更新 tabStore // v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
editorRef.current.setValue(activeTab.content, { silent: true }) const renderMermaid = () => {
try {
// 恢复滚动位置 mermaid.run({ querySelector: '.me-mermaid .mermaid' })
requestAnimationFrame(() => { } catch {
const c = containerRef.current /* 容错 */
if (!c) return }
const textarea = c.querySelector('textarea') }
if (textarea) { editor.on('afterRender', renderMermaid)
textarea.scrollTop = activeTab.scrollTop // 首次渲染后延迟触发一次
} setTimeout(renderMermaid, 300)
const preview = c.querySelector('.me-preview') as HTMLElement | null
if (preview) { // v0.6.0: Zen 状态同步 → editorStoreToolbar 消费)
preview.scrollTop = activeTab.scrollTop // 文档统计由编辑器自带底栏展示(wordCount: true),无需应用层同步
} const syncZen = (zen: boolean) => {
}) setZenMode(Boolean(zen))
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [activeTabId]) editor.on('zenChange', syncZen)
// ── 取消挂载/标签切换前保存滚动位置 ────────────────────── return () => {
useEffect(() => { editor.destroy()
const c = containerRef.current editorRef.current = null
return () => { setMetonaEditorGetter(() => null)
if (!activeTabId || !editorRef.current || !c) return }
const textarea = c.querySelector('textarea') // 仅在挂载时创建一次
const preview = c.querySelector('.me-preview') as HTMLElement | null // eslint-disable-next-line react-hooks/exhaustive-deps
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0 }, [])
updateTabScroll(activeTabId, { scrollTop })
} // ── 标签切换:同步内容到编辑器 ──────────────────────────
// eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => {
}, [activeTabId]) if (!activeTab || !editorRef.current) return
if (activeTab.content === currentContentRef.current) return
// ── 主题同步 ──────────────────────────────────────────
useEffect(() => { currentContentRef.current = activeTab.content
try {
// 全局主题(documentElement + localStorage // silent: true — 不触发 onChange,避免重复更新 tabStore
MeEditor.setTheme(themeMode) editorRef.current.setValue(activeTab.content, { silent: true })
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode) // 恢复滚动位置 + 光标位置
} catch { requestAnimationFrame(() => {
// 容错 const c = containerRef.current
} if (!c) return
}, [themeMode]) const textarea = c.querySelector('textarea')
if (textarea) {
// ── 视图模式同步 ────────────────────────────────────────── textarea.scrollTop = activeTab.scrollTop
useEffect(() => { }
const editor = editorRef.current const preview = c.querySelector('.me-preview') as HTMLElement | null
if (!editor) return if (preview) {
const targetMode = mapViewMode(viewMode) preview.scrollTop = activeTab.scrollTop
if (editor.getMode() !== targetMode) { }
editor.setMode(targetMode) // B10-fix: 恢复光标位置(此前 selectionStart/End 已持久化但从未恢复)
} const editor = editorRef.current
// 预览模式设为只读 if (editor && activeTab.selectionStart > 0) {
editor.setReadOnly(viewMode === 'preview') const before = activeTab.content.substring(
}, [viewMode]) 0,
Math.min(activeTab.selectionStart, activeTab.content.length),
return ( )
<div className="editor-container" role="region" aria-label="Markdown编辑器"> const lines = before.split('\n')
<div ref={containerRef} className="metona-editor-wrapper" /> editor.setCursorPosition(lines.length, lines[lines.length - 1].length)
</div> }
) })
}) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
Editor.displayName = 'Editor'
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => {
const c = containerRef.current
return () => {
if (!activeTabId || !editorRef.current || !c) return
const textarea = c.querySelector('textarea')
const preview = c.querySelector('.me-preview') as HTMLElement | null
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
updateTabScroll(activeTabId, { scrollTop })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 主题同步 ──────────────────────────────────────────
useEffect(() => {
try {
// 全局主题(documentElement + localStorage
MeEditor.setTheme(themeMode)
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode)
} catch {
// 容错
}
}, [themeMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
// 预览模式设为只读
editor.setReadOnly(viewMode === 'preview')
}, [viewMode])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<div ref={containerRef} className="metona-editor-wrapper" />
</div>
)
})
Editor.displayName = 'Editor'
@@ -1,60 +1,56 @@
import { Component, ErrorInfo, ReactNode } from 'react' import { Component, ErrorInfo, ReactNode } from 'react'
interface Props { interface Props {
children: ReactNode children: ReactNode
fallback?: ReactNode fallback?: ReactNode
} }
interface State { interface State {
hasError: boolean hasError: boolean
error: Error | null error: Error | null
} }
export class ErrorBoundary extends Component<Props, State> { export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this.state = { hasError: false, error: null } this.state = { hasError: false, error: null }
} }
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
return { hasError: true, error } return { hasError: true, error }
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// eslint-disable-next-line no-console -- React error boundary standard pattern // eslint-disable-next-line no-console -- React error boundary standard pattern
console.error('ErrorBoundary caught an error:', error, errorInfo) console.error('ErrorBoundary caught an error:', error, errorInfo)
} }
handleReset = (): void => { handleReset = (): void => {
this.setState({ hasError: false, error: null }) this.setState({ hasError: false, error: null })
} }
render(): ReactNode { render(): ReactNode {
if (this.state.hasError) { if (this.state.hasError) {
if (this.props.fallback) { if (this.props.fallback) {
return this.props.fallback return this.props.fallback
} }
const isDev = const isDev =
(typeof import.meta !== 'undefined' && (typeof import.meta !== 'undefined' &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
false false
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"> <button className="error-boundary-reset" onClick={this.handleReset}>
{this.state.error?.message}
</pre> </button>
)} </div>
<button className="error-boundary-reset" onClick={this.handleReset}> )
}
</button>
</div> return this.props.children
) }
} }
return this.props.children
}
}
@@ -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') {
+356 -176
View File
@@ -1,176 +1,356 @@
import React from 'react' import React from 'react'
import appIconUrl from '../assets/icon.png' import appIconUrl from '../assets/icon.png'
// 统一图标 Props // 统一图标 Props
interface IconProps { interface IconProps {
size?: number size?: number
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
} }
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} />
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} /> }
)
} // ===== 工具栏图标 =====
export function FolderOpen({ size = defaultProps.size }: IconProps) {
// ===== 工具栏图标 ===== return (
export function FolderOpen({ size = defaultProps.size }: IconProps) { <svg
return ( width={size}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> height={size}
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/> viewBox="0 0 24 24"
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/> fill="none"
<path d="M12 11h4" strokeDasharray="2 2"/> stroke="currentColor"
</svg> strokeWidth="1.75"
) strokeLinecap="round"
} strokeLinejoin="round"
>
export function Save({ size = defaultProps.size }: IconProps) { <path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1" />
return ( <path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none" />
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <path d="M12 11h4" strokeDasharray="2 2" />
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> </svg>
<polyline points="17 21 17 13 7 13 7 21"/> )
<polyline points="7 3 7 8 15 8"/> }
</svg>
) export function Save({ size = defaultProps.size }: IconProps) {
} return (
<svg
export function Moon({ size = defaultProps.size }: IconProps) { width={size}
return ( height={size}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24"
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> fill="none"
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/> stroke="currentColor"
</svg> strokeWidth="1.75"
) strokeLinecap="round"
} strokeLinejoin="round"
>
export function Sun({ size = defaultProps.size }: IconProps) { <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
return ( <polyline points="17 21 17 13 7 13 7 21" />
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <polyline points="7 3 7 8 15 8" />
<circle cx="12" cy="12" r="5"/> </svg>
<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"/> export function Moon({ size = defaultProps.size }: IconProps) {
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> return (
<line x1="1" y1="12" x2="3" y2="12"/> <svg
<line x1="21" y1="12" x2="23" y2="12"/> width={size}
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> height={size}
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> viewBox="0 0 24 24"
</svg> fill="none"
) stroke="currentColor"
} strokeWidth="1.75"
strokeLinecap="round"
// ===== 工具栏右侧图标 ===== strokeLinejoin="round"
export function Gitee({ size = defaultProps.size }: IconProps) { >
return ( <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> <circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5" />
<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 Sun({ size = defaultProps.size }: IconProps) {
export function Info({ size = defaultProps.size }: IconProps) { return (
return ( <svg
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> width={size}
<circle cx="12" cy="12" r="10"/> height={size}
<line x1="12" y1="16" x2="12" y2="12"/> viewBox="0 0 24 24"
<line x1="12" y1="8" x2="12.01" y2="8"/> fill="none"
</svg> stroke="currentColor"
) strokeWidth="1.75"
} strokeLinecap="round"
strokeLinejoin="round"
// ===== 标签栏图标 ===== >
export function Close({ size = 10 }: IconProps) { <circle cx="12" cy="12" r="5" />
return ( <circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3" />
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <line x1="12" y1="1" x2="12" y2="3" />
<line x1="18" y1="6" x2="6" y2="18"/> <line x1="12" y1="21" x2="12" y2="23" />
<line x1="6" y1="6" x2="18" y2="18"/> <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
</svg> <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" />
export function Plus({ size = 14 }: IconProps) { <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
return ( </svg>
<svg width={size} 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> // ===== 工具栏右侧图标 =====
) export function Gitee({ size = defaultProps.size }: IconProps) {
} return (
<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" />
export function Folder({ size = 14 }: IconProps) { </svg>
return ( )
<svg width={size} 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"/> export function Info({ size = defaultProps.size }: IconProps) {
</svg> return (
) <svg
} width={size}
height={size}
export function File({ size = 14 }: IconProps) { viewBox="0 0 24 24"
return ( fill="none"
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> stroke="currentColor"
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> strokeWidth="1.75"
<polyline points="14 2 14 8 20 8"/> strokeLinecap="round"
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/> strokeLinejoin="round"
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/> >
</svg> <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>
export function ChevronRight({ size = 10 }: IconProps) { )
return ( }
<svg 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> export function Close({ size = 10 }: IconProps) {
) return (
} <svg
width={size}
export function FolderPlus({ size = 14 }: IconProps) { height={size}
return ( viewBox="0 0 24 24"
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> fill="none"
<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"/> stroke="currentColor"
<line x1="12" y1="11" x2="12" y2="17"/> strokeWidth="2.5"
<line x1="9" y1="14" x2="15" y2="14"/> strokeLinecap="round"
</svg> >
) <line x1="18" y1="6" x2="6" y2="18" />
} <line x1="6" y1="6" x2="18" y2="18" />
</svg>
// ===== 拖拽覆盖层图标 ===== )
export function UploadCloud({ size = 64 }: IconProps) { }
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"> export function Plus({ size = 14 }: IconProps) {
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/> return (
<polyline points="12 16 12 8"/> <svg
<polyline points="8 12 12 8 16 12"/> width={size}
<line x1="12" y1="16" x2="12" y2="22"/> height={size}
</svg> viewBox="0 0 24 24"
) fill="none"
} stroke="currentColor"
strokeWidth="2"
// ===== 欢迎屏幕图标 ===== strokeLinecap="round"
export function WelcomeFile({ size = 20 }: IconProps) { >
return ( <line x1="12" y1="5" x2="12" y2="19" />
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <line x1="5" y1="12" x2="19" y2="12" />
<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"/> </svg>
<line x1="12" y1="11" x2="12" y2="17"/> )
<line x1="9" y1="14" x2="15" y2="14"/> }
</svg>
) // ===== 侧边栏图标 =====
} export function Folder({ size = 14 }: IconProps) {
return (
export function WelcomeNew({ size = 20 }: IconProps) { <svg
return ( width={size}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> height={size}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> viewBox="0 0 24 24"
<polyline points="14 2 14 8 20 8"/> fill="none"
<line x1="12" y1="18" x2="12" y2="12"/> stroke="currentColor"
<line x1="9" y1="15" x2="15" y2="15"/> strokeWidth="1.75"
</svg> 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>
)
}
export function File({ size = 14 }: 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="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>
)
}
export function ChevronRight({ size = 10 }: IconProps) {
return (
<svg
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>
)
}
export function FolderPlus({ size = 14 }: 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="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>
)
}
// ===== 拖拽覆盖层图标 =====
export function UploadCloud({ size = 64 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
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>
)
}
// ===== v0.6.2: 多文件搜索图标 =====
export function SearchIcon({ 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"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</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>
)
}
// ===== 欢迎屏幕图标 =====
export function WelcomeFile({ size = 20 }: 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="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>
)
}
export function WelcomeNew({ size = 20 }: 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="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>
)
}
@@ -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'
@@ -1,16 +1,26 @@
import React from 'react' import React from 'react'
import { getFileName } from '../../lib/fileUtils'
interface ModifiedBannerProps { interface ModifiedBannerProps {
filePath: string
onReload: () => void onReload: () => void
onDismiss: () => void onDismiss: () => void
} }
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) { export const ModifiedBanner = React.memo(function ModifiedBanner({
filePath,
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>{getFileName(filePath)} </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>
) )
}) })
@@ -1,71 +1,71 @@
import React, { memo } from 'react' import React, { memo } from 'react'
import type { Heading } from './outlineUtils' import type { Heading } from './outlineUtils'
// --- Component --- // --- Component ---
interface OutlinePanelProps { interface OutlinePanelProps {
headings: Heading[] headings: Heading[]
onNavigate: (heading: Heading, index: number) => void onNavigate: (heading: Heading, index: number) => void
activeHeadingIndex: number | null activeHeadingIndex: number | null
} }
interface OutlineItemProps { interface OutlineItemProps {
heading: Heading heading: Heading
index: number index: number
isActive: boolean isActive: boolean
onNavigate: (heading: Heading, index: number) => void onNavigate: (heading: Heading, index: number) => void
} }
const OutlineItem = memo(function OutlineItem({ const OutlineItem = memo(function OutlineItem({
heading, heading,
index, index,
isActive, isActive,
onNavigate onNavigate,
}: OutlineItemProps) { }: OutlineItemProps) {
return ( return (
<button <button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`} className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading, index)} onClick={() => onNavigate(heading, index)}
title={heading.text} title={heading.text}
aria-label={`跳转到标题:${heading.text}`} aria-label={`跳转到标题:${heading.text}`}
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }} style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
> >
<span className="outline-level-dot" /> <span className="outline-level-dot" />
<span className="outline-item-text">{heading.text}</span> <span className="outline-item-text">{heading.text}</span>
</button> </button>
) )
}) })
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 (
<div className="outline-panel" role="region" aria-label="文档大纲"> <div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div> <div className="outline-header"></div>
<div className="outline-empty"></div> <div className="outline-empty"></div>
</div> </div>
) )
} }
return ( return (
<div className="outline-panel" role="region" aria-label="文档大纲"> <div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div> <div className="outline-header"></div>
<div className="outline-list" role="list" aria-label="标题列表"> <div className="outline-list" role="list" aria-label="标题列表">
{headings.map((h, i) => ( {headings.map((h, i) => (
<OutlineItem <OutlineItem
key={`${h.text}-${i}`} key={`${h.text}-${i}`}
heading={h} heading={h}
index={i} index={i}
isActive={i === activeHeadingIndex} isActive={i === activeHeadingIndex}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
))} ))}
</div> </div>
</div> </div>
) )
}) })
OutlinePanel.displayName = 'OutlinePanel' OutlinePanel.displayName = 'OutlinePanel'
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { parseHeadings } from '../outlineUtils'
describe('parseHeadings', () => {
it('should parse headings from markdown', () => {
const headings = parseHeadings('# Title\n## Section\ncontent')
expect(headings).toHaveLength(2)
expect(headings[0]).toEqual({ level: 1, text: 'Title', pos: 0 })
expect(headings[1].level).toBe(2)
expect(headings[1].text).toBe('Section')
})
it('should skip headings inside fenced code blocks', () => {
const md = [
'# Real Title',
'```markdown',
'# Fake Heading',
'## Fake Sub',
'```',
'## After Code',
].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['Real Title', 'After Code'])
})
it('should skip headings inside tilde code blocks', () => {
const md = ['# A', '~~~', '### Inside', '~~~', '## B'].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['A', 'B'])
})
it('should keep position offsets (pos) correct', () => {
const md = 'aaa\n## B'
const headings = parseHeadings(md)
expect(headings).toHaveLength(1)
expect(headings[0].pos).toBe(4)
})
it('should handle unclosed fence (rest treated as code)', () => {
const md = ['# A', '```', '## Inside', '# Also Inside'].join('\n')
const headings = parseHeadings(md)
expect(headings.map(h => h.text)).toEqual(['A'])
})
it('should return empty array for empty markdown', () => {
expect(parseHeadings('')).toEqual([])
})
})
@@ -1,3 +1,3 @@
export { OutlinePanel } from './OutlinePanel' export { OutlinePanel } from './OutlinePanel'
export { parseHeadings } from './outlineUtils' export { parseHeadings } from './outlineUtils'
export type { Heading } from './outlineUtils' export type { Heading } from './outlineUtils'
@@ -1,27 +1,44 @@
export interface Heading { export interface Heading {
level: number level: number
text: string text: string
/** Position in document (character offset from markdown source) */ /** Position in document (character offset from markdown source) */
pos: number pos: number
} }
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm /**
* Parse headings from raw markdown content using regex.
/** *
* Parse headings from raw markdown content using regex. * B9-fix: 逐行扫描并跟踪代码围栏(``` / ~~~)状态,
*/ * 跳过代码块内的 # 行(此前会被误判为标题,大纲点击导航到错误位置)。
export function parseHeadings(markdown: string): Heading[] { */
const headings: Heading[] = [] export function parseHeadings(markdown: string): Heading[] {
let match: RegExpExecArray | null const headings: Heading[] = []
const lines = markdown.split('\n')
// Reset regex state let pos = 0
HEADING_RE.lastIndex = 0 let fence: string | null = null // 当前围栏标记(``` 或 ~~~),null = 不在代码块内
while ((match = HEADING_RE.exec(markdown)) !== null) { for (const line of lines) {
const level = match[1].length const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line)
const text = match[2].trim() if (fenceMatch) {
headings.push({ level, text, pos: match.index }) const marker = fenceMatch[2][0]
} if (fence === null) {
fence = marker
return headings } else if (marker === fence && fenceMatch[1].length < 4) {
} fence = null
}
pos += line.length + 1
continue
}
if (fence === null) {
const m = /^(#{1,6})\s+(.+)$/.exec(line)
if (m) {
headings.push({ level: m[1].length, text: m[2].trim(), pos })
}
}
pos += line.length + 1
}
return headings
}
@@ -0,0 +1,171 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { SearchMatch } from '../../../shared/types'
interface SearchPanelProps {
onClose: () => void
onOpenResult: (filePath: string, line: number) => void
}
/**
* v0.6.2: 多文件搜索面板 — 在侧边栏已打开的文件夹内递归搜索
* md/markdown/txt 文件内容,结果按 文件+行号 展示,点击打开并定位。
*/
export const SearchPanel = React.memo(function SearchPanel({ onClose, onOpenResult }: SearchPanelProps) {
const rootPath = useSidebarStore(s => s.rootPath)
const [query, setQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [searching, setSearching] = useState(false)
const [matches, setMatches] = useState<SearchMatch[]>([])
const [totalFiles, setTotalFiles] = useState(0)
const [truncated, setTruncated] = useState(false)
const [searched, setSearched] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
const runSearch = useCallback(
async (q: string) => {
if (!q.trim()) return
if (!rootPath || !window.electronAPI) {
showToast('请先在侧边栏打开一个文件夹', 'warning')
return
}
setSearching(true)
setSearched(true)
try {
const result = await window.electronAPI.searchInDir({
dirPath: rootPath,
query: q,
caseSensitive,
useRegex,
})
if (result.success) {
setMatches(result.matches ?? [])
setTotalFiles(result.totalFiles ?? 0)
setTruncated(result.truncated ?? false)
} else {
setMatches([])
showToast(result.error ?? '搜索失败', 'error')
}
} catch (error) {
logError('多文件搜索失败', error)
showToast('搜索失败', 'error')
} finally {
setSearching(false)
}
},
[rootPath, caseSensitive, useRegex],
)
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
runSearch(query)
},
[query, runSearch],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
},
[onClose],
)
return (
<div
className="search-overlay"
role="dialog"
aria-modal="true"
aria-label="多文件搜索"
onKeyDown={handleKeyDown}
onClick={onClose}
>
<div className="search-panel" onClick={e => e.stopPropagation()}>
<div className="search-header">
<form className="search-form" onSubmit={handleSubmit}>
<input
ref={inputRef}
className="search-input"
type="text"
placeholder={
rootPath ? `${getFileName(rootPath)} 中搜索...` : '请先在侧边栏打开文件夹'
}
value={query}
onChange={e => setQuery(e.target.value)}
aria-label="搜索内容"
/>
<button className="search-run-btn" type="submit" disabled={searching || !query.trim()}>
{searching ? '搜索中...' : '搜索'}
</button>
</form>
<div className="search-options">
<label className="search-option">
<input
type="checkbox"
checked={caseSensitive}
onChange={e => setCaseSensitive(e.target.checked)}
/>
</label>
<label className="search-option">
<input
type="checkbox"
checked={useRegex}
onChange={e => setUseRegex(e.target.checked)}
/>
</label>
<button className="search-close-btn" onClick={onClose} aria-label="关闭搜索">
</button>
</div>
</div>
<div className="search-body">
{!searched ? (
<div className="search-empty">Ctrl+Shift+F </div>
) : searching ? (
<div className="search-empty">...</div>
) : matches.length === 0 ? (
<div className="search-empty">
{totalFiles > 0 ? `(已扫描 ${totalFiles} 个文件)` : ''}
</div>
) : (
<>
<div className="search-summary">
{matches.length}
{truncated && '(结果过多,仅显示前 500 条)'} · {totalFiles}
</div>
<div className="search-results" role="list" aria-label="搜索结果">
{matches.map((m, i) => (
<button
key={`${m.filePath}:${m.line}:${i}`}
className="search-result-item"
onClick={() => onOpenResult(m.filePath, m.line)}
title={m.filePath}
>
<span className="search-result-file">{getFileName(m.filePath)}</span>
<span className="search-result-line">:{m.line}</span>
<span className="search-result-text">{m.lineText}</span>
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
)
})
SearchPanel.displayName = 'SearchPanel'
@@ -0,0 +1 @@
export { SearchPanel } from './SearchPanel'
+217 -190
View File
@@ -1,190 +1,217 @@
import React, { useCallback, useMemo, useEffect, useRef } from 'react' import React, { useCallback, useMemo, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore' import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { recentFilesRepository } from '../../db/recentFilesRepository' import { recentFilesRepository } from '../../db/recentFilesRepository'
import { FolderPlus, File } from '../Icons' import { logError } from '../../lib/errorHandler'
import { FileTree } from '../FileTree' import { showToast } from '../../lib/toast'
import { useSidebarResize } from '../../hooks/useSidebarResize' import { FolderPlus, File } from '../Icons'
import { useFolderOperations } from '../../hooks/useFolderOperations' import { FileTree } from '../FileTree'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useActiveHeading } from '../../hooks/useActiveHeading' import { useFolderOperations } from '../../hooks/useFolderOperations'
import { OutlinePanel, parseHeadings } from '../OutlinePanel' import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import type { Heading } from '../OutlinePanel' import { useActiveHeading } from '../../hooks/useActiveHeading'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore' import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
function escapeRegex(s: string): string { const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
} function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const Sidebar = React.memo(function Sidebar() { }
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) export const Sidebar = React.memo(function Sidebar() {
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab() const tabs = useTabStore(s => s.tabs)
// 后者每次返回新对象引用导致 Zustand 无条件重渲染 const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId]) // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
const switchToTab = useTabStore(s => s.switchToTab) // 后者每次返回新对象引用导致 Zustand 无条件重渲染
const createTab = useTabStore(s => s.createTab) const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
const rootPath = useSidebarStore(s => s.rootPath) const switchToTab = useTabStore(s => s.switchToTab)
const tree = useSidebarStore(s => s.tree) const createTab = useTabStore(s => s.createTab)
const expandedDirs = useSidebarStore(s => s.expandedDirs) const rootPath = useSidebarStore(s => s.rootPath)
const toggleDir = useSidebarStore(s => s.toggleDir) const tree = useSidebarStore(s => s.tree)
const isVisible = useSidebarStore(s => s.isVisible) const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const activeFilePath = activeTab?.filePath ?? null const isVisible = useSidebarStore(s => s.isVisible)
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations() const activeFilePath = activeTab?.filePath ?? null
const setLoading = useEditorStore(s => s.setLoading) const { sidebarRef, startResize } = useSidebarResize()
const viewMode = useEditorStore(s => s.viewMode) const { handleOpenFolder } = useFolderOperations()
useAutoExpandDir(activeFilePath) const setLoading = useEditorStore(s => s.setLoading)
const viewMode = useEditorStore(s => s.viewMode)
// Parse headings from active tab content useAutoExpandDir(activeFilePath)
const headings = useMemo(() => {
if (!activeTab?.content) return [] // Parse headings from active tab content
return parseHeadings(activeTab.content) const headings = useMemo(() => {
}, [activeTab?.content]) if (!activeTab?.content) return []
return parseHeadings(activeTab.content)
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview }, [activeTab?.content])
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => { // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview
if (viewMode === 'preview') { const previewRef = useRef<HTMLElement | null>(null)
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null useEffect(() => {
} else { if (viewMode === 'preview') {
previewRef.current = null previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
} } else {
}, [viewMode]) previewRef.current = null
}
const activeHeadingIndex = useActiveHeading( }, [viewMode])
viewMode === 'preview' ? previewRef : { current: null },
headings const activeHeadingIndex = useActiveHeading(
) viewMode === 'preview' ? previewRef : { current: null },
headings,
// Navigate to heading in MetonaEditor )
const handleHeadingNavigate = useCallback((heading: Heading) => {
const editor = getMetonaEditor() // Navigate to heading in MetonaEditor
if (!editor) return // v0.6.0: 使用官方 APIscrollToLine + setCursorPosition)替代 DOM hack
const handleHeadingNavigate = useCallback((heading: Heading) => {
try { const editor = getMetonaEditor()
// 获取当前内容,查找标题文本在源代码中的位置 if (!editor) return
const content = editor.getValue()
const headingPattern = new RegExp( try {
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, // 获取当前内容,查找标题文本在源代码中的位置
'm' const content = editor.getValue()
) const headingPattern = new RegExp(`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm')
const match = headingPattern.exec(content) const match = headingPattern.exec(content)
if (!match) return if (!match) return
const pos = match.index // 按行号导航 — 官方 API 处理滚动与光标
const line = content.substring(0, match.index).split('\n').length
// 通过 DOM 操作滚动 textarea 到对应位置 editor.scrollToLine(line)
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null editor.setCursorPosition(line, 0)
if (!container) return editor.focus()
} catch {
const textarea = container.querySelector('textarea') // 导航失败,静默忽略
if (!textarea) return }
}, [])
// 估算滚动位置(简单方法:按行数比例)
const linesBefore = content.substring(0, pos).split('\n').length const handleFileClick = useCallback(
const lineHeight = 24 // 估算行高 async (path: string) => {
textarea.scrollTop = linesBefore * lineHeight const existing = tabs.find(t => t.filePath === path)
if (existing) {
// 设置光标位置 switchToTab(existing.id)
textarea.focus() return
textarea.setSelectionRange(pos, pos) }
} catch { if (!window.electronAPI) return
// 导航失败,静默忽略 setLoading('file-open', true)
} try {
}, []) const result = await window.electronAPI.readFile(path)
if (result.success && result.content !== undefined) {
const handleFileClick = useCallback(async (path: string) => { createTab(path, result.content)
const existing = tabs.find(t => t.filePath === path) recentFilesRepository.add(path)
if (existing) { switchToTab(existing.id); return } } else {
if (!window.electronAPI) return // B13-fix: 读取失败(文件被删除/权限问题)显式提示
setLoading('file-open', true) showToast(`无法打开 "${path}": ${result.error ?? '未知错误'}`, 'error')
try { }
const result = await window.electronAPI.readFile(path) } catch (error) {
if (result.success && result.content !== undefined) { logError('文件树打开文件失败', error)
createTab(path, result.content) showToast(`无法打开 "${path}"`, 'error')
recentFilesRepository.add(path) } finally {
} setLoading('file-open', false)
} finally { }
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
if (!rootPath) return true if (!rootPath) return true
return !norm(t.filePath).startsWith(norm(rootPath)) return !norm(t.filePath).startsWith(norm(rootPath))
}) })
if (!isVisible) return null if (!isVisible) return null
return ( return (
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器"> <aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
<div id="sidebar-header"> <div id="sidebar-header">
<span id="sidebar-title"></span> <span id="sidebar-title"></span>
<button <button
className="sidebar-header-btn" className="sidebar-header-btn"
onClick={handleOpenFolder} onClick={handleOpenFolder}
title="打开文件夹" title="打开文件夹"
aria-label="打开文件夹" aria-label="打开文件夹"
> >
<FolderPlus size={14} /> <FolderPlus size={14} />
</button> </button>
</div> </div>
<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">
{independentFiles.map(tab => (
<div key={tab.id} </div>
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`} {independentFiles.map(tab => (
style={{ paddingLeft: '8px' }} <div
role="treeitem" key={tab.id}
tabIndex={0} className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
aria-selected={tab.id === activeTabId} style={{ paddingLeft: '8px' }}
aria-label={getFileName(tab.filePath!)} role="treeitem"
onClick={() => switchToTab(tab.id)} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }} aria-selected={tab.id === activeTabId}
> aria-label={getFileName(tab.filePath!)}
<span className="tree-icon"><File size={14} /></span> onClick={() => switchToTab(tab.id)}
<span className="tree-name">{getFileName(tab.filePath!)}</span> onKeyDown={e => {
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>} if (e.key === 'Enter' || e.key === ' ') {
</div> e.preventDefault()
))} switchToTab(tab.id)
</div> }
)} }}
{rootPath && ( >
<> <span className="tree-icon">
<div className="sidebar-section-header" id="folder-tree-label"></div> <File size={14} />
<FileTree </span>
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]} <span className="tree-name">{getFileName(tab.filePath!)}</span>
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir} {tab.isModified && (
activeFilePath={activeFilePath} onFileClick={handleFileClick} <span className="independent-modified-dot" aria-label="已修改">
/> {' '}
</>
)} </span>
</nav> )}
<div className="sidebar-outline-section"> </div>
<OutlinePanel ))}
headings={headings} </div>
onNavigate={handleHeadingNavigate} )}
activeHeadingIndex={activeHeadingIndex} {rootPath && (
/> <>
</div> <div className="sidebar-section-header" id="folder-tree-label">
<div
className="sidebar-resize-handle" </div>
onMouseDown={startResize} <FileTree
role="separator" nodes={[
aria-orientation="vertical" {
aria-label="调整侧边栏宽度" name: rootPath.split(/[/\\]/).pop() || rootPath,
tabIndex={0} path: rootPath,
/> type: 'dir' as const,
</aside> children: tree,
) },
}) ]}
depth={0}
Sidebar.displayName = 'Sidebar' expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeFilePath={activeFilePath}
onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div className="sidebar-outline-section">
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={activeHeadingIndex}
/>
</div>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</aside>
)
})
Sidebar.displayName = 'Sidebar'
+306 -304
View File
@@ -1,304 +1,306 @@
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 x: number
x: number y: number
y: number tabId: string
tabId: string }
}
export const TabBar = React.memo(function TabBar() {
export const TabBar = React.memo(function TabBar() { const tabs = useTabStore(s => s.tabs)
const tabs = useTabStore(s => s.tabs) const activeTabId = useTabStore(s => s.activeTabId)
const activeTabId = useTabStore(s => s.activeTabId) const switchToTab = useTabStore(s => s.switchToTab)
const switchToTab = useTabStore(s => s.switchToTab) const closeTab = useTabStore(s => s.closeTab)
const closeTab = useTabStore(s => s.closeTab) const createTab = useTabStore(s => s.createTab)
const createTab = useTabStore(s => s.createTab) const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
const closeOtherTabs = useTabStore(s => s.closeOtherTabs) const closeAllTabs = useTabStore(s => s.closeAllTabs)
const closeAllTabs = useTabStore(s => s.closeAllTabs) const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const closeTabsToRight = useTabStore(s => s.closeTabsToRight) const moveTab = useTabStore(s => s.moveTab)
const moveTab = useTabStore(s => s.moveTab)
const tabListRef = useRef<HTMLDivElement>(null)
const tabListRef = useRef<HTMLDivElement>(null) const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' }) const [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 tabList = tabListRef.current
const scrollToActiveTab = useCallback(() => { if (!tabList) return
const tabList = tabListRef.current const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
if (!tabList) return if (!activeTab) return
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
if (!activeTab) return const listRect = tabList.getBoundingClientRect()
const tabRect = activeTab.getBoundingClientRect()
const listRect = tabList.getBoundingClientRect()
const tabRect = activeTab.getBoundingClientRect() // 如果标签在可视区域左侧之外
if (tabRect.left < listRect.left) {
// 如果标签在可视区域左侧之外 tabList.scrollLeft -= listRect.left - tabRect.left + 20
if (tabRect.left < listRect.left) { }
tabList.scrollLeft -= (listRect.left - tabRect.left + 20) // 如果标签在可视区域右侧之外
} else if (tabRect.right > listRect.right) {
// 如果标签在可视区域右侧之外 tabList.scrollLeft += tabRect.right - listRect.right + 20
else if (tabRect.right > listRect.right) { }
tabList.scrollLeft += (tabRect.right - listRect.right + 20) }, [])
}
}, []) // 自动滚动到活动标签
useEffect(() => {
// 自动滚动到活动标签 requestAnimationFrame(scrollToActiveTab)
useEffect(() => { }, [activeTabId, scrollToActiveTab])
requestAnimationFrame(scrollToActiveTab)
}, [activeTabId, scrollToActiveTab]) // 支持鼠标滚轮水平滚动标签栏
useEffect(() => {
// 支持鼠标滚轮水平滚动标签栏 const tabList = tabListRef.current
useEffect(() => { if (!tabList) return
const tabList = tabListRef.current
if (!tabList) return const handleWheel = (e: WheelEvent) => {
// 检查是否有水平溢出
const handleWheel = (e: WheelEvent) => { if (tabList.scrollWidth <= tabList.clientWidth) return
// 检查是否有水平溢出
if (tabList.scrollWidth <= tabList.clientWidth) return // 阻止默认滚动
e.preventDefault()
// 阻止默认滚动
e.preventDefault() // 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY tabList.scrollLeft += delta
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY }
tabList.scrollLeft += delta
} // 直接绑定到 tabList,使用 passive: false 允许 preventDefault
tabList.addEventListener('wheel', handleWheel, { passive: false })
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault return () => tabList.removeEventListener('wheel', handleWheel)
tabList.addEventListener('wheel', handleWheel, { passive: false }) }, [])
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 MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
const confirmed = await confirm({ title: '关闭标签',
title: '关闭标签', confirmText: '关闭',
message: `"${name}" 尚未保存,确定要关闭吗?`, cancelText: '取消',
variant: 'warning', })
confirmLabel: '关闭' if (!confirmed) return
}) }
if (!confirmed) return closeTab(tabId)
} },
closeTab(tabId) [tabs, closeTab],
}, [tabs, closeTab, confirm]) )
// C-06: 右键菜单(带边界修正) // C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const MENU_WIDTH = 170 const MENU_WIDTH = 170
const MENU_HEIGHT = 140 const MENU_HEIGHT = 140
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!menu.visible) return if (!menu.visible) return
const handleClick = () => setMenu(prev => ({ ...prev, visible: false })) const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
document.addEventListener('click', handleClick) document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick) return () => document.removeEventListener('click', handleClick)
}, [menu.visible]) }, [menu.visible])
const handleMenuClose = useCallback(async () => { const handleMenuClose = useCallback(async () => {
const tab = tabs.find(t => t.id === menu.tabId) const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) { if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名' const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({ const confirmed = await 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])
}, [tabs, menu.tabId, closeTab, confirm])
const handleMenuCloseOthers = useCallback(async () => {
const handleMenuCloseOthers = useCallback(async () => { const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) if (otherModified.length > 0) {
if (otherModified.length > 0) { const names = otherModified
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') .map(t => (t.filePath ? getFileName(t.filePath) : '未命名'))
const confirmed = await confirm({ .join('、')
title: '关闭其他标签', const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
message: `以下文件尚未保存:${names},确定要关闭吗?`, title: '关闭其他标签',
variant: 'warning', confirmText: '关闭',
confirmLabel: '关闭' cancelText: '取消',
}) })
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])
}, [tabs, closeAllTabs, confirm])
const handleMenuCloseRight = useCallback(async () => {
const handleMenuCloseRight = useCallback(async () => { const index = tabs.findIndex(t => t.id === menu.tabId)
const index = tabs.findIndex(t => t.id === menu.tabId) const rightTabs = tabs.slice(index + 1)
const rightTabs = tabs.slice(index + 1) const modified = rightTabs.filter(t => t.isModified)
const modified = rightTabs.filter(t => t.isModified) if (modified.length > 0) {
if (modified.length > 0) { const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
const confirmed = await confirm({ title: '关闭右侧标签',
title: '关闭右侧标签', confirmText: '关闭',
message: `以下文件尚未保存:${names},确定要关闭吗?`, cancelText: '取消',
variant: 'warning', })
confirmLabel: '关闭' if (!confirmed) return
}) }
if (!confirmed) return closeTabsToRight(menu.tabId)
} setMenu(prev => ({ ...prev, visible: false }))
closeTabsToRight(menu.tabId) }, [tabs, menu.tabId, closeTabsToRight])
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm]) // D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
// D1: 拖拽排序事件处理 dragTabIdRef.current = tabId
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { e.dataTransfer.effectAllowed = 'move'
dragTabIdRef.current = tabId e.dataTransfer.setData('text/plain', tabId)
e.dataTransfer.effectAllowed = 'move' // 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
e.dataTransfer.setData('text/plain', tabId) requestAnimationFrame(() => {
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获 const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
requestAnimationFrame(() => { el?.classList.add('dragging')
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement })
el?.classList.add('dragging') }, [])
})
}, []) const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.dataTransfer.dropEffect = 'move'
e.preventDefault() setDragOverIndex(index)
e.dataTransfer.dropEffect = 'move' }, [])
setDragOverIndex(index)
}, []) const handleDragLeave = useCallback(() => {
setDragOverIndex(null)
const handleDragLeave = useCallback(() => { }, [])
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 if (fromId) {
if (fromId) { moveTab(fromId, toIndex)
moveTab(fromId, toIndex) }
} 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(() => {
setDragOverIndex(null) const handleDragEnd = useCallback(() => {
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging')) setDragOverIndex(null)
dragTabIdRef.current = null document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, []) dragTabIdRef.current = null
}, [])
const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId) const hasRightTabs =
return index < tabs.length - 1 menu.visible &&
})() (() => {
const index = tabs.findIndex(t => t.id === menu.tabId)
if (tabs.length === 0) return null return index < tabs.length - 1
})()
return (
<> if (tabs.length === 0) return null
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页"> return (
{tabs.map((tab, index) => ( <>
<div <div id="tab-bar">
key={tab.id} <div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`} {tabs.map((tab, index) => (
role="tab" <div
aria-selected={tab.id === activeTabId} key={tab.id}
tabIndex={tab.id === activeTabId ? 0 : -1} className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
data-tab-id={tab.id} role="tab"
draggable aria-selected={tab.id === activeTabId}
onClick={() => switchToTab(tab.id)} tabIndex={tab.id === activeTabId ? 0 : -1}
onContextMenu={(e) => handleContextMenu(e, tab.id)} data-tab-id={tab.id}
onDragStart={(e) => handleDragStart(e, tab.id)} draggable
onDragOver={(e) => handleDragOver(e, index)} onClick={() => switchToTab(tab.id)}
onDragLeave={handleDragLeave} onContextMenu={e => handleContextMenu(e, tab.id)}
onDrop={(e) => handleDrop(e, index)} onDragStart={e => handleDragStart(e, tab.id)}
onDragEnd={handleDragEnd} onDragOver={e => handleDragOver(e, index)}
> onDragLeave={handleDragLeave}
<span className="tab-name"> onDrop={e => handleDrop(e, index)}
{tab.filePath ? getFileName(tab.filePath) : '未命名'} onDragEnd={handleDragEnd}
</span> >
<button <span className="tab-name">
className="tab-close" {tab.filePath ? getFileName(tab.filePath) : '未命名'}
onClick={(e) => handleClose(e, tab.id)} </span>
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`} <button
> className="tab-close"
<Close size={10} /> onClick={e => handleClose(e, tab.id)}
</button> aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
</div> >
))} <Close size={10} />
</div> </button>
<button </div>
className="tab-add-btn" ))}
onClick={() => createTab(null, '')} </div>
title="新建标签页 (Ctrl+T)" <button
aria-label="新建标签页" className="tab-add-btn"
> onClick={() => createTab(null, '')}
<Plus size={14} /> title="新建标签页 (Ctrl+T)"
</button> aria-label="新建标签页"
>
{/* 右键菜单 */} <Plus size={14} />
{menu.visible && ( </button>
<div
className="tab-context-menu" {/* 右键菜单 */}
style={{ left: menu.x, top: menu.y }} {menu.visible && (
role="menu" <div
aria-label="标签操作" className="tab-context-menu"
onClick={(e) => e.stopPropagation()} style={{ left: menu.x, top: menu.y }}
onMouseDown={(e) => e.stopPropagation()} role="menu"
> aria-label="标签操作"
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}> onClick={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
</div> >
{tabs.length > 1 && ( <div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
</div>
</div> {tabs.length > 1 && (
)} <div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
{hasRightTabs && (
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}> </div>
)}
</div> {hasRightTabs && (
)} <div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
<div className="tab-context-divider" role="separator" />
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}> </div>
)}
</div> <div className="tab-context-divider" role="separator" />
</div> <div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
)}
</div> </div>
<ConfirmDialog {...confirmDialogProps} /> </div>
</> )}
) </div>
}) </>
)
TabBar.displayName = 'TabBar' })
TabBar.displayName = 'TabBar'
+193 -71
View File
@@ -1,71 +1,193 @@
import React from 'react' import React, { useCallback } from 'react'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons' import { FolderOpen, Save, Moon, Sun, Info, Download, Upload, SearchIcon } from '../Icons'
import type { ThemeMode } from '../../types/settings' import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { backupRepository } from '../../db/backupRepository'
interface ToolbarProps { import { closeDatabase } from '../../db/schema'
onOpen: () => void import { flushSaveToDB } from '../../stores/tabStore'
onSave: () => void import { showToast } from '../../lib/toast'
themeMode: ThemeMode import { logError } from '../../lib/errorHandler'
onCycleTheme: () => void import type { ThemeMode } from '../../types/settings'
onShowAbout: () => void
isAutoSaving: boolean interface ToolbarProps {
autoSaveEnabled: boolean onOpen: () => void
onToggleAutoSave: () => void onSave: () => void
} themeMode: ThemeMode
onCycleTheme: () => void
const THEME_LABELS: Record<ThemeMode, string> = { onShowAbout: () => void
light: '亮色', onShowSearch: () => void
dark: '暗色', isAutoSaving: boolean
warm: '暖色', autoSaveEnabled: boolean
} onToggleAutoSave: () => void
}
/**
* const THEME_LABELS: Record<ThemeMode, string> = {
* MetonaEditor light: '亮色',
*/ dark: '暗色',
export const Toolbar = React.memo(function Toolbar({ warm: '暖色',
onOpen, onSave, themeMode, onCycleTheme, onShowAbout, }
isAutoSaving, autoSaveEnabled, onToggleAutoSave
}: ToolbarProps) { /**
const nextLabel = THEME_LABELS[themeMode] ?? '主题' *
* MetonaEditor
return ( */
<div id="toolbar" role="toolbar" aria-label="工具栏"> export const Toolbar = React.memo(function Toolbar({
<div className="toolbar-left" role="group" aria-label="文件操作"> onOpen,
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件"> onSave,
<FolderOpen size={18} /> themeMode,
<span></span> onCycleTheme,
</button> onShowAbout,
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件"> onShowSearch,
<Save size={18} /> isAutoSaving,
<span></span> autoSaveEnabled,
</button> onToggleAutoSave,
<div className="toolbar-divider" role="separator" /> }: ToolbarProps) {
<button const nextLabel = THEME_LABELS[themeMode] ?? '主题'
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`} const zenMode = useEditorStore(s => s.zenMode)
onClick={onToggleAutoSave}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')} // v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')} const handleToggleZen = useCallback(() => {
> const editor = getMetonaEditor()
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span> if (!editor) return
</button> editor.toggleZen()
</div> }, [])
<div className="toolbar-right" role="group" aria-label="设置">
<button // v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
className="toolbar-btn" const handleExport = useCallback(async () => {
onClick={onCycleTheme} if (!window.electronAPI) return
title={`当前 ${nextLabel} — 点击切换`} const data = await backupRepository.exportAll()
aria-label={`当前${nextLabel}主题,点击切换`} if (!data) return
> const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />} if (result.success) {
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span> showToast('备份已导出', 'success')
</button> } else if (!result.canceled) {
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite"> showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
<Info size={18} /> }
</button> }, [])
</div>
</div> // v0.6.0: 数据备份导入(JSON 文件 → sqlark 事务导入)
) const handleImport = useCallback(async () => {
}) if (!window.electronAPI) return
const result = await window.electronAPI.importData()
Toolbar.displayName = 'Toolbar' 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')
// B7-fix: 先 flush 快照并干净关闭数据库,确保导入数据落盘后再重载
// (此前固定 800ms 后 reload,OPFS 写未完成会丢导入数据)
try {
await flushSaveToDB()
await closeDatabase()
} catch {
/* flush/close 失败仍继续 reload,由自愈流程兜底 */
}
window.location.reload()
} else {
showToast('恢复备份失败', 'error')
}
} catch (error) {
logError('解析备份文件失败', error)
showToast('备份文件格式无效', 'error')
}
}, [])
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
<button
className="toolbar-btn"
onClick={onOpen}
title="打开文件 (Ctrl+O)"
aria-label="打开文件"
>
<FolderOpen size={18} />
<span></span>
</button>
<button
className="toolbar-btn"
onClick={onSave}
title="保存文件 (Ctrl+S)"
aria-label="保存文件"
>
<Save size={18} />
<span></span>
</button>
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave}
title={
isAutoSaving
? '正在自动保存...'
: autoSaveEnabled
? '自动保存已开启 — 点击关闭'
: '自动保存已关闭 — 点击开启'
}
aria-label={
isAutoSaving ? '正在自动保存' : autoSaveEnabled ? '关闭自动保存' : '开启自动保存'
}
>
<span>{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动' : '手动'}</span>
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">
<button
className={`toolbar-btn${zenMode ? ' active' : ''}`}
onClick={handleToggleZen}
title={zenMode ? '退出专注模式' : '专注模式 — 隐藏编辑器工具栏'}
aria-label={zenMode ? '退出专注模式' : '进入专注模式'}
aria-pressed={zenMode}
>
<span>🧘 {zenMode ? '专注中' : '专注'}</span>
</button>
<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={onShowSearch}
title="多文件搜索 (Ctrl+Shift+F)"
aria-label="多文件搜索"
>
<SearchIcon 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} />
</button>
</div>
</div>
)
})
Toolbar.displayName = 'Toolbar'
@@ -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,35 @@
import { describe, it, expect } from 'vitest'
import { recentFilesRepository } from '../recentFilesRepository'
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端,jsdom 无 OPFS →
// schema.ts 自动回退 AriaEngine memory 后端(见 DISK_ENGINE 探测)。
describe('recentFilesRepository (真实 AriaEngine + memory 后端)', () => {
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')
})
})
+45
View File
@@ -0,0 +1,45 @@
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()
// B8-fix: 清空 + 导入在单个事务内完成(此前 clear 事务与逐表 importTable
// 分离,中途失败会留下半导入状态)
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 trx.table(tableName).insertMany(rows as Record<string, unknown>[])
}
}
})
return true
} catch (error) {
logError('导入备份失败', error)
return false
}
},
}
+22 -3
View File
@@ -1,7 +1,20 @@
import { type Table } from '@metona-team/metona-sqlark' import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
import { getDb, type RecentFile } from './schema' 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 {
@@ -17,8 +30,12 @@ export const recentFilesRepository = {
const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[] const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
if (all.length > 50) { if (all.length > 50) {
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath) const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
await tbl.delete().where({ filePath: { $in: toDelete } }).execute() await tbl
.delete()
.where({ filePath: { $in: toDelete } })
.execute()
} }
await emitChange(db, 'update')
} catch (error) { } catch (error) {
logError('添加最近文件失败', error) logError('添加最近文件失败', error)
} }
@@ -44,6 +61,7 @@ export const recentFilesRepository = {
try { try {
const db = await getDb() const db = await getDb()
await db.table('recentFiles').delete().where({ filePath }).execute() await db.table('recentFiles').delete().where({ filePath }).execute()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('删除最近文件失败', error) logError('删除最近文件失败', error)
} }
@@ -53,8 +71,9 @@ export const recentFilesRepository = {
try { try {
const db = await getDb() const db = await getDb()
await db.table('recentFiles').clear() await db.table('recentFiles').clear()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('清空最近文件失败', error) logError('清空最近文件失败', error)
} }
} },
} }
+214 -17
View File
@@ -1,5 +1,6 @@
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark' import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import { MeToast } from '../lib/toast'
// 注意: 使用 type 别名而非 interface — // 注意: 使用 type 别名而非 interface —
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown> // sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>
@@ -35,11 +36,26 @@ export type RecentFile = {
lastOpened: number lastOpened: number
} }
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃 // v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离
// v0.5.0: 存储引擎选用 AriaEngine(自研 LSM-Tree + WAL + MVCC,对标 SQLite // v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端(v0.6.0 起),存储后端改用 KVStore
// 引擎(内存索引 + 快照/日志,落盘于 OPFS 目录 DB_NAME)。
// 不再考虑向下兼容:启动时直接删除旧 IndexedDB 库(见 deleteLegacyDatabases)。
const DB_NAME = 'MarkLiteV2' const DB_NAME = 'MarkLiteV2'
const DB_VERSION = 1
// v0.6.2: OPFS 可用性探测 — Electron/Chromium 提供 navigator.storage.getDirectory
// jsdom 等测试环境无 OPFS,回退 AriaEngine memory 后端(测试数据不持久化)。
// kv 后端的 KVStore 底层 medium 同样基于 OPFS。
const DISK_ENGINE: 'kv' | 'memory' =
typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function'
? 'kv'
: 'memory'
/**
* v0.6.0: 表结构定义 getTableNames
* v0.6.1: sqlark 0.4.2 IndexedDBEngine
* 使 version: 1 IDB
* schema 变更: createDatabase
*/
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = { const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true }, id: { type: 'string', primaryKey: true },
filePath: { type: 'string' }, filePath: { type: 'string' },
@@ -70,33 +86,214 @@ const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
} }
/** /**
* v0.5.0: MetonaSqlark * v0.6.0: 数据损坏自愈
* create() Dexie * / IDB
* Promise * localStorage IDB
* create
*
* /
* flushSaveToDBgetDb
* /
*/ */
let dbPromise: Promise<MetonaSqlark> | null = null 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()
async function defineTablesIfNeeded(db: MetonaSqlark): Promise<void> { const RESET_PENDING_KEY = 'marklite-db-reset-pending'
/**
*
* v0.6.2: sqlark 0.7.4 KVStore KVStoreEngine medium OPFSBackend
* DB_NAME OPFS =
*/
function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => {
try {
if (!navigator.storage?.getDirectory) {
resolve()
return
}
navigator.storage
.getDirectory()
.then(root => root.removeEntry(DB_NAME, { recursive: true }))
.then(() => resolve())
.catch(() => resolve())
} catch {
resolve()
}
})
}
/**
* v0.6.2: 删除旧版本遗留的 IndexedDB
* sqlark0.4.x aria-MarkLiteV2 DexieMarkLite
* IndexedDB MarkLite
*/
function deleteLegacyIndexedDB(): Promise<void> {
return new Promise<void>(resolve => {
const finish = () => resolve()
try {
if (typeof indexedDB === 'undefined' || typeof indexedDB.deleteDatabase !== 'function') {
finish()
return
}
const candidates: string[] = ['aria-MarkLiteV2', 'MarkLiteV2', 'MarkLite']
const deleteOne = (name: string) =>
new Promise<void>(r => {
try {
const req = indexedDB.deleteDatabase(name)
req.onsuccess = () => r()
req.onerror = () => r()
req.onblocked = () => r()
} catch {
r()
}
})
// 优先枚举全部 IDB 库(Chromium 支持),找不到 API 则回退固定名单
if (typeof indexedDB.databases === 'function') {
indexedDB
.databases()
.then(dbs => {
const names = dbs
.map(d => d.name ?? '')
.filter(n => n.includes('MarkLite') && !candidates.includes(n))
return Promise.all([...candidates, ...names].map(deleteOne))
})
.then(() => finish())
.catch(() => Promise.all(candidates.map(deleteOne)).then(() => finish()))
} else {
Promise.all(candidates.map(deleteOne)).then(() => finish())
}
} catch {
finish()
}
})
}
/** 自愈入口:存在重置标记时删除损坏库(保留标记,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 快照隔离
// v0.6.2: sqlark 0.7.4 DiskEngine 移除 indexeddb,选用 kv(自研 KVStore 后端:
// 内存索引 + 快照/日志,OPFS 落盘)。无 OPFS 环境(jsdom 测试)回退 memory。
diskEngine: DISK_ENGINE, // 底层存储后端(opfs | kv | memory
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
onError: (err: Error) => logError('数据库错误', err),
})
// 幂等建表(getTableNames 基于持久化 schema,重启后不会重复建表)
const tables = await db.getTableNames() const tables = await db.getTableNames()
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS) if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_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('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_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()
// v0.6.2: 清理旧版本 IndexedDB 库(不向下兼容,静默删除)
await deleteLegacyIndexedDB()
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> { export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) { if (!dbPromise) {
dbPromise = (async () => { if (isClosing) {
const db = await create({ // 关闭流程中禁止重开(返回 rejected Promise,调用方 catch 吞掉)
name: DB_NAME, return Promise.reject(new Error('数据库正在关闭'))
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离 }
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory // 串行化初始化:失败后的重试排队执行,避免并发 createDatabase
version: DB_VERSION, initChain = initChain.catch(() => {
onError: (err: Error) => logError('数据库错误', err), /* 前序失败不影响后续 */
})
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
}) })
await defineTablesIfNeeded(db)
return db
})()
// 初始化失败时允许下次重试 // 初始化失败时允许下次重试
dbPromise.catch(() => { dbPromise.catch(() => {
dbPromise = null dbPromise = null
+16 -11
View File
@@ -4,27 +4,29 @@ 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> { /**
* B11-fix: 无记录时返回 null
* DEFAULT_SETTINGS useSettingsInit
* "跟随系统主题偏好"
*/
async load(): Promise<Settings | null> {
try { try {
const db = await getDb() const db = await getDb()
const rows = await db const rows = await db.table('settings').select().where({ id: 'default' }).execute()
.table('settings')
.select()
.where({ id: 'default' })
.execute()
const record = rows[0] as SettingsRecord | undefined const record = rows[0] as SettingsRecord | undefined
if (record) { if (record) {
return { return {
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode, 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,
} }
} }
return null
} catch (error) { } catch (error) {
logError('加载设置失败', error) logError('加载设置失败', error)
return null
} }
return { ...DEFAULT_SETTINGS }
}, },
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失 // C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
@@ -32,17 +34,20 @@ export const settingsRepository = {
async save(partial: Partial<Settings>): Promise<void> { async save(partial: Partial<Settings>): Promise<void> {
try { try {
const db = await getDb() const db = await getDb()
const current: Settings = await this.load() const current: Settings = (await this.load()) ?? DEFAULT_SETTINGS
const merged: SettingsRecord = { id: 'default', ...current, ...partial } const merged: SettingsRecord = { id: 'default', ...current, ...partial }
const tbl = db.table('settings') as Table<SettingsRecord> const tbl = db.table('settings') as Table<SettingsRecord>
const rows = await tbl.select().where({ id: 'default' }).execute() const rows = await tbl.select().where({ id: 'default' }).execute()
if (rows.length > 0) { if (rows.length > 0) {
await tbl.update({ ...merged }).where({ id: 'default' }).execute() await tbl
.update({ ...merged })
.where({ id: 'default' })
.execute()
} else { } else {
await tbl.insert(merged) await tbl.insert(merged)
} }
} catch (error) { } catch (error) {
logError('保存设置失败', error) logError('保存设置失败', error)
} }
} },
} }
+3 -11
View File
@@ -7,7 +7,7 @@ export const tabRepository = {
try { try {
const db = await getDb() const db = await getDb()
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离) // v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
await db.transaction(async (trx) => { await db.transaction(async trx => {
await trx.table('tabSnapshots').clear() await trx.table('tabSnapshots').clear()
if (tabs.length > 0) { if (tabs.length > 0) {
await trx.table('tabSnapshots').insertMany(tabs) await trx.table('tabSnapshots').insertMany(tabs)
@@ -21,11 +21,7 @@ export const tabRepository = {
async loadAll(): Promise<TabSnapshot[]> { async loadAll(): Promise<TabSnapshot[]> {
try { try {
const db = await getDb() const db = await getDb()
const rows = await db const rows = await db.table('tabSnapshots').select().orderBy('updatedAt', 'asc').execute()
.table('tabSnapshots')
.select()
.orderBy('updatedAt', 'asc')
.execute()
return rows as TabSnapshot[] return rows as TabSnapshot[]
} catch (error) { } catch (error) {
logError('加载标签快照失败', error) logError('加载标签快照失败', error)
@@ -60,11 +56,7 @@ export const tabRepository = {
async loadActiveTabId(): Promise<string | null> { async loadActiveTabId(): Promise<string | null> {
try { try {
const db = await getDb() const db = await getDb()
const rows = await db const rows = await db.table('activeTab').select().where({ id: 'current' }).execute()
.table('activeTab')
.select()
.where({ id: 'current' })
.execute()
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
} catch (error) { } catch (error) {
logError('加载活动标签ID失败', error) logError('加载活动标签ID失败', error)
@@ -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)
})
})
+79 -79
View File
@@ -1,79 +1,79 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
/** /**
* D4: 基于视口的活跃标题追踪 hook * D4: 基于视口的活跃标题追踪 hook
* preview * preview
* offsetTop * offsetTop
* *
* ref preview * ref preview
* *
* @param containerRef - Markdown DOM ref * @param containerRef - Markdown DOM ref
* @param headings - * @param headings -
* @returns - null * @returns - null
*/ */
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 handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = containerRef.current const container = containerRef.current
if (!container || headings.length === 0) { if (!container || headings.length === 0) {
setActiveIndex(null) setActiveIndex(null)
return return
} }
// 收集容器内所有 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) {
setActiveIndex(null) setActiveIndex(null)
return return
} }
const scrollTop = container.scrollTop const scrollTop = container.scrollTop
const containerHeight = container.clientHeight const containerHeight = container.clientHeight
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达" const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
let bestIndex: number | null = null let bestIndex: number | null = null
for (let i = 0; i < headingElements.length; i++) { for (let i = 0; i < headingElements.length; i++) {
const el = headingElements[i] const el = headingElements[i]
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window // 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window
const top = el.offsetTop - (container.offsetTop || 0) const top = el.offsetTop - (container.offsetTop || 0)
if (top <= threshold) { if (top <= threshold) {
// 找到 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
} }
} }
setActiveIndex(bestIndex) setActiveIndex(bestIndex)
}, [containerRef, headings]) }, [containerRef, headings])
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
if (!container || headings.length === 0) { if (!container || headings.length === 0) {
setActiveIndex(null) setActiveIndex(null)
return return
} }
// 监听滚动事件 // 监听滚动事件
container.addEventListener('scroll', handleScroll, { passive: true }) container.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算 // 初始计算
handleScroll() handleScroll()
return () => { return () => {
container.removeEventListener('scroll', handleScroll) container.removeEventListener('scroll', handleScroll)
} }
}, [containerRef, headings, handleScroll]) }, [containerRef, headings, handleScroll])
return activeIndex return activeIndex
} }
+131 -116
View File
@@ -1,116 +1,131 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { useAutoSaveStore } from '../stores/autoSaveStore' import { useAutoSaveStore } from '../stores/autoSaveStore'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000 /** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
let _toggleAutoSave: (() => void) | null = null /** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
export function toggleAutoSaveExternal(): void { let _toggleAutoSave: (() => void) | null = null
_toggleAutoSave?.() export function toggleAutoSaveExternal(): void {
} _toggleAutoSave?.()
}
/**
* Auto-save hook: subscribes to Zustand tab store, debounces content changes /**
* and saves automatically after a pause in editing for file-backed tabs. * Auto-save hook: subscribes to Zustand tab store, debounces content changes
* * and saves automatically after a pause in editing for file-backed tabs.
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to *
* re-render on every keystroke the effect is triggered once and runs * Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
* reactively via the store subscription. * re-render on every keystroke the effect is triggered once and runs
* * reactively via the store subscription.
* Captures the tabId at debounce start so the timeout always saves the *
* correct tab even if the user switches tabs during the debounce window. * Captures the tabId at debounce start so the timeout always saves the
*/ * correct tab even if the user switches tabs during the debounce window.
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } { */
const [isAutoSaving, setIsAutoSaving] = useState(false) export function useAutoSave(): {
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true) isAutoSaving: boolean
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) autoSaveEnabled: boolean
const isSavingRef = useRef(false) toggleAutoSave: () => void
const enabledRef = useRef(true) } {
const mountedRef = useRef(true) const [isAutoSaving, setIsAutoSaving] = useState(false)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
const toggleAutoSave = useCallback(() => { const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
setAutoSaveEnabled(prev => { const isSavingRef = useRef(false)
const next = !prev const enabledRef = useRef(true)
enabledRef.current = next const mountedRef = useRef(true)
if (!next && timerRef.current) {
clearTimeout(timerRef.current) const toggleAutoSave = useCallback(() => {
timerRef.current = null setAutoSaveEnabled(prev => {
} const next = !prev
return next enabledRef.current = next
}) if (!next && timerRef.current) {
}, []) clearTimeout(timerRef.current)
timerRef.current = null
// 暴露给外部(状态栏按钮) }
_toggleAutoSave = toggleAutoSave return next
})
useEffect(() => { }, [])
mountedRef.current = true
// 暴露给外部(状态栏按钮)
// Subscribe to Zustand store — fires on every state change _toggleAutoSave = toggleAutoSave
const unsub = useTabStore.subscribe((state) => {
if (!enabledRef.current) return useEffect(() => {
mountedRef.current = true
const tab = state.getActiveTab()
if (!tab || !tab.filePath || !tab.isModified) return // Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe(state => {
// Capture the tabId so the timeout saves the correct tab if (!enabledRef.current) return
const tabIdToSave = tab.id
const tab = state.getActiveTab()
// Debounce: clear previous timer, start new one if (!tab || !tab.filePath || !tab.isModified) return
if (timerRef.current) {
clearTimeout(timerRef.current) // Capture the tabId so the timeout saves the correct tab
} const tabIdToSave = tab.id
timerRef.current = setTimeout(async () => { // Debounce: clear previous timer, start new one
if (isSavingRef.current) return if (timerRef.current) {
clearTimeout(timerRef.current)
// Re-read latest state; verify the captured tab still exists and is modified }
const currentState = useTabStore.getState()
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave) timerRef.current = setTimeout(async () => {
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return if (isSavingRef.current) return
isSavingRef.current = true // Re-read latest state; verify the captured tab still exists and is modified
if (mountedRef.current) setIsAutoSaving(true) const currentState = useTabStore.getState()
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
try { if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({ isSavingRef.current = true
filePath: tabToSave.filePath, if (mountedRef.current) setIsAutoSaving(true)
content: tabToSave.content
}) // B1-fix: 记录保存时的内容快照 — 保存期间用户继续输入的话,
if (result.success && mountedRef.current) { // 完成后仅当内容仍是快照时才清除 isModified(否则新输入会被误清标记
currentState.setModified(tabToSave.id, false) // 导致永不落盘、关闭时也无未保存提示)
} const savedContent = tabToSave.content
} catch (error) {
logError('自动保存失败', error) try {
} finally { if (!window.electronAPI) return
if (mountedRef.current) setIsAutoSaving(false) const result = await window.electronAPI.saveFile({
isSavingRef.current = false filePath: tabToSave.filePath,
} content: savedContent,
}, AUTO_SAVE_DELAY) })
}) if (result.success && mountedRef.current) {
const latest = useTabStore.getState().tabs.find(t => t.id === tabIdToSave)
return () => { if (latest && latest.content === savedContent) {
mountedRef.current = false currentState.setModified(tabToSave.id, false)
unsub() }
if (timerRef.current) { } else if (mountedRef.current && !result.success && !result.canceled) {
clearTimeout(timerRef.current) showToast(`自动保存失败: ${result.error ?? '未知错误'}`, 'error')
timerRef.current = null }
} } catch (error) {
} logError('自动保存失败', error)
}, []) } finally {
if (mountedRef.current) setIsAutoSaving(false)
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取) isSavingRef.current = false
useEffect(() => { }
useAutoSaveStore.getState().setState({ autoSaveEnabled }) }, AUTO_SAVE_DELAY)
}, [autoSaveEnabled]) })
useEffect(() => { return () => {
useAutoSaveStore.getState().setState({ isAutoSaving }) mountedRef.current = false
}, [isAutoSaving]) unsub()
if (timerRef.current) {
return { isAutoSaving, autoSaveEnabled, toggleAutoSave } clearTimeout(timerRef.current)
} timerRef.current = null
}
}
}, [])
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
useEffect(() => {
useAutoSaveStore.getState().setState({ autoSaveEnabled })
}, [autoSaveEnabled])
useEffect(() => {
useAutoSaveStore.getState().setState({ isAutoSaving })
}, [isAutoSaving])
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
}
-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])
}
+77 -74
View File
@@ -1,74 +1,77 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore' 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' import { showToast } from '../lib/toast'
export function useDragDrop() { export function useDragDrop() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => { const handleDrop = useCallback(
e.preventDefault() async (e: DragEvent) => {
e.stopPropagation() e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files) return const files = e.dataTransfer?.files
if (!files) return
let rejected = 0
for (const file of Array.from(files)) { let rejected = 0
// Electron adds a `path` property to File objects for (const file of Array.from(files)) {
const electronFile = file as File & { path?: string } // Electron adds a `path` property to File objects
const filePath: string = electronFile.path || file.name const electronFile = file as File & { path?: string }
if (!isAllowedFile(filePath)) { const filePath: string = electronFile.path || file.name
rejected++ if (!isAllowedFile(filePath)) {
continue rejected++
} continue
if (file.size > MAX_FILE_SIZE) { }
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`) if (file.size > MAX_FILE_SIZE) {
continue showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
} continue
}
if (window.electronAPI) {
try { if (window.electronAPI) {
const result = await window.electronAPI.readFile(filePath) try {
if (result.success && result.content !== undefined) { const result = await window.electronAPI.readFile(filePath)
createTab(filePath, result.content) if (result.success && result.content !== undefined) {
} createTab(filePath, result.content)
} catch (error) { }
logError('拖拽读取文件失败', error) } catch (error) {
} logError('拖拽读取文件失败', error)
} else { }
const reader = new FileReader() } else {
reader.onload = (ev: ProgressEvent<FileReader>) => { const reader = new FileReader()
const content = ev.target?.result as string reader.onload = (ev: ProgressEvent<FileReader>) => {
createTab(file.name, content) const content = ev.target?.result as string
} createTab(file.name, content)
reader.readAsText(file) }
} reader.readAsText(file)
} }
}
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`) if (rejected > 0) {
} showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}, [createTab]) }
},
useEffect(() => { [createTab],
const prevent = (e: DragEvent) => { )
e.preventDefault()
e.stopPropagation() useEffect(() => {
} const prevent = (e: DragEvent) => {
e.preventDefault()
document.addEventListener('dragenter', prevent) e.stopPropagation()
document.addEventListener('dragleave', prevent) }
document.addEventListener('dragover', prevent)
document.addEventListener('drop', handleDrop) document.addEventListener('dragenter', prevent)
document.addEventListener('dragleave', prevent)
return () => { document.addEventListener('dragover', prevent)
document.removeEventListener('dragenter', prevent) document.addEventListener('drop', handleDrop)
document.removeEventListener('dragleave', prevent)
document.removeEventListener('dragover', prevent) return () => {
document.removeEventListener('drop', handleDrop) document.removeEventListener('dragenter', prevent)
} document.removeEventListener('dragleave', prevent)
}, [handleDrop]) document.removeEventListener('dragover', prevent)
} document.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
}
+142 -94
View File
@@ -1,94 +1,142 @@
import { useCallback, useRef } 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 { showToast } from '../lib/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.1.9: handleSave onSave + Ctrl+S
*/ * v0.6.0: loading MeToast.loading MeToast.promise
export function useFileOperations() { */
const createTab = useTabStore(s => s.createTab) export function useFileOperations() {
const getActiveTab = useTabStore(s => s.getActiveTab) const createTab = useTabStore(s => s.createTab)
const setLoading = useEditorStore(s => s.setLoading) const getActiveTab = useTabStore(s => s.getActiveTab)
const updateTabFilePath = useTabStore(s => s.updateTabFilePath)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发 const setLoading = useEditorStore(s => s.setLoading)
const savingGate = useRef(false)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const handleOpenFile = useCallback(async (): Promise<void> => { const savingGate = useRef(false)
try {
if (!window.electronAPI) return const handleOpenFile = useCallback(async (): Promise<void> => {
setLoading('file-open', true) if (!window.electronAPI) return
const result = await window.electronAPI.openFile() const loading = MeToast.loading('打开文件...')
if (result && 'filePath' in result) { try {
createTab(result.filePath, result.content) setLoading('file-open', true)
if (result.filePath) recentFilesRepository.add(result.filePath) const result = await window.electronAPI.openFile()
setTimeout(() => useTabStore.getState().saveToDB(), 100) if (result && 'filePath' in result) {
} createTab(result.filePath, result.content)
} catch (error) { if (result.filePath) recentFilesRepository.add(result.filePath)
logError('打开文件失败', error) setTimeout(() => useTabStore.getState().saveToDB(), 100)
showToast('打开文件失败', 'error') loading.success('文件已打开')
} finally { } else if (result && 'error' in result) {
setLoading('file-open', false) loading.error(`打开失败: ${result.error}`)
} } else {
}, [createTab, setLoading]) loading.dismiss()
}
const handleSave = useCallback(async (): Promise<void> => { } catch (error) {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发) logError('打开文件失败', error)
if (savingGate.current) return loading.error('打开文件失败')
savingGate.current = true } finally {
try { setLoading('file-open', false)
const tab = getActiveTab() }
if (!tab || !window.electronAPI) return }, [createTab, setLoading])
const result = await window.electronAPI.saveFile({
filePath: tab.filePath, const handleSave = useCallback(async (): Promise<void> => {
content: tab.content // 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
}) if (savingGate.current) return
if (result.success) { savingGate.current = true
useTabStore.getState().setModified(tab.id, false) try {
showToast('已保存', 'success') const tab = getActiveTab()
} if (!tab || !window.electronAPI) return
} catch (error) { // B1-fix: 保存前记录内容快照 — 保存期间继续输入的话,完成后
logError('保存文件失败', error) // 仅当内容未变化时才清除 isModified,避免新输入被误清标记
showToast('保存失败', 'error') const savedContent = tab.content
} finally { // v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
// 300ms 后释放锁,允许下次保存 const result = await MeToast.promise(
setTimeout(() => { savingGate.current = false }, 300) window.electronAPI.saveFile({
} filePath: tab.filePath,
}, [getActiveTab]) content: savedContent,
}),
const handleSaveAs = useCallback(async (): Promise<void> => { {
try { loading: '保存中...',
const tab = getActiveTab() success: '已保存',
if (!tab || !window.electronAPI) return error: '保存失败',
const result = await window.electronAPI.saveFileAs({ content: tab.content }) },
if (result.success) { )
useTabStore.getState().setModified(tab.id, false) if (result.success) {
showToast('已保存', 'success') const latest = useTabStore.getState().tabs.find(t => t.id === tab.id)
} if (latest && latest.content === savedContent) {
} catch (error) { useTabStore.getState().setModified(tab.id, false)
logError('另存为失败', error) }
showToast('另存为失败', 'error') } else {
} // IPC 返回 success:false 不 rejectpromise 的 error 文案不会触发,需显式提示
}, [getActiveTab]) if (!result.canceled) {
showToast(`保存失败: ${result.error ?? '未知错误'}`, 'error')
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => { }
if (!window.electronAPI) return }
setLoading('file-open', true) } catch (error) {
try { logError('保存文件失败', error)
const result = await window.electronAPI.readFile(filePath) } finally {
if (result.success && result.content !== undefined) { // 300ms 后释放锁,允许下次保存
createTab(filePath, result.content) setTimeout(() => {
recentFilesRepository.add(filePath) savingGate.current = false
setTimeout(() => useTabStore.getState().saveToDB(), 100) }, 300)
} }
} finally { }, [getActiveTab])
setLoading('file-open', false)
} const handleSaveAs = useCallback(async (): Promise<void> => {
}, [createTab, setLoading]) try {
const tab = getActiveTab()
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } if (!tab || !window.electronAPI) return
} const result = await MeToast.promise(
window.electronAPI.saveFileAs({ content: tab.content }),
{
loading: '另存为...',
success: '已保存',
error: '另存为失败',
},
)
if (result.success && result.filePath) {
// B2-fix: 另存为成功后把标签重新绑定到新路径(此前标签仍指向旧文件,
// 之后 Ctrl+S/自动保存会写回旧文件)
updateTabFilePath(tab.id, result.filePath)
useTabStore.getState().setModified(tab.id, false)
recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} else if (!result.canceled) {
showToast('另存为失败', 'error')
}
} catch (error) {
logError('另存为失败', error)
}
}, [getActiveTab, updateTabFilePath])
const handleOpenRecent = useCallback(
async (filePath: string): Promise<void> => {
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
} else {
// B13-fix: 打开失败(文件被删除/移动)显式提示,不再静默
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
}
} catch (error) {
logError('打开最近文件失败', error)
showToast(`无法打开 "${filePath}"`, 'error')
} finally {
setLoading('file-open', false)
}
},
[createTab, setLoading],
)
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
}
+5 -5
View File
@@ -7,20 +7,20 @@ import { useEditorStore } from '../stores/editorStore'
* 使 Zustand store DOM CustomEvent * 使 Zustand store DOM CustomEvent
*/ */
export function useFileWatch() { export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
const setExternallyModified = useEditorStore(s => s.setExternallyModified) const setExternallyModified = useEditorStore(s => s.setExternallyModified)
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
// B5-fix: 任何打开的标签对应的文件被外部修改都提示(此前只检测活动标签,
// 后台标签被外部改后切过去无感知,显示的还是内存旧内容)
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => { const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab() const tabs = useTabStore.getState().tabs
if (tab && tab.filePath === filePath) { if (tabs.some(t => t.filePath === filePath)) {
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
setExternallyModified({ filePath }) setExternallyModified({ filePath })
} }
}) })
return unsubscribe return unsubscribe
}, [getActiveTab, setExternallyModified]) }, [setExternallyModified])
} }
+52 -52
View File
@@ -1,52 +1,52 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { useSidebarStore } from '../stores/sidebarStore' import { useSidebarStore } from '../stores/sidebarStore'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
/** /**
* AR-02: Sidebar.tsx * AR-02: Sidebar.tsx
* UX-02: 添加目录加载 loading * UX-02: 添加目录加载 loading
*/ */
export function useFolderOperations() { export function useFolderOperations() {
const rootPath = useSidebarStore(s => s.rootPath) const rootPath = useSidebarStore(s => s.rootPath)
const setRootPath = useSidebarStore(s => s.setRootPath) const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree) const setTree = useSidebarStore(s => s.setTree)
const expandDirs = useSidebarStore(s => s.expandDirs) const expandDirs = useSidebarStore(s => s.expandDirs)
const setLoading = useEditorStore(s => s.setLoading) const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFolder = useCallback(async () => { const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return if (!window.electronAPI) return
const dirPath = await window.electronAPI.openFolderDialog() const dirPath = await window.electronAPI.openFolderDialog()
if (!dirPath) return if (!dirPath) return
setRootPath(dirPath) setRootPath(dirPath)
expandDirs([dirPath]) expandDirs([dirPath])
setLoading('dir-load', true) setLoading('dir-load', true)
try { try {
const dirTree = await window.electronAPI.readDirTree(dirPath) const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) { if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree) setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath) window.electronAPI.watchDir(dirPath)
} }
} finally { } finally {
setLoading('dir-load', false) setLoading('dir-load', false)
} }
}, [setRootPath, setTree, expandDirs, setLoading]) }, [setRootPath, setTree, expandDirs, setLoading])
const refreshTree = useCallback(async () => { const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return if (!rootPath || !window.electronAPI) return
setLoading('dir-load', true) setLoading('dir-load', true)
try { try {
const result = await window.electronAPI.readDirTree(rootPath) const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) setTree(result.tree) if (result.success && result.tree) setTree(result.tree)
} finally { } finally {
setLoading('dir-load', false) setLoading('dir-load', false)
} }
}, [rootPath, setTree, setLoading]) }, [rootPath, setTree, setLoading])
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree()) const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
return unsubscribe return unsubscribe
}, [refreshTree]) }, [refreshTree])
return { handleOpenFolder } return { handleOpenFolder }
} }
+22 -22
View File
@@ -1,22 +1,22 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository' import { recentFilesRepository } from '../db/recentFilesRepository'
/** /**
* hook * hook
* *
*/ */
export function useIpcListeners() { export function useIpcListeners() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
const api = window.electronAPI const api = window.electronAPI
const onOpen = (data: { filePath: string; content: string }) => { const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content) createTab(data.filePath, data.content)
if (data.filePath) recentFilesRepository.add(data.filePath) if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100) setTimeout(() => useTabStore.getState().saveToDB(), 100)
} }
return api.onFileOpenInTab(onOpen) return api.onFileOpenInTab(onOpen)
}, [createTab]) }, [createTab])
} }
+81 -53
View File
@@ -1,53 +1,81 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
/** /**
* hook * hook
* MetonaEditor Ctrl+B/I/1/2/3 * MetonaEditor Ctrl+B/I/1/2/3
* v0.1.9 onSave Ctrl+S * v0.1.9 onSave Ctrl+S
* handler / * handler /
*/ */
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) { export function useKeyboard(
const handleKeydown = useCallback((e: KeyboardEvent) => { handleOpenFile: () => void,
const isCtrl = e.ctrlKey || e.metaKey handleSave: () => void,
handleSaveAs: () => void,
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return } handleShowSearch: () => void,
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理) ) {
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return } const handleKeydown = useCallback(
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return } (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } if (isCtrl && e.key === 'o') {
if (isCtrl && e.key === 'w') { e.preventDefault()
e.preventDefault() handleOpenFile()
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId) return
return }
} // 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) {
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换 e.preventDefault()
if (isCtrl && e.key === 'Tab') { handleSave()
e.preventDefault() return
const { tabs, activeTabId, mruStack } = tabState }
if (tabs.length > 1) { if (isCtrl && e.shiftKey && e.key === 'S') {
if (mruStack.length > 0) { e.preventDefault()
const targetId = mruStack[0] handleSaveAs()
if (tabs.find(t => t.id === targetId)) { return
tabState.switchToTab(targetId) }
return // v0.6.2: 多文件搜索
} if (isCtrl && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
} e.preventDefault()
const idx = tabs.findIndex(t => t.id === activeTabId) handleShowSearch()
const next = e.shiftKey return
? (idx - 1 + tabs.length) % tabs.length }
: (idx + 1) % tabs.length
tabState.switchToTab(tabs[next].id) const tabState = useTabStore.getState()
} if (isCtrl && e.key === 't') {
return e.preventDefault()
} tabState.createTab(null, '')
}, [handleOpenFile, handleSave, handleSaveAs]) return
}
useEffect(() => { if (isCtrl && e.key === 'w') {
document.addEventListener('keydown', handleKeydown) e.preventDefault()
return () => document.removeEventListener('keydown', handleKeydown) if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
}, [handleKeydown]) return
} }
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
tabState.switchToTab(targetId)
return
}
}
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length
tabState.switchToTab(tabs[next].id)
}
return
}
},
[handleOpenFile, handleSave, handleSaveAs, handleShowSearch],
)
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}, [handleKeydown])
}
+50 -47
View File
@@ -1,47 +1,50 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { useSidebarStore } from '../stores/sidebarStore' import { useSidebarStore } from '../stores/sidebarStore'
import { settingsRepository } from '../db/settingsRepository' import { settingsRepository } from '../db/settingsRepository'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import { DEFAULT_SETTINGS } from '../types/settings'
/**
* AR-04: 统一设置加载 hook /**
* MetonaSqlark store * AR-04: 统一设置加载 hook
* hook * MetonaSqlark store
*/ * hook
export function useSettingsInit() { */
const setThemeMode = useEditorStore(s => s.setThemeMode) export function useSettingsInit() {
const setViewMode = useEditorStore(s => s.setViewMode) const setThemeMode = useEditorStore(s => s.setThemeMode)
const isInitialized = useRef(false) const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
useEffect(() => {
if (isInitialized.current) return useEffect(() => {
isInitialized.current = true if (isInitialized.current) return
isInitialized.current = true
settingsRepository.load()
.then((settings) => { settingsRepository
// 主题:优先使用保存的设置,否则跟随系统偏好 .load()
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches .then(settings => {
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light') // B11-fix: 首次启动(settings 为 null)时主题跟随系统偏好;
setThemeMode(themeMode) // 有保存记录则使用保存值
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
// 视图模式 const themeMode = settings?.themeMode ?? (prefersDark ? 'dark' : 'light')
setViewMode(settings.viewMode ?? 'editor') setThemeMode(themeMode)
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB // 视图模式
const sidebarStore = useSidebarStore.getState() setViewMode(settings?.viewMode ?? 'editor')
if (!sidebarStore._loaded) {
useSidebarStore.setState({ // Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB
isVisible: !settings.sidebarCollapsed, const sidebarStore = useSidebarStore.getState()
sidebarWidth: settings.sidebarWidth, if (!sidebarStore._loaded) {
_loaded: true useSidebarStore.setState({
}) isVisible: settings ? !settings.sidebarCollapsed : true,
} sidebarWidth: settings?.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
}) _loaded: true,
.catch((error: unknown) => { })
logError('加载设置失败', error) }
}) })
}, [setThemeMode, setViewMode]) .catch((error: unknown) => {
logError('加载设置失败', error)
return { isInitialized } })
} }, [setThemeMode, setViewMode])
return { isInitialized }
}
+45 -45
View File
@@ -1,45 +1,45 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useSidebarStore } from '../stores/sidebarStore' import { useSidebarStore } from '../stores/sidebarStore'
/** /**
* AR-02: Sidebar.tsx resize * AR-02: Sidebar.tsx resize
* *
* DOM mousemove store + * DOM mousemove store +
* sidebarStore IndexedDB * sidebarStore IndexedDB
*/ */
export function useSidebarResize() { export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!isResizing) return if (!isResizing) return
const handleMouseMove = (e: MouseEvent): void => { const handleMouseMove = (e: MouseEvent): void => {
if (sidebarRef.current) { if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX)) const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px' sidebarRef.current.style.width = newWidth + 'px'
} }
} }
const handleMouseUp = (): void => { const handleMouseUp = (): void => {
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB // 拖拽结束时将最终宽度持久化到 store(→ IndexedDB
if (sidebarRef.current) { if (sidebarRef.current) {
const width = sidebarRef.current.offsetWidth const width = sidebarRef.current.offsetWidth
const clamped = Math.max(180, Math.min(500, width)) const clamped = Math.max(180, Math.min(500, width))
useSidebarStore.getState().setSidebarWidth(clamped) useSidebarStore.getState().setSidebarWidth(clamped)
} }
setIsResizing(false) setIsResizing(false)
} }
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
} }
}, [isResizing]) }, [isResizing])
const startResize = (): void => setIsResizing(true) const startResize = (): void => setIsResizing(true)
return { sidebarRef, isResizing, startResize } return { sidebarRef, isResizing, startResize }
} }
+97 -91
View File
@@ -1,91 +1,97 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor' 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' import type { ThemeMode } from '../types/settings'
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */ /** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
function hexWithAlpha(hex: string, alpha: number): string { function hexWithAlpha(hex: string, alpha: number): string {
if (!hex || !hex.startsWith('#')) return hex if (!hex || !hex.startsWith('#')) return hex
const a = Math.round(alpha * 255).toString(16).padStart(2, '0') const a = Math.round(alpha * 255)
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a .toString(16)
} .padStart(2, '0')
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */ }
function syncAppColorsToEditor(): void {
try { /** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名) function syncAppColorsToEditor(): void {
const vars = MeEditor.themes.exportCSSVars() try {
if (!vars || typeof vars !== 'object') return // v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
const root = document.documentElement const vars = MeEditor.themes.exportCSSVars()
const set = (name: string, value: string | undefined) => { if (!vars || typeof vars !== 'object') return
if (value) root.style.setProperty(name, value) const root = document.documentElement
} const set = (name: string, value: string | undefined) => {
const accent = vars['--md-accent'] ?? '#1a73e8' if (value) root.style.setProperty(name, value)
// 直接映射 — 文本/边框用纯色 }
set('--bg', vars['--md-bg']) const accent = vars['--md-accent'] ?? '#1a73e8'
set('--text', vars['--md-text']) // 直接映射 — 文本/边框用纯色
set('--border', vars['--md-border']) set('--bg', vars['--md-bg'])
set('--primary', accent) set('--text', vars['--md-text'])
set('--primary-dark', accent) set('--border', vars['--md-border'])
set('--text-secondary', vars['--md-muted']) set('--primary', accent)
set('--text-tertiary', vars['--md-muted']) set('--primary-dark', accent)
set('--code-bg', vars['--md-code-bg']) set('--text-secondary', vars['--md-muted'])
// 背景派生 — 用 accent 的低透明度版本 set('--text-tertiary', vars['--md-muted'])
const accentBg = hexWithAlpha(accent, 0.12) set('--code-bg', vars['--md-code-bg'])
set('--primary-light', accentBg) // 背景派生 — 用 accent 的低透明度版本
set('--sidebar-active', accentBg) const accentBg = hexWithAlpha(accent, 0.12)
set('--sidebar-bg', vars['--md-bg']) set('--primary-light', accentBg)
set('--sidebar-border', vars['--md-border']) set('--sidebar-active', accentBg)
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4)) set('--sidebar-bg', vars['--md-bg'])
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04)) set('--sidebar-border', vars['--md-border'])
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08)) set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5)) set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04)) set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
set('--search-border', vars['--md-border']) set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
// 阴影根据主题适配 set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff' set('--search-border', vars['--md-border'])
root.style.setProperty('--shadow', isDark // 阴影根据主题适配
? '0 1px 3px rgba(0,0,0,0.3)' const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
: '0 1px 3px rgba(0,0,0,0.08)') root.style.setProperty(
root.style.setProperty('--shadow-lg', isDark '--shadow',
? '0 4px 12px rgba(0,0,0,0.4)' isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)',
: '0 4px 12px rgba(0,0,0,0.1)') )
} catch { /* 容错 */ } root.style.setProperty(
} '--shadow-lg',
isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.1)',
const THEME_TO_TOAST: Record<ThemeMode, string> = { )
light: 'light', } catch {
dark: 'dark', /* 容错 */
warm: 'warm', }
} }
/** const THEME_TO_TOAST: Record<ThemeMode, string> = {
* hook light: 'light',
* dark: 'dark',
*/ warm: 'warm',
export function useTheme() { }
const themeMode = useEditorStore(s => s.themeMode)
const cycleTheme = useEditorStore(s => s.cycleTheme) /**
* hook
const handleCycleTheme = useCallback(() => { *
const next = cycleTheme() */
// 同步编辑器全局主题 export function useTheme() {
MeEditor.setTheme(next) const themeMode = useEditorStore(s => s.themeMode)
// 同步 Toast 主题 const cycleTheme = useEditorStore(s => s.cycleTheme)
import('../lib/toast').then(({ MeToast }) => {
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto') const handleCycleTheme = useCallback(() => {
}) const next = cycleTheme()
// 持久化 // 同步编辑器全局主题
settingsRepository.save({ themeMode: next }) MeEditor.setTheme(next)
}, [cycleTheme]) // 同步 Toast 主题
import('../lib/toast').then(({ MeToast }) => {
// themeMode 变化时同步应用颜色 MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
useEffect(() => { })
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则) // 持久化
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm') settingsRepository.save({ themeMode: next })
syncAppColorsToEditor() }, [cycleTheme])
}, [themeMode])
// themeMode 变化时同步应用颜色
return { themeMode, cycleTheme: handleCycleTheme } useEffect(() => {
} // 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
syncAppColorsToEditor()
}, [themeMode])
return { themeMode, cycleTheme: handleCycleTheme }
}
+57 -57
View File
@@ -1,57 +1,57 @@
import { useEffect, useCallback, useRef } from 'react' import { useEffect, useCallback, useRef } from 'react'
/** /**
* hook * hook
* UX-01: 接受外部 confirm confirm() * UX-01: 接受外部 confirm confirm()
*/ */
export function useUnsavedWarning( 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
const onBeforeForceCloseRef = useRef(onBeforeForceClose) const onBeforeForceCloseRef = useRef(onBeforeForceClose)
onBeforeForceCloseRef.current = onBeforeForceClose onBeforeForceCloseRef.current = onBeforeForceClose
const doConfirm = useCallback(async (message: string): Promise<boolean> => { const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) { if (confirmFnRef.current) {
return confirmFnRef.current(message) return confirmFnRef.current(message)
} }
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为 // 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
return true return true
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!window.electronAPI) { if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved()) { if (hasUnsaved()) {
e.preventDefault() e.preventDefault()
e.returnValue = '' e.returnValue = ''
} }
} }
window.addEventListener('beforeunload', handler) window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
} }
const api = window.electronAPI const api = window.electronAPI
const unsubscribe = api.onConfirmClose(async () => { const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) { if (!hasUnsaved()) {
await onBeforeForceCloseRef.current?.() await onBeforeForceCloseRef.current?.()
api.forceClose() api.forceClose()
return return
} }
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?') const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) { if (shouldClose) {
await onBeforeForceCloseRef.current?.() await onBeforeForceCloseRef.current?.()
api.forceClose() api.forceClose()
} else { } else {
api.cancelClose() api.cancelClose()
} }
}) })
return unsubscribe return unsubscribe
}, [hasUnsaved, doConfirm]) }, [hasUnsaved, doConfirm])
} }
+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=')
}) })
}) })
+99 -198
View File
@@ -1,198 +1,99 @@
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' // 简单的路径解析(Electron renderer 中没有 path 模块)
import { visit } from 'unist-util-visit' // E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
import type { Element, Root } from 'hast' function normPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '')
// 简单的路径解析(Electron renderer 中没有 path 模块) }
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
function normPath(p: string): string { function resolveRelativePath(base: string, rel: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '') const parts = normPath(base).split('/')
} parts.pop() // 移除文件名
const relParts = rel.split('/')
function resolveRelativePath(base: string, rel: string): string { for (const p of relParts) {
const parts = normPath(base).split('/') if (p === '.' || p === '') continue
parts.pop() // 移除文件名 if (p === '..') {
const relParts = rel.split('/') if (parts.length > 0) parts.pop()
for (const p of relParts) { } else {
if (p === '.' || p === '') continue parts.push(p)
if (p === '..') { }
if (parts.length > 0) parts.pop() }
} else { return parts.join('/')
parts.push(p) }
}
} /** 属性值转义(与内置解析器 escapeAttr 行为一致) */
return parts.join('/') function escapeAttr(s: string): string {
} return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> { /**
return () => (tree: Root) => { * HTML
if (!filePath) return *
* v0.6.0: 内置解析器的 safeUrl file: 协议 sanitize
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') * parseMarkdown DOM
* img src escapeAttr
function visit(node: Element | Root): void { * src
if (!node.children) return *
for (const child of node.children) { * CQ-09: 保持与原 rehypeFixImages
if (child.type === 'element' && child.tagName === 'img') { * markdown
const src = child.properties?.src as string | undefined */
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { function fixImageSrcs(html: string, filePath: string | null): string {
// 处理Unix风格绝对路径(以/开头) if (!filePath) return html
if (src.startsWith('/')) {
child.properties = { const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
...child.properties,
src: 'file://' + src return html.replace(/<img\b[^>]*\bsrc="([^"]*)"/g, (full, src) => {
} const raw = String(src).replace(/&amp;/g, '&') // 先还原转义,避免二次转义
continue if (!raw) return full
} if (
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外 raw.startsWith('http://') ||
const resolvedPath = resolveRelativePath(dir, src) raw.startsWith('https://') ||
// E1: 统一规范化后比较,防止 ../ 路径越界 raw.startsWith('data:') ||
const normalizedBase = normPath(dir) raw.startsWith('file:')
if (!normPath(resolvedPath).startsWith(normalizedBase)) return ) {
child.properties = { return full
...child.properties, }
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/') // 处理 Unix 风格绝对路径(以 / 开头)
} if (raw.startsWith('/')) {
} return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
} }
if (child.type === 'element') { // 解析完整路径并检查是否越界
visit(child as Element) // 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
} // 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
} const resolvedPath = resolveRelativePath(filePath, raw)
} const normalizedBase = normPath(dir)
// 用 base + '/' 前缀判断,避免 /home/docs-other/ 误判为在 /home/docs/ 内
visit(tree) const boundary = normalizedBase === '' ? '/' : normalizedBase + '/'
} if (!normPath(resolvedPath).startsWith(boundary)) return full
} const fixed = 'file://' + resolvedPath
return full.replace(`src="${src}"`, `src="${escapeAttr(fixed)}"`)
// 自定义 rehype 插件:将 mermaid 代码块转为 .me-mermaid 容器 })
// 在 rehypeHighlight 之后运行,此时 code 元素已有 language-mermaid 类名。 }
// 输出结构需与 MetonaEditor 内置解析器一致,以便 mermaid.run() 统一查询。
function rehypeMermaid(): Plugin<[], Root> { /**
return () => (tree: Root) => { * Markdown 使 MetonaEditor
visit(tree, 'element', (node: Element, index, parent) => { *
if (node.tagName !== 'pre') return * @param content Markdown
if (parent === null || parent === undefined || index === undefined) return * @param filePath null
*/
const code = node.children.find( export function renderMarkdownSync(content: string, filePath?: string | null): string {
(child): child is Element => child.type === 'element' && child.tagName === 'code' try {
) const html = parseMarkdown(content)
if (!code) return return fixImageSrcs(html, filePath ?? null)
} catch (e) {
const className: string[] = (code.properties?.className as string[] | undefined) ?? [] const errorMsg: string = e instanceof Error ? e.message : String(e)
if (!className.some(c => c === 'language-mermaid' || c === 'mermaid')) return return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
// 提取代码文本内容 }
const text = (code.children ?? [])
.filter(c => c.type === 'text') /**
.map(c => (c as { type: 'text'; value: string }).value) *
.join('') */
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
// 替换为 mermaid 渲染容器(与 MetonaEditor 内置输出一致) return renderMarkdownSync(content, filePath)
const mermaidContainer: Element = { }
type: 'element',
tagName: 'div',
properties: { className: ['me-mermaid'] },
children: [{
type: 'element',
tagName: 'div',
properties: { className: ['mermaid'] },
children: [{ type: 'text', value: text }]
}]
}
parent.children[index] = mermaidContainer
})
}
}
// 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(rehypeMermaid())
.use(rehypeStringify)
}
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
const key: string = filePath ?? '__null__'
if (processorCache.has(key)) {
const cached = processorCache.get(key)!
processorCache.delete(key)
processorCache.set(key, cached)
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 {
const processor = getCachedProcessor(filePath ?? null)
const result = await processor.process(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
/**
* MetonaEditor render 使
* unified render
*/
export function renderMarkdownSync(content: string, filePath?: string | null): string {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = processor.processSync(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
+56 -50
View File
@@ -1,50 +1,56 @@
import MeToast from '@metona-team/metona-toast' import MeToast from '@metona-team/metona-toast'
import { logError } from './errorHandler'
/**
* MeToast /**
* * MeToast
* Toast components/Toast MeToast *
* MeToast DOM React * Toast components/Toast MeToast
* * MeToast DOM React
* v0.5.0: 升级 MeToast 0.5.0 keyboard / accessibility *
*/ * v0.5.0: 升级 MeToast 0.5.0 keyboard / accessibility
MeToast.configure({ * v0.6.0: 接入 dedupe onError
position: 'top-right', */
duration: 3000, MeToast.configure({
max: 5, position: 'top-right',
theme: 'auto', duration: 3000,
animation: 'slide', max: 5,
pauseOnHover: true, theme: 'auto',
closeOnClick: true, animation: 'slide',
showProgress: true, pauseOnHover: true,
draggable: true, closeOnClick: true,
locale: 'zh-CN', showProgress: true,
width: 360, draggable: true,
}) locale: 'zh-CN',
width: 360,
// 安装内置插件 // v0.6.0: 钩子/定时器异常统一走应用错误日志
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
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() 是插件安装方法,非 React Hook
/** 与原 showToast 保持兼容的类型子集 */ MeToast.use('keyboard') // ESC 关闭所有 Toast
export type ToastType = 'success' | 'error' | 'warning' | 'info' // eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
/** // eslint-disable-next-line react-hooks/rules-of-hooks
* Toast useToast().showToast MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
*
* @param msg /** 与原 showToast 保持兼容的类型子集 */
* @param type info export type ToastType = 'success' | 'error' | 'warning' | 'info'
* @param duration (ms)
*/ /**
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void { * Toast useToast().showToast
MeToast[type](msg, duration !== undefined ? { duration } : undefined) *
} * @param msg
* @param type info
/** 同步 MeToast 主题到当前暗色模式 */ * @param duration (ms)
export function syncToastTheme(darkMode: boolean): void { */
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light') export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
} MeToast[type](msg, duration !== undefined ? { duration } : undefined)
}
export { MeToast }
/** 同步 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>,
) )
+116 -103
View File
@@ -1,103 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { useEditorStore } from '../editorStore' import { useEditorStore } from '../editorStore'
describe('editorStore', () => { describe('editorStore', () => {
beforeEach(() => { beforeEach(() => {
useEditorStore.setState({ useEditorStore.setState({
viewMode: 'editor', viewMode: 'editor',
themeMode: 'light', themeMode: 'light',
externallyModified: null, externallyModified: null,
loadingStates: {}, loadingStates: {},
}) })
}) })
describe('setViewMode', () => { describe('setViewMode', () => {
it('should set view mode to preview', () => { it('should set view mode to preview', () => {
useEditorStore.getState().setViewMode('preview') useEditorStore.getState().setViewMode('preview')
expect(useEditorStore.getState().viewMode).toBe('preview') expect(useEditorStore.getState().viewMode).toBe('preview')
}) })
it('should set view mode to editor', () => { it('should set view mode to editor', () => {
useEditorStore.setState({ viewMode: 'preview' }) useEditorStore.setState({ viewMode: 'preview' })
useEditorStore.getState().setViewMode('editor') useEditorStore.getState().setViewMode('editor')
expect(useEditorStore.getState().viewMode).toBe('editor') expect(useEditorStore.getState().viewMode).toBe('editor')
}) })
}) })
describe('themeMode', () => { describe('themeMode', () => {
it('should default to light', () => { it('should default to light', () => {
expect(useEditorStore.getState().themeMode).toBe('light') expect(useEditorStore.getState().themeMode).toBe('light')
}) })
it('should set theme mode', () => { it('should set theme mode', () => {
useEditorStore.getState().setThemeMode('dark') useEditorStore.getState().setThemeMode('dark')
expect(useEditorStore.getState().themeMode).toBe('dark') expect(useEditorStore.getState().themeMode).toBe('dark')
}) })
it('should set warm theme', () => { it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm') useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm') expect(useEditorStore.getState().themeMode).toBe('warm')
}) })
}) })
describe('cycleTheme', () => { describe('cycleTheme', () => {
it('should cycle light → dark', () => { it('should cycle light → dark', () => {
expect(useEditorStore.getState().cycleTheme()).toBe('dark') expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().themeMode).toBe('dark') expect(useEditorStore.getState().themeMode).toBe('dark')
}) })
it('should cycle dark → warm', () => { it('should cycle dark → warm', () => {
useEditorStore.setState({ themeMode: 'dark' }) useEditorStore.setState({ themeMode: 'dark' })
expect(useEditorStore.getState().cycleTheme()).toBe('warm') expect(useEditorStore.getState().cycleTheme()).toBe('warm')
}) })
it('should cycle warm → light', () => { it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' }) useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light') expect(useEditorStore.getState().cycleTheme()).toBe('light')
}) })
}) })
describe('setExternallyModified', () => { describe('setExternallyModified', () => {
it('should set externally modified info', () => { it('should set externally modified info', () => {
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' }) useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' }) expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
}) })
it('should clear externally modified info', () => { it('should clear externally modified info', () => {
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } }) useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
useEditorStore.getState().setExternallyModified(null) useEditorStore.getState().setExternallyModified(null)
expect(useEditorStore.getState().externallyModified).toBeNull() expect(useEditorStore.getState().externallyModified).toBeNull()
}) })
}) })
describe('loading states', () => { describe('loading states', () => {
it('should set loading state', () => { it('should set loading state', () => {
useEditorStore.getState().setLoading('file-open', true) useEditorStore.getState().setLoading('file-open', true)
expect(useEditorStore.getState().isLoading('file-open')).toBe(true) expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
}) })
it('should return false for unset loading key', () => { it('should return false for unset loading key', () => {
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false) expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
}) })
it('should update loading state to false', () => { it('should update loading state to false', () => {
useEditorStore.getState().setLoading('file-open', true) useEditorStore.getState().setLoading('file-open', true)
useEditorStore.getState().setLoading('file-open', false) useEditorStore.getState().setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false) expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
}) })
it('should handle multiple independent loading states', () => { it('should handle multiple independent loading states', () => {
const { setLoading } = useEditorStore.getState() const { setLoading } = useEditorStore.getState()
setLoading('file-open', true) setLoading('file-open', true)
setLoading('markdown-render', true) setLoading('markdown-render', true)
const state = useEditorStore.getState() const state = useEditorStore.getState()
expect(state.isLoading('file-open')).toBe(true) expect(state.isLoading('file-open')).toBe(true)
expect(state.isLoading('markdown-render')).toBe(true) expect(state.isLoading('markdown-render')).toBe(true)
setLoading('file-open', false) setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false) expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
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)
})
})
})
+343 -317
View File
@@ -1,317 +1,343 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock external dependencies before importing store // Mock external dependencies before importing store
vi.mock('../../db/tabRepository', () => ({ vi.mock('../../db/tabRepository', () => ({
tabRepository: { tabRepository: {
loadAll: vi.fn().mockResolvedValue([]), loadAll: vi.fn().mockResolvedValue([]),
loadActiveTabId: vi.fn().mockResolvedValue(null), loadActiveTabId: vi.fn().mockResolvedValue(null),
saveAll: vi.fn().mockResolvedValue(undefined), saveAll: vi.fn().mockResolvedValue(undefined),
saveActiveTabId: vi.fn().mockResolvedValue(undefined), saveActiveTabId: vi.fn().mockResolvedValue(undefined),
clearAll: vi.fn().mockResolvedValue(undefined), clearAll: vi.fn().mockResolvedValue(undefined),
}, },
})) }))
vi.mock('nanoid', () => { vi.mock('nanoid', () => {
let counter = 0 let counter = 0
return { return {
nanoid: vi.fn(() => `test-id-${++counter}`), nanoid: vi.fn(() => `test-id-${++counter}`),
} }
}) })
import { useTabStore } from '../tabStore' import { useTabStore } from '../tabStore'
describe('tabStore', () => { describe('tabStore', () => {
beforeEach(() => { beforeEach(() => {
// Reset store state between tests // Reset store state between tests
useTabStore.setState({ useTabStore.setState({
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
mruStack: [], mruStack: [],
_loaded: false, _loaded: false,
}) })
}) })
describe('createTab', () => { describe('createTab', () => {
it('should create a new tab with default values', () => { it('should create a new tab with default values', () => {
const tab = useTabStore.getState().createTab() const tab = useTabStore.getState().createTab()
const state = useTabStore.getState() const state = useTabStore.getState()
expect(tab.id).toBeTruthy() expect(tab.id).toBeTruthy()
expect(tab.filePath).toBeNull() expect(tab.filePath).toBeNull()
expect(tab.content).toBe('') expect(tab.content).toBe('')
expect(tab.isModified).toBe(false) expect(tab.isModified).toBe(false)
expect(state.tabs).toHaveLength(1) expect(state.tabs).toHaveLength(1)
expect(state.activeTabId).toBe(tab.id) expect(state.activeTabId).toBe(tab.id)
}) })
it('should create a tab with file path and content', () => { it('should create a tab with file path and content', () => {
const tab = useTabStore.getState().createTab('/test.md', '# Hello') const tab = useTabStore.getState().createTab('/test.md', '# Hello')
expect(tab.filePath).toBe('/test.md') expect(tab.filePath).toBe('/test.md')
expect(tab.content).toBe('# Hello') expect(tab.content).toBe('# Hello')
}) })
it('should switch to existing tab if same filePath is opened', () => { it('should switch to existing tab if same filePath is opened', () => {
useTabStore.getState().createTab('/test.md', '# First') useTabStore.getState().createTab('/test.md', '# First')
// Create another tab to make it active // Create another tab to make it active
useTabStore.getState().createTab('/other.md', '# Other') useTabStore.getState().createTab('/other.md', '# Other')
// Now try to open the first file again // Now try to open the first file again
const existing = useTabStore.getState().createTab('/test.md', '# Updated') const existing = useTabStore.getState().createTab('/test.md', '# Updated')
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(2) expect(state.tabs).toHaveLength(2)
expect(state.activeTabId).toBe(existing.id) expect(state.activeTabId).toBe(existing.id)
}) })
}) })
describe('closeTab', () => { describe('closeTab', () => {
it('should close a tab and update activeTabId', () => { it('should close a tab and update activeTabId', () => {
const { createTab, closeTab } = useTabStore.getState() const { createTab, closeTab } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md') const tab2 = createTab('/file2.md')
closeTab(tab1.id) closeTab(tab1.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1) expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab2.id) expect(state.tabs[0].id).toBe(tab2.id)
expect(state.activeTabId).toBe(tab2.id) expect(state.activeTabId).toBe(tab2.id)
}) })
it('should set activeTabId to null when closing last tab', () => { it('should set activeTabId to null when closing last tab', () => {
const { createTab, closeTab } = useTabStore.getState() const { createTab, closeTab } = useTabStore.getState()
const tab = createTab('/file1.md') const tab = createTab('/file1.md')
closeTab(tab.id) closeTab(tab.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0) expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull() expect(state.activeTabId).toBeNull()
}) })
it('should handle closing non-existent tab gracefully', () => { it('should handle closing non-existent tab gracefully', () => {
const { createTab, closeTab } = useTabStore.getState() const { createTab, closeTab } = useTabStore.getState()
createTab('/file1.md') createTab('/file1.md')
closeTab('non-existent') closeTab('non-existent')
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1) expect(state.tabs).toHaveLength(1)
}) })
}) })
describe('closeOtherTabs', () => { describe('closeOtherTabs', () => {
it('should close all tabs except the specified one', () => { it('should close all tabs except the specified one', () => {
const { createTab, closeOtherTabs } = useTabStore.getState() const { createTab, closeOtherTabs } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab1 = createTab('/file1.md')
createTab('/file2.md') createTab('/file2.md')
createTab('/file3.md') createTab('/file3.md')
closeOtherTabs(tab1.id) closeOtherTabs(tab1.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1) expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id) expect(state.tabs[0].id).toBe(tab1.id)
expect(state.activeTabId).toBe(tab1.id) expect(state.activeTabId).toBe(tab1.id)
}) })
}) })
describe('closeAllTabs', () => { describe('closeAllTabs', () => {
it('should close all tabs', () => { it('should close all tabs', () => {
const { createTab, closeAllTabs } = useTabStore.getState() const { createTab, closeAllTabs } = useTabStore.getState()
createTab('/file1.md') createTab('/file1.md')
createTab('/file2.md') createTab('/file2.md')
closeAllTabs() closeAllTabs()
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0) expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull() expect(state.activeTabId).toBeNull()
expect(state.mruStack).toHaveLength(0) expect(state.mruStack).toHaveLength(0)
}) })
}) })
describe('closeTabsToRight', () => { describe('closeTabsToRight', () => {
it('should close tabs to the right of the specified tab', () => { it('should close tabs to the right of the specified tab', () => {
const { createTab, closeTabsToRight } = useTabStore.getState() const { createTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab1 = createTab('/file1.md')
createTab('/file2.md') createTab('/file2.md')
createTab('/file3.md') createTab('/file3.md')
closeTabsToRight(tab1.id) closeTabsToRight(tab1.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1) expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id) expect(state.tabs[0].id).toBe(tab1.id)
}) })
it('should keep active tab if it is in the remaining set', () => { it('should keep active tab if it is in the remaining set', () => {
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState() const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab1 = createTab('/file1.md')
createTab('/file2.md') createTab('/file2.md')
createTab('/file3.md') createTab('/file3.md')
switchToTab(tab1.id) switchToTab(tab1.id)
closeTabsToRight(tab1.id) closeTabsToRight(tab1.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id) expect(state.activeTabId).toBe(tab1.id)
}) })
}) })
describe('switchToTab', () => { describe('switchToTab', () => {
it('should switch active tab and update MRU stack', () => { it('should switch active tab and update MRU stack', () => {
const { createTab, switchToTab } = useTabStore.getState() const { createTab, switchToTab } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md') const tab2 = createTab('/file2.md')
switchToTab(tab1.id) switchToTab(tab1.id)
const state = useTabStore.getState() const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id) expect(state.activeTabId).toBe(tab1.id)
expect(state.mruStack).toContain(tab2.id) expect(state.mruStack).toContain(tab2.id)
}) })
it('should not update if switching to already active tab', () => { it('should not update if switching to already active tab', () => {
const { createTab, switchToTab } = useTabStore.getState() const { createTab, switchToTab } = useTabStore.getState()
createTab('/file1.md') createTab('/file1.md')
const stateBefore = useTabStore.getState() const stateBefore = useTabStore.getState()
switchToTab(stateBefore.activeTabId!) switchToTab(stateBefore.activeTabId!)
const stateAfter = useTabStore.getState() const stateAfter = useTabStore.getState()
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack) expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
}) })
}) })
describe('updateTabContent', () => { describe('updateTabContent', () => {
it('should update content and mark as modified', () => { it('should update content and mark as modified', () => {
const { createTab, updateTabContent } = useTabStore.getState() const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old') const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New Content') updateTabContent(tab.id, '# New Content')
const state = useTabStore.getState() const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)! const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New Content') expect(updated.content).toBe('# New Content')
expect(updated.isModified).toBe(true) expect(updated.isModified).toBe(true)
}) })
}) })
describe('getActiveTab', () => { describe('getActiveTab', () => {
it('should return the active tab', () => { it('should return the active tab', () => {
const { createTab, getActiveTab } = useTabStore.getState() const { createTab, getActiveTab } = useTabStore.getState()
const tab = createTab('/file.md') const tab = createTab('/file.md')
const active = getActiveTab() const active = getActiveTab()
expect(active?.id).toBe(tab.id) expect(active?.id).toBe(tab.id)
}) })
it('should return null when no tabs exist', () => { it('should return null when no tabs exist', () => {
const { getActiveTab } = useTabStore.getState() const { getActiveTab } = useTabStore.getState()
expect(getActiveTab()).toBeNull() expect(getActiveTab()).toBeNull()
}) })
}) })
describe('setModified', () => { describe('setModified', () => {
it('should set modified flag on a tab', () => { it('should set modified flag on a tab', () => {
const { createTab, setModified } = useTabStore.getState() const { createTab, setModified } = useTabStore.getState()
const tab = createTab('/file.md') const tab = createTab('/file.md')
setModified(tab.id, true) setModified(tab.id, true)
const state = useTabStore.getState() const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)! const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.isModified).toBe(true) expect(updated.isModified).toBe(true)
setModified(tab.id, false) setModified(tab.id, false)
const state2 = useTabStore.getState() const state2 = useTabStore.getState()
const updated2 = state2.tabs.find(t => t.id === tab.id)! const updated2 = state2.tabs.find(t => t.id === tab.id)!
expect(updated2.isModified).toBe(false) expect(updated2.isModified).toBe(false)
}) })
}) })
describe('updateTabScroll', () => { describe('updateTabScroll', () => {
it('should update scroll position', () => { it('should update scroll position', () => {
const { createTab, updateTabScroll } = useTabStore.getState() const { createTab, updateTabScroll } = useTabStore.getState()
const tab = createTab('/file.md') const tab = createTab('/file.md')
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 }) updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
const state = useTabStore.getState() const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)! const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.scrollTop).toBe(100) expect(updated.scrollTop).toBe(100)
expect(updated.selectionStart).toBe(10) expect(updated.selectionStart).toBe(10)
expect(updated.selectionEnd).toBe(20) expect(updated.selectionEnd).toBe(20)
}) })
}) })
describe('updateTabContent same-content guard (A3)', () => { describe('updateTabFilePath (B2)', () => {
it('should not mark as modified when content is unchanged', () => { it('should rebind tab to a new file path (save-as)', () => {
const { createTab, updateTabContent } = useTabStore.getState() const { createTab, updateTabFilePath } = useTabStore.getState()
const tab = createTab('/file.md', '# Same Content') const tab = createTab('/old.md', '# Content')
updateTabContent(tab.id, '# Same Content') updateTabFilePath(tab.id, '/new.md')
const state = useTabStore.getState() const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)! const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# Same Content') expect(updated.filePath).toBe('/new.md')
expect(updated.isModified).toBe(false) expect(updated.content).toBe('# Content')
}) })
it('should mark as modified when content changes', () => { it('should not affect other tabs', () => {
const { createTab, updateTabContent } = useTabStore.getState() const { createTab, updateTabFilePath } = useTabStore.getState()
const tab = createTab('/file.md', '# Old') const tab1 = createTab('/a.md')
const tab2 = createTab('/b.md')
updateTabContent(tab.id, '# New')
const state = useTabStore.getState() updateTabFilePath(tab1.id, '/c.md')
const updated = state.tabs.find(t => t.id === tab.id)! const state = useTabStore.getState()
expect(updated.content).toBe('# New') expect(state.tabs.find(t => t.id === tab1.id)?.filePath).toBe('/c.md')
expect(updated.isModified).toBe(true) expect(state.tabs.find(t => t.id === tab2.id)?.filePath).toBe('/b.md')
}) })
}) })
describe('moveTab (D1)', () => { describe('updateTabContent same-content guard (A3)', () => {
it('should move tab to a new position', () => { it('should not mark as modified when content is unchanged', () => {
const { createTab, moveTab } = useTabStore.getState() const { createTab, updateTabContent } = useTabStore.getState()
const tab1 = createTab('/file1.md') const tab = createTab('/file.md', '# Same Content')
const tab2 = createTab('/file2.md')
const tab3 = createTab('/file3.md') updateTabContent(tab.id, '# Same Content')
const state = useTabStore.getState()
// tab1 移到末尾:期望 [tab2, tab3, tab1] const updated = state.tabs.find(t => t.id === tab.id)!
moveTab(tab1.id, 2)
const state = useTabStore.getState() expect(updated.content).toBe('# Same Content')
expect(updated.isModified).toBe(false)
// 用引用相等而非基于全局计数器 ID })
expect(state.tabs[0]).toBe(tab2)
expect(state.tabs[1]).toBe(tab3) it('should mark as modified when content changes', () => {
expect(state.tabs[2]).toBe(tab1) const { createTab, updateTabContent } = useTabStore.getState()
}) const tab = createTab('/file.md', '# Old')
it('should handle moving to same position (no-op)', () => { updateTabContent(tab.id, '# New')
const { createTab, moveTab } = useTabStore.getState() const state = useTabStore.getState()
const tab1 = createTab('/file1.md') const updated = state.tabs.find(t => t.id === tab.id)!
createTab('/file2.md')
expect(updated.content).toBe('# New')
moveTab(tab1.id, 0) expect(updated.isModified).toBe(true)
const state = useTabStore.getState() })
})
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2) describe('moveTab (D1)', () => {
}) it('should move tab to a new position', () => {
const { createTab, moveTab } = useTabStore.getState()
it('should handle moving non-existent tab gracefully', () => { const tab1 = createTab('/file1.md')
const { createTab, moveTab } = useTabStore.getState() const tab2 = createTab('/file2.md')
createTab('/file1.md') const tab3 = createTab('/file3.md')
moveTab('non-existent', 0) // tab1 移到末尾:期望 [tab2, tab3, tab1]
const state = useTabStore.getState() moveTab(tab1.id, 2)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
}) // 用引用相等而非基于全局计数器 ID
}) expect(state.tabs[0]).toBe(tab2)
}) expect(state.tabs[1]).toBe(tab3)
expect(state.tabs[2]).toBe(tab1)
})
it('should handle moving to same position (no-op)', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
moveTab(tab1.id, 0)
const state = useTabStore.getState()
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2)
})
it('should handle moving non-existent tab gracefully', () => {
const { createTab, moveTab } = useTabStore.getState()
createTab('/file1.md')
moveTab('non-existent', 0)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
})
})
})
+24 -24
View File
@@ -1,24 +1,24 @@
import { create } from 'zustand' import { create } from 'zustand'
/** /**
* auto-save store * auto-save store
* *
* useAutoSave hook auto-save * useAutoSave hook auto-save
* store render useAutoSave() * store render useAutoSave()
* useAutoSave hook store * useAutoSave hook store
*/ */
interface AutoSaveState { interface AutoSaveState {
isAutoSaving: boolean isAutoSaving: boolean
autoSaveEnabled: boolean autoSaveEnabled: boolean
} }
interface AutoSaveStore extends AutoSaveState { 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),
})) }))
+62 -56
View File
@@ -1,56 +1,62 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ViewMode, ThemeMode } from '../types/settings' import type { ViewMode, ThemeMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor' import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter for the MetonaEditor instance // Module-level getter for the MetonaEditor instance
// Used by OutlinePanel for heading navigation // Used by OutlinePanel for heading navigation
let _getEditor: (() => MarkdownEditor | null) | null = null let _getEditor: (() => MarkdownEditor | null) | null = null
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) { export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getEditor = fn _getEditor = fn
} }
export function getMetonaEditor(): MarkdownEditor | null { export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null return _getEditor ? _getEditor() : null
} }
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm'] const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
interface EditorState { interface EditorState {
viewMode: ViewMode viewMode: ViewMode
themeMode: ThemeMode 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 消费)
setViewMode: (mode: ViewMode) => void zenMode: boolean
setThemeMode: (mode: ThemeMode) => void
cycleTheme: () => ThemeMode setViewMode: (mode: ViewMode) => void
setExternallyModified: (info: { filePath: string } | null) => void setThemeMode: (mode: ThemeMode) => void
// UX-02: 加载状态管理 cycleTheme: () => ThemeMode
setLoading: (key: string, loading: boolean) => void setExternallyModified: (info: { filePath: string } | null) => void
isLoading: (key: string) => boolean // UX-02: 加载状态管理
} setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean
export const useEditorStore = create<EditorState>((set, get) => ({ setZenMode: (zen: boolean) => void
viewMode: 'editor', }
themeMode: 'light',
externallyModified: null, export const useEditorStore = create<EditorState>((set, get) => ({
loadingStates: {}, viewMode: 'editor',
themeMode: 'light',
setViewMode: (mode: ViewMode) => set({ viewMode: mode }), externallyModified: null,
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }), loadingStates: {},
cycleTheme: () => { zenMode: false,
const current = get().themeMode
const idx = THEME_CYCLE.indexOf(current) setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length] setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
set({ themeMode: next }) cycleTheme: () => {
return next const current = get().themeMode
}, const idx = THEME_CYCLE.indexOf(current)
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }), const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
setLoading: (key: string, loading: boolean) => set(state => ({ set({ themeMode: next })
loadingStates: { ...state.loadingStates, [key]: loading } return next
})), },
isLoading: (key: string) => get().loadingStates[key] ?? false setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
})) setLoading: (key: string, loading: boolean) =>
set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading },
})),
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 })
} },
})) }))
+322 -271
View File
@@ -1,271 +1,322 @@
import { create } from 'zustand' import { create } from 'zustand'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import type { Tab } from '../types/tab' import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository' import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数 // PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } { function debounce<F extends (...args: unknown[]) => void>(
let timer: ReturnType<typeof setTimeout> | null = null fn: F,
const debounced = ((...args: unknown[]) => { delay: number,
if (timer) clearTimeout(timer) ): F & { cancel: () => void } {
timer = setTimeout(() => { let timer: ReturnType<typeof setTimeout> | null = null
fn(...args) const debounced = ((...args: unknown[]) => {
timer = null if (timer) clearTimeout(timer)
}, delay) timer = setTimeout(() => {
}) as F & { cancel: () => void } fn(...args)
debounced.cancel = () => { timer = null
if (timer) { }, delay)
clearTimeout(timer) }) as F & { cancel: () => void }
timer = null debounced.cancel = () => {
} if (timer) {
} clearTimeout(timer)
return debounced timer = null
} }
}
interface TabState { return debounced
tabs: Tab[] }
activeTabId: string | null
mruStack: string[] interface TabState {
_loaded: boolean tabs: Tab[]
activeTabId: string | null
createTab: (filePath?: string | null, content?: string) => Tab mruStack: string[]
closeTab: (tabId: string) => void _loaded: boolean
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void createTab: (filePath?: string | null, content?: string) => Tab
closeTabsToRight: (tabId: string) => void closeTab: (tabId: string) => void
moveTab: (fromId: string, toIndex: number) => void closeOtherTabs: (tabId: string) => void
switchToTab: (tabId: string) => void closeAllTabs: () => void
updateTabContent: (tabId: string, content: string) => void closeTabsToRight: (tabId: string) => void
setModified: (tabId: string, modified: boolean) => void moveTab: (fromId: string, toIndex: number) => void
getActiveTab: () => Tab | null switchToTab: (tabId: string) => void
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void updateTabContent: (tabId: string, content: string) => void
loadFromDB: () => Promise<void> updateTabFilePath: (tabId: string, filePath: string) => void
saveToDB: () => Promise<void> setModified: (tabId: string, modified: boolean) => void
} getActiveTab: () => Tab | null
updateTabScroll: (
// PF-08: 模块级防抖保存函数(500ms延迟) tabId: string,
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => void
function getActualSaveToDB(get: () => TabState) { loadFromDB: () => Promise<void>
return async () => { saveToDB: () => Promise<void>
const { tabs } = get() }
if (tabs.length === 0) {
await tabRepository.clearAll() // PF-08: 模块级防抖保存函数(500ms延迟)
return let _debouncedSaveToDB: ((() => void) & { cancel: () => void }) | null = null
}
const snapshots = tabs.map(t => ({ function getActualSaveToDB(get: () => TabState) {
id: t.id, return async () => {
filePath: t.filePath, const { tabs } = get()
content: t.content, if (tabs.length === 0) {
isModified: t.isModified, await tabRepository.clearAll()
scrollTop: t.scrollTop, return
selectionStart: t.selectionStart, }
selectionEnd: t.selectionEnd, const snapshots = tabs.map(t => ({
updatedAt: Date.now() id: t.id,
})) filePath: t.filePath,
await tabRepository.saveAll(snapshots) content: t.content,
await tabRepository.saveActiveTabId(get().activeTabId) isModified: t.isModified,
} scrollTop: t.scrollTop,
} selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd,
export const useTabStore = create<TabState>((set, get) => { updatedAt: Date.now(),
const actualSave = getActualSaveToDB(get) }))
_debouncedSaveToDB = debounce(actualSave, 500) await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
return { }
tabs: [], }
activeTabId: null,
mruStack: [], export const useTabStore = create<TabState>((set, get) => {
_loaded: false, const actualSave = getActualSaveToDB(get)
_debouncedSaveToDB = debounce(actualSave, 500)
loadFromDB: async () => {
if (get()._loaded) return return {
try { tabs: [],
const [snapshots, savedActiveTabId] = await Promise.all([ activeTabId: null,
tabRepository.loadAll(), mruStack: [],
tabRepository.loadActiveTabId() _loaded: false,
])
if (snapshots.length > 0) { loadFromDB: async () => {
const tabs: Tab[] = snapshots.map(s => ({ if (get()._loaded) return
id: s.id, try {
filePath: s.filePath, const [snapshots, savedActiveTabId] = await Promise.all([
content: s.content, tabRepository.loadAll(),
isModified: s.isModified, tabRepository.loadActiveTabId(),
scrollTop: s.scrollTop, ])
selectionStart: s.selectionStart, if (snapshots.length > 0) {
selectionEnd: s.selectionEnd const tabs: Tab[] = snapshots.map(s => ({
})) id: s.id,
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) filePath: s.filePath,
? savedActiveTabId content: s.content,
: tabs[tabs.length - 1].id isModified: s.isModified,
set({ tabs, activeTabId, _loaded: true }) scrollTop: s.scrollTop,
} else { selectionStart: s.selectionStart,
set({ _loaded: true }) selectionEnd: s.selectionEnd,
} }))
} catch (err) { // v0.6.2: 磁盘 mtime 比对 — 未修改的标签若磁盘内容比快照新
// eslint-disable-next-line no-console -- DB load error // (应用关闭期间被外部修改),恢复时读取磁盘最新内容
console.error('Failed to load tabs from DB:', err) let refreshed = 0
set({ _loaded: true }) if (typeof window !== 'undefined' && window.electronAPI) {
} for (const tab of tabs) {
}, if (!tab.filePath || tab.isModified) continue
const snap = snapshots.find(s => s.id === tab.id)
saveToDB: async () => { if (!snap || !snap.updatedAt) continue
if (_debouncedSaveToDB) { try {
_debouncedSaveToDB() const stats = await window.electronAPI.getFileStats(tab.filePath)
} if (
}, stats.success &&
stats.mtime &&
createTab: (filePath: string | null = null, content: string = ''): Tab => { new Date(stats.mtime).getTime() > snap.updatedAt
if (filePath) { ) {
const existing = get().tabs.find(t => t.filePath === filePath) const read = await window.electronAPI.readFile(tab.filePath)
if (existing) { if (read.success && read.content !== undefined) {
get().switchToTab(existing.id) tab.content = read.content
return existing refreshed++
} }
} }
} catch {
const tab: Tab = { // 单个文件校验失败不影响整体恢复
id: nanoid(), }
filePath, }
content, }
isModified: false, const activeTabId =
scrollTop: 0, savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
selectionStart: 0, ? savedActiveTabId
selectionEnd: 0 : tabs[tabs.length - 1].id
} set({ tabs, activeTabId, _loaded: true })
if (refreshed > 0) {
set(state => ({ _debouncedSaveToDB?.()
tabs: [...state.tabs, tab], try {
activeTabId: tab.id const { showToast } = await import('../lib/toast')
})) showToast(`${refreshed} 个标签已同步磁盘最新内容`, 'info')
} catch {
_debouncedSaveToDB?.() /* 提示失败不影响主流程 */
return tab }
}, }
} else {
closeTab: (tabId: string) => { set({ _loaded: true })
set(state => { }
const index = state.tabs.findIndex(t => t.id === tabId) } catch (err) {
if (index === -1) return state // eslint-disable-next-line no-console -- DB load error
console.error('Failed to load tabs from DB:', err)
const newTabs = state.tabs.filter(t => t.id !== tabId) set({ _loaded: true })
const newMru = state.mruStack.filter(id => id !== tabId) }
},
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) { saveToDB: async () => {
if (newTabs.length === 0) { if (_debouncedSaveToDB) {
newActiveId = null _debouncedSaveToDB()
} else { }
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) },
if (mruCandidate) {
newActiveId = mruCandidate createTab: (filePath: string | null = null, content: string = ''): Tab => {
newMru.splice(newMru.indexOf(mruCandidate), 1) if (filePath) {
} else { const existing = get().tabs.find(t => t.filePath === filePath)
const newIndex = Math.min(index, newTabs.length - 1) if (existing) {
newActiveId = newTabs[newIndex].id get().switchToTab(existing.id)
} return existing
} }
} }
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru } const tab: Tab = {
}) id: nanoid(),
filePath,
_debouncedSaveToDB?.() content,
}, isModified: false,
scrollTop: 0,
closeOtherTabs: (tabId: string) => { selectionStart: 0,
set(state => { selectionEnd: 0,
const target = state.tabs.find(t => t.id === tabId) }
if (!target) return state
return { tabs: [target], activeTabId: tabId, mruStack: [] } set(state => ({
}) tabs: [...state.tabs, tab],
_debouncedSaveToDB?.() activeTabId: tab.id,
}, }))
closeAllTabs: () => { _debouncedSaveToDB?.()
set({ tabs: [], activeTabId: null, mruStack: [] }) return tab
_debouncedSaveToDB?.() },
},
closeTab: (tabId: string) => {
closeTabsToRight: (tabId: string) => { set(state => {
set(state => { const index = state.tabs.findIndex(t => t.id === tabId)
const index = state.tabs.findIndex(t => t.id === tabId) if (index === -1) return state
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1) const newTabs = state.tabs.filter(t => t.id !== tabId)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) const newMru = state.mruStack.filter(id => id !== tabId)
? state.activeTabId
: tabId let newActiveId = state.activeTabId
return { if (state.activeTabId === tabId) {
tabs: newTabs, if (newTabs.length === 0) {
activeTabId: newActiveId, newActiveId = null
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) } else {
} const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
}) if (mruCandidate) {
_debouncedSaveToDB?.() newActiveId = mruCandidate
}, newMru.splice(newMru.indexOf(mruCandidate), 1)
} else {
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置 const newIndex = Math.min(index, newTabs.length - 1)
moveTab: (fromId: string, toIndex: number) => { newActiveId = newTabs[newIndex].id
set(state => { }
const fromIndex = state.tabs.findIndex(t => t.id === fromId) }
if (fromIndex === -1 || fromIndex === toIndex) return state }
const newTabs = [...state.tabs]
const [moved] = newTabs.splice(fromIndex, 1) return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短, })
// toIndex 相对于原数组的位置在移除后无需调整)
newTabs.splice(toIndex, 0, moved) _debouncedSaveToDB?.()
return { tabs: newTabs } },
})
_debouncedSaveToDB?.() closeOtherTabs: (tabId: string) => {
}, set(state => {
const target = state.tabs.find(t => t.id === tabId)
switchToTab: (tabId: string) => { if (!target) return state
set(state => { return { tabs: [target], activeTabId: tabId, mruStack: [] }
if (state.activeTabId === tabId) return state })
const newMru = state.activeTabId _debouncedSaveToDB?.()
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)] },
: state.mruStack
return { activeTabId: tabId, mruStack: newMru } closeAllTabs: () => {
}) set({ tabs: [], activeTabId: null, mruStack: [] })
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) _debouncedSaveToDB?.()
}, },
updateTabContent: (tabId: string, content: string) => { closeTabsToRight: (tabId: string) => {
set(state => ({ set(state => {
tabs: state.tabs.map(t => { const index = state.tabs.findIndex(t => t.id === tabId)
if (t.id !== tabId) return t if (index === -1) return state
// A3: 内容未变时不变更对象引用(避免误标记 isModified const newTabs = state.tabs.slice(0, index + 1)
if (t.content === content) return t const newActiveId =
return { ...t, content, isModified: true } state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
}) ? state.activeTabId
})) : tabId
}, return {
tabs: newTabs,
setModified: (tabId: string, modified: boolean) => { activeTabId: newActiveId,
set(state => ({ mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)),
tabs: state.tabs.map(t => }
t.id === tabId ? { ...t, isModified: modified } : t })
) _debouncedSaveToDB?.()
})) },
},
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
getActiveTab: (): Tab | null => { moveTab: (fromId: string, toIndex: number) => {
const { tabs, activeTabId } = get() set(state => {
return tabs.find(t => t.id === activeTabId) ?? null const fromIndex = state.tabs.findIndex(t => t.id === fromId)
}, if (fromIndex === -1 || fromIndex === toIndex) return state
const newTabs = [...state.tabs]
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => { const [moved] = newTabs.splice(fromIndex, 1)
set(state => ({ // 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
tabs: state.tabs.map(t => // toIndex 相对于原数组的位置在移除后无需调整)
t.id === tabId ? { ...t, ...scroll } : t newTabs.splice(toIndex, 0, moved)
) return { tabs: newTabs }
})) })
} _debouncedSaveToDB?.()
} },
})
switchToTab: (tabId: string) => {
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入) set(state => {
export function flushSaveToDB(): Promise<void> { if (state.activeTabId === tabId) return state
_debouncedSaveToDB?.cancel() const newMru = state.activeTabId
return getActualSaveToDB(useTabStore.getState)() ? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
} : state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
updateTabContent: (tabId: string, content: string) => {
set(state => ({
tabs: state.tabs.map(t => {
if (t.id !== tabId) return t
// A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t
return { ...t, content, isModified: true }
}),
}))
},
setModified: (tabId: string, modified: boolean) => {
set(state => ({
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, isModified: modified } : t)),
}))
},
// B2-fix: 另存为成功后重新绑定标签的文件路径(并触发快照持久化)
updateTabFilePath: (tabId: string, filePath: string) => {
set(state => ({
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, filePath } : t)),
}))
_debouncedSaveToDB?.()
},
getActiveTab: (): Tab | null => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
updateTabScroll: (
tabId: string,
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => {
set(state => ({
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, ...scroll } : t)),
}))
},
}
})
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
export function flushSaveToDB(): Promise<void> {
_debouncedSaveToDB?.cancel()
return getActualSaveToDB(useTabStore.getState)()
}
+182 -168
View File
@@ -906,174 +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);
}
}
/* ===== UX-07: 通用可访问性增强 ===== */ /* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */ /* Focus visible 为所有交互元素提供清晰的焦点指示 */
@@ -1110,6 +942,188 @@ button:focus-visible,
border: 0; border: 0;
} }
/* ===== v0.6.2: 多文件搜索面板 ===== */
.search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 10001;
padding-top: 8vh;
animation: fadeIn 0.15s ease;
}
.search-panel {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 640px;
max-width: 92vw;
max-height: 76vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.search-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.search-form {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text);
font-size: 13px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s ease;
}
.search-input:focus {
border-color: var(--primary);
}
.search-run-btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
background: var(--primary);
color: white;
font-size: 13px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
}
.search-run-btn:disabled {
opacity: 0.5;
cursor: default;
}
.search-options {
display: flex;
align-items: center;
gap: 14px;
margin-top: 8px;
}
.search-option {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.search-option input {
accent-color: var(--primary);
}
.search-close-btn {
margin-left: auto;
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
}
.search-close-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.search-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.search-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.search-summary {
padding: 8px 14px;
font-size: 12px;
color: var(--text-tertiary);
border-bottom: 1px solid var(--border-light);
}
.search-results {
padding: 4px 0;
}
.search-result-item {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 6px 14px;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-family: var(--font-ui);
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
}
.search-result-item:hover {
background: var(--primary-light);
}
.search-result-file {
flex-shrink: 0;
font-weight: 500;
color: var(--primary);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-line {
flex-shrink: 0;
color: var(--text-tertiary);
min-width: 24px;
text-align: right;
}
.search-result-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
/* ===== ErrorBoundary ===== */ /* ===== ErrorBoundary ===== */
.error-boundary-root { .error-boundary-root {
display: flex; display: flex;
+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);
} }
+69 -51
View File
@@ -1,51 +1,69 @@
import type { import type {
OpenFileResponse, OpenFileResponse,
ReadFileResult, ReadFileResult,
SaveFilePayload, SaveFilePayload,
SaveFileResult, SaveFileResult,
SaveAsPayload, SaveAsPayload,
ReloadFileResult, ReloadFileResult,
FileStatsResult, FileStatsResult,
ReadDirTreeResult ReadDirTreeResult,
} from '../../shared/types' SearchInDirPayload,
SearchInDirResult,
export interface IpcInvokeMap { } from '../../shared/types'
'dialog:openFile': [void, OpenFileResponse]
'file:read': [string, ReadFileResult] export interface IpcInvokeMap {
'file:save': [SaveFilePayload, SaveFileResult] 'dialog:openFile': [void, OpenFileResponse]
'file:saveAs': [SaveAsPayload, SaveFileResult] 'file:read': [string, ReadFileResult]
'file:getCurrentPath': [void, string | null] 'file:save': [SaveFilePayload, SaveFileResult]
'file:stats': [string, FileStatsResult] 'file:saveAs': [SaveAsPayload, SaveFileResult]
'file:reload': [void, ReloadFileResult] 'file:getCurrentPath': [void, string | null]
'tab:switched': [string | null, void] 'file:stats': [string, FileStatsResult]
'window:forceClose': [void, void] 'file:reload': [void, ReloadFileResult]
'window:cancelClose': [void, void] 'tab:switched': [string | null, void]
'dir:readTree': [string, ReadDirTreeResult] 'window:forceClose': [void, void]
'dir:openDialog': [void, string | null] 'window:cancelClose': [void, void]
'dir:watch': [string, void] 'dir:readTree': [string, ReadDirTreeResult]
'dir:unwatch': [void, void] 'dir:openDialog': [void, string | null]
} 'dir:watch': [string, void]
'dir:unwatch': [void, void]
export type Unsubscribe = () => void 'dir:search': [SearchInDirPayload, SearchInDirResult]
'data:export': [
export interface ElectronAPI { string,
openFile: () => Promise<OpenFileResponse> { success: boolean; canceled?: boolean; filePath?: string; error?: string },
readFile: (filePath: string) => Promise<ReadFileResult> ]
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult> 'data:import': [void, { success: boolean; canceled?: boolean; content?: string; error?: string }]
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult> }
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult> export type Unsubscribe = () => void
reloadFile: () => Promise<ReloadFileResult>
tabSwitched: (filePath: string | null) => Promise<void> export interface ElectronAPI {
forceClose: () => Promise<void> openFile: () => Promise<OpenFileResponse>
cancelClose: () => Promise<void> readFile: (filePath: string) => Promise<ReadFileResult>
openExternal: (url: string) => void saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult> saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
openFolderDialog: () => Promise<string | null> getCurrentPath: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void> getFileStats: (filePath: string) => Promise<FileStatsResult>
unwatchDir: () => Promise<void> reloadFile: () => Promise<ReloadFileResult>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe tabSwitched: (filePath: string | null) => Promise<void>
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe forceClose: () => Promise<void>
onDirChanged: (callback: () => void) => Unsubscribe cancelClose: () => Promise<void>
onConfirmClose: (callback: () => void) => Unsubscribe openExternal: (url: string) => void
} readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
searchInDir: (payload: SearchInDirPayload) => Promise<SearchInDirResult>
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
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe
}
+16 -16
View File
@@ -1,16 +1,16 @@
export type ThemeMode = 'light' | 'dark' | 'warm' export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source' export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings { export interface Settings {
themeMode: ThemeMode themeMode: ThemeMode
viewMode: ViewMode viewMode: ViewMode
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
themeMode: 'light', themeMode: 'light',
viewMode: 'editor', viewMode: 'editor',
sidebarCollapsed: false, sidebarCollapsed: false,
sidebarWidth: 240 sidebarWidth: 240,
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16 -8
View File
@@ -1,8 +1,16 @@
// 共享常量 — 主进程和渲染进程共用 // 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.5.0' export const APP_VERSION = 'v0.6.2'
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',
])
+31 -26
View File
@@ -1,26 +1,31 @@
// IPC 通道名常量 — 主进程和渲染进程共享 // IPC 通道名常量 — 主进程和渲染进程共享
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
// 渲染进程 → 主进程 (invoke) // 渲染进程 → 主进程 (invoke)
DIALOG_OPEN_FILE: 'dialog:openFile', DIALOG_OPEN_FILE: 'dialog:openFile',
FILE_READ: 'file:read', FILE_READ: 'file:read',
FILE_SAVE: 'file:save', FILE_SAVE: 'file:save',
FILE_SAVE_AS: 'file:saveAs', FILE_SAVE_AS: 'file:saveAs',
FILE_GET_CURRENT_PATH: 'file:getCurrentPath', FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
FILE_STATS: 'file:stats', FILE_STATS: 'file:stats',
FILE_RELOAD: 'file:reload', FILE_RELOAD: 'file:reload',
TAB_SWITCHED: 'tab:switched', TAB_SWITCHED: 'tab:switched',
WINDOW_FORCE_CLOSE: 'window:forceClose', WINDOW_FORCE_CLOSE: 'window:forceClose',
WINDOW_CANCEL_CLOSE: 'window:cancelClose', WINDOW_CANCEL_CLOSE: 'window:cancelClose',
DIR_READ_TREE: 'dir:readTree', DIR_READ_TREE: 'dir:readTree',
DIR_OPEN_DIALOG: 'dir:openDialog', DIR_OPEN_DIALOG: 'dir:openDialog',
DIR_WATCH: 'dir:watch', DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch', DIR_UNWATCH: 'dir:unwatch',
DIR_SEARCH: 'dir:search',
// 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab', // v0.6.0: 数据备份导出/导入(JSON
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified', DATA_EXPORT: 'data:export',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose', DATA_IMPORT: 'data:import',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
} as const // 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab',
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS] FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged',
} as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
+24
View File
@@ -63,3 +63,27 @@ export interface OpenFileError {
} }
export type OpenFileResponse = OpenFileResult | OpenFileError | null export type OpenFileResponse = OpenFileResult | OpenFileError | null
// v0.6.2: 多文件搜索
export interface SearchInDirPayload {
dirPath: string
query: string
caseSensitive?: boolean
useRegex?: boolean
}
export interface SearchMatch {
filePath: string
/** 1-based 行号 */
line: number
/** 匹配行内容(截断至 200 字符) */
lineText: string
}
export interface SearchInDirResult {
success: boolean
matches?: SearchMatch[]
totalFiles?: number
truncated?: boolean
error?: string
}