diff --git a/.tscheck/corrupt.mjs b/.tscheck/corrupt.mjs new file mode 100644 index 0000000..3165eca --- /dev/null +++ b/.tscheck/corrupt.mjs @@ -0,0 +1,34 @@ +import 'fake-indexeddb/auto' +import { create } from '/mnt/d/CodeWorkspace/MarkLite/node_modules/@metona-team/metona-sqlark/dist/metona-sqlark.esm.js' + +const DB = 'aria-corrupt-test' + +// 1. 正常建库写数据 +const db1 = await create({ name: 'corrupt-test', mode: 'aria', diskEngine: 'indexeddb', version: 0 }) +await db1.defineTable('recentFiles', { filePath: { type: 'string', primaryKey: true }, lastOpened: { type: 'number' } }) +await db1.table('recentFiles').insert({ filePath: '/a.md', lastOpened: 1 }) +await db1.close() + +// 2. 模拟异常退出留下的残缺 SSTable 页面:直接往 IDB 写一个 10 字节的 pg_ 文件 +await new Promise((resolve, reject) => { + const req = indexedDB.open(DB, 1) + req.onsuccess = () => { + const idb = req.result + const tx = idb.transaction('data', 'readwrite') + tx.objectStore('data').put(new ArrayBuffer(10), 'pg_1') // 残缺页面(正常 4096 字节) + tx.oncomplete = () => { idb.close(); resolve() } + tx.onerror = () => reject(tx.error) + } + req.onerror = () => reject(req.error) +}) + +// 3. 重新打开 → 应该抛 offset is out of bounds +try { + const db2 = await create({ name: 'corrupt-test', mode: 'aria', diskEngine: 'indexeddb', version: 0 }) + const rows = await db2.table('recentFiles').select().execute() + console.log('S2 opened OK, rows:', JSON.stringify(rows)) + await db2.close() + console.log('NO ERROR — 未能复现') +} catch (e) { + console.log('REPRODUCED:', e.constructor.name, '|', e.message) +} diff --git a/.tscheck/heal.mjs b/.tscheck/heal.mjs new file mode 100644 index 0000000..1aa126a --- /dev/null +++ b/.tscheck/heal.mjs @@ -0,0 +1,41 @@ +import 'fake-indexeddb/auto' +import { create } from '/mnt/d/CodeWorkspace/MarkLite/node_modules/@metona-team/metona-sqlark/dist/metona-sqlark.esm.js' + +const DB = 'aria-heal-test' + +// 1. 正常建库写数据 +const db1 = await create({ name: 'heal-test', mode: 'aria', diskEngine: 'indexeddb', version: 0 }) +await db1.defineTable('recentFiles', { filePath: { type: 'string', primaryKey: true }, lastOpened: { type: 'number' } }) +await db1.table('recentFiles').insert({ filePath: '/a.md', lastOpened: 1 }) +await db1.close() + +// 2. 制造损坏:写一个 footer 声称块偏移在 100000 的残缺 sst 文件(data store 里直接塞坏数据) +await new Promise((resolve, reject) => { + const req = indexedDB.open(DB, 1) + req.onsuccess = () => { + const idb = req.result + const tx = idb.transaction('data', 'readwrite') + const store = tx.objectStore('data') + // 残缺 sst:8 字节(正常是 4096 块),meta 记录会声称偏移 4096+ + store.put(new ArrayBuffer(8), 'sst_1') + tx.oncomplete = () => { idb.close(); resolve() } + tx.onerror = () => reject(tx.error) + } + req.onerror = () => reject(req.error) +}) + +// 3. 删除损坏库(模拟自愈,无活动连接时应该成功) +await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(DB) + req.onsuccess = () => { console.log('deleteDatabase OK'); resolve() } + req.onerror = () => { console.log('deleteDatabase error'); resolve() } + req.onblocked = () => { console.log('deleteDatabase BLOCKED'); resolve() } +}) + +// 4. 重建库成功 +const db2 = await create({ name: 'heal-test', mode: 'aria', diskEngine: 'indexeddb', version: 0 }) +await db2.defineTable('recentFiles', { filePath: { type: 'string', primaryKey: true }, lastOpened: { type: 'number' } }) +await db2.table('recentFiles').insert({ filePath: '/new.md', lastOpened: 2 }) +console.log('rebuilt rows:', JSON.stringify(await db2.table('recentFiles').select().execute())) +await db2.close() +console.log('HEAL OK') diff --git a/DESIGN.md b/DESIGN.md index a5ba0a1..c209e98 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,13 +2,13 @@ ## 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 状态管理、MetonaSqlark(AriaEngine)持久化。 ### 1.1 核心原则 1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义 2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types) -3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize +3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + 内置解析器 XSS 防护 4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦 ## 2. 技术架构 @@ -23,8 +23,8 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 | 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 | | 状态管理 | Zustand | v5 | 轻量级状态管理 | | 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件(AriaEngine) | -| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) | -| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js | +| Markdown 解析 | MetonaEditor 内置解析器 | v0.4.0 | 零依赖,GFM + 脚注 + 数学公式 + mermaid | +| 代码高亮 | MetonaEditor 内置高亮器 | v0.4.0 | 零依赖,16 种语言 | | Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 | | 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 | | 打包工具 | electron-builder | v25 | Windows NSIS 安装包 | @@ -86,7 +86,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 | Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | | IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 | | CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 | -| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 | +| HTML | `MetonaEditor 内置解析器` | 渲染 Markdown(escapeHTML + safeUrl XSS 防护) | | 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | | 路径 | `validatePath()` | 防止路径遍历攻击 | @@ -265,26 +265,27 @@ MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向 ### 6.4 渲染管线集成 -通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现: +v0.6.0: 移除 unified/remark/rehype 自研管线,改用 MetonaEditor **内置解析器**(parseMarkdown), +通过 `render` 钩子接入,实现: -- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径 -- **XSS 防护**:rehype-sanitize 过滤危险标签 -- **代码高亮**:rehype-highlight 语法高亮 -- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶 +- **相对路径图片解析**:内置解析器的 safeUrl 会过滤 `file:` 协议,因此渲染后做 HTML 后处理,将相对路径图片 src 转换为 `file://` 绝对路径(越界路径保持原样) +- **XSS 防护**:内置 escapeHTML + safeUrl(过滤 javascript:/vbscript:/file:/data:)+ 属性转义 +- **代码高亮**:内置零依赖高亮器(`highlight: MeEditor.highlight`,16 种语言) +- **Mermaid 图表**:内置解析器原生输出 `.me-mermaid` 容器,`mermaid.run()` 直接渲染 ``` Markdown 源码 │ ▼ -unified 管线(renderMarkdownSync) - ├── remark-parse 解析为 MDAST - ├── remark-gfm GFM 扩展 - ├── remark-rehype 转换为 HAST - ├── rehype-raw 解析内联 HTML - ├── rehype-sanitize 安全过滤 - ├── rehype-fixImages 相对路径 → file:// - ├── rehype-highlight 代码高亮 - └── rehype-stringify 序列化为 HTML +parseMarkdown(MetonaEditor 内置解析器) + ├── GFM / 任务列表 / 表格 / 删除线 + ├── 脚注 / 数学公式 / 定义列表 / emoji + ├── 引用链接 / 自动链接 / 上下标 + ├── mermaid →
… + └── XSS 防护(escapeHTML + safeUrl) + │ + ▼ +fixImageSrcs(HTML 后处理:相对路径 → file://) │ ▼ MetonaEditor 预览区渲染 @@ -306,35 +307,24 @@ MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变 ## 7. Markdown 渲染管线 +v0.6.0: 渲染完全由 MetonaEditor 内置解析器承担(零依赖),应用侧仅保留图片路径修复后处理: + ``` Markdown 文本 │ ▼ -remark-parse 解析为 MDAST +parseMarkdown(MetonaEditor 内置解析器) + ├── 块级:标题 / 列表 / 引用 / 代码块 / 表格 / 水平线 / 脚注 / 数学公式 / 定义列表 + ├── 行内:粗体 / 斜体 / 删除线 / 高亮 / 上下标 / 行内代码 / 链接 / 图片 / emoji + ├── mermaid:
…
+    └── 安全:escapeHTML 转义 + safeUrl URL 过滤 + 属性级注入防护
     │
     ▼
-remark-gfm            扩展 GFM 语法
+fixImageSrcs(markdown.ts 后处理)
+    └── 相对路径图片 src → file:// 绝对路径(越界 ../ 不处理)
     │
     ▼
-remark-rehype          转换为 HAST
-    │
-    ▼
-rehype-raw             解析内联 HTML
-    │
-    ▼
-rehype-sanitize         安全过滤
-    │
-    ▼
-rehype-fixImages        相对路径图片转 file:// URL
-    │
-    ▼
-rehype-highlight        代码语法高亮
-    │
-    ▼
-rehype-stringify        序列化为 HTML
-    │
-    ▼
-Renderer (MetonaEditor preview / Preview component)
+MetonaEditor 预览区 / getHTML 导出
 ```
 
 ## 8. UI 设计
@@ -415,12 +405,11 @@ npm run build:portable  # 便携版(免安装)
 |------|------|------|
 | react / react-dom | ^18.3 | UI 框架 |
 | zustand | ^5.0 | 状态管理 |
-| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(IndexedDB) |
+| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(AriaEngine) |
 | 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 通知组件 |
-| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
-| rehype-highlight | ^7.0 | 代码语法高亮 |
+| mermaid | ^10.9 | Mermaid 图表渲染 |
 
 ### 开发依赖
 
diff --git a/README.md b/README.md
index 11d1726..2dad2c6 100644
--- a/README.md
+++ b/README.md
@@ -30,9 +30,9 @@
 |------|------|
 | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
 | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
-| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 |
-| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 |
-| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
+| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置解析器渲染、格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、Zen 专注模式、粘贴图片转 base64 |
+| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,MetonaEditor 内置解析器渲染 |
+| 🔤 **代码高亮** | MetonaEditor 内置零依赖高亮器(js / ts / python / bash / css / html / json 等 16 种语言) |
 | 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
 | 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
 | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
@@ -40,7 +40,9 @@
 | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
 | 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
 | 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
+| 📋 **状态栏** | 实时显示文档字数 / 行数 / 阅读时间 / 光标位置 / 自动保存状态 |
 | 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlark(AriaEngine)持久化,关闭后可恢复 |
+| 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable) |
 | ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
 | 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
 | 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
@@ -146,11 +148,11 @@ npm run test:coverage
 | 桌面框架 | [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 编辑器,三模式视图 + 浮动格式栏 + 插件系统 |
+| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.0 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
 | 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
 | 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.1 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
-| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
-| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
+| Markdown 解析 | MetonaEditor 内置解析器 | 零依赖:GFM / 脚注 / 数学公式 / Mermaid |
+| 代码高亮 | MetonaEditor 内置高亮器 | 零依赖,16 种语言 |
 | Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v0.5.0 | 通知提示组件 |
 | 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
 | 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
diff --git a/package.json b/package.json
index 6993dad..467b2c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marklite",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "Lightweight Markdown Editor for Windows",
   "main": "./dist/main/index.js",
   "scripts": {
@@ -14,7 +14,8 @@
     "test": "vitest run",
     "test:watch": "vitest",
     "test:coverage": "vitest run --coverage",
-    "prepare": "husky"
+    "prepare": "husky",
+    "postinstall": "node scripts/ensure-rollup-platform.mjs"
   },
   "lint-staged": {
     "*.{ts,tsx}": [
@@ -35,18 +36,10 @@
     "nanoid": "^5.1.5",
     "react": "^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"
   },
   "devDependencies": {
+    "@rollup/rollup-linux-x64-gnu": "^4.60.4",
     "@testing-library/jest-dom": "^6.9.1",
     "@testing-library/react": "^16.3.2",
     "@types/jsdom": "^28.0.3",
@@ -60,6 +53,7 @@
     "eslint": "^9.22.0",
     "eslint-plugin-react-hooks": "^5.2.0",
     "eslint-plugin-react-refresh": "^0.4.19",
+    "fake-indexeddb": "^6.2.5",
     "husky": "^9.1.7",
     "jsdom": "^29.1.1",
     "lint-staged": "^17.0.7",
diff --git a/scripts/ensure-rollup-platform.mjs b/scripts/ensure-rollup-platform.mjs
new file mode 100644
index 0000000..33c8dd1
--- /dev/null
+++ b/scripts/ensure-rollup-platform.mjs
@@ -0,0 +1,55 @@
+/**
+ * 确保 rollup 当前平台的 optional 原生包已安装。
+ *
+ * 背景: npm bug #4828 导致跨平台共享 node_modules(如 WSL 与 Windows 挂载同一目录)时,
+ * 一方 npm install 可能清理掉另一方的 rollup 平台包(@rollup/rollup--x64-),
+ * 导致 build/test 报 "Cannot find module @rollup/rollup-"。
+ *
+ * 方案: 在 postinstall 阶段检测当前平台所需的 rollup 原生包,缺失则自动补装
+ * (--no-save 不污染依赖清单,--ignore-scripts 防止递归触发 postinstall)。
+ */
+import { execSync } from 'child_process'
+import { existsSync } from 'fs'
+import { join, dirname } from 'path'
+import { fileURLToPath } from 'url'
+
+const ROLLUP_VERSION = '4.60.4'
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+/** 平台 → 架构 → 对应 rollup 原生包 */
+const PLATFORM_PACKAGES = {
+  win32: {
+    x64: '@rollup/rollup-win32-x64-msvc',
+    ia32: '@rollup/rollup-win32-ia32-msvc',
+    arm64: '@rollup/rollup-win32-arm64-msvc',
+  },
+  linux: {
+    x64: '@rollup/rollup-linux-x64-gnu',
+    arm64: '@rollup/rollup-linux-arm64-gnu',
+    arm: '@rollup/rollup-linux-arm-gnueabihf',
+  },
+  darwin: {
+    x64: '@rollup/rollup-darwin-x64',
+    arm64: '@rollup/rollup-darwin-arm64',
+  },
+}
+
+const pkg = PLATFORM_PACKAGES[process.platform]?.[process.arch]
+if (!pkg) {
+  process.exit(0)
+}
+
+const pkgDir = join(__dirname, '..', 'node_modules', ...pkg.split('/'))
+if (existsSync(pkgDir)) {
+  process.exit(0)
+}
+
+try {
+  execSync(`npm install --no-save --ignore-scripts ${pkg}@${ROLLUP_VERSION}`, {
+    stdio: 'inherit',
+    cwd: join(__dirname, '..'),
+  })
+} catch (err) {
+  // eslint-disable-next-line no-console -- 安装失败仅告警,不阻断 npm install
+  console.warn(`[ensure-rollup-platform] 自动安装 ${pkg} 失败:`, err)
+}
diff --git a/src/main/file-system.ts b/src/main/file-system.ts
index fb114c0..403162a 100644
--- a/src/main/file-system.ts
+++ b/src/main/file-system.ts
@@ -10,26 +10,29 @@ export async function readFileContent(filePath: string): Promise
   try {
     const fileStat = await stat(filePath)
     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: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF)
     const buffer = await readFile(filePath)
     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 再解码
       const swapped = Buffer.allocUnsafe(buffer.length)
       buffer.copy(swapped)
       swapped.swap16()
       content = swapped.toString('utf-16le')
-      if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
-    } else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
+      if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
+    } else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
       // UTF-16 LE
       content = buffer.toString('utf-16le')
-      if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
+      if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
     } else {
       // UTF-8 (含 FEFF BOM 剥离)
       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 }
   } catch (err) {
@@ -48,7 +51,11 @@ export async function saveFileContent(filePath: string, content: string): Promis
     return { success: true, filePath }
   } catch (err) {
     // 清理残留临时文件
-    try { await unlink(tmpFile) } catch { /* ignore */ }
+    try {
+      await unlink(tmpFile)
+    } catch {
+      /* ignore */
+    }
     return { success: false, error: (err as Error).message }
   }
 }
@@ -58,7 +65,7 @@ export async function buildDirTree(
   dirPath: string,
   depth = 0,
   maxDepth = 10,
-  visited?: Set
+  visited?: Set,
 ): Promise {
   if (depth > maxDepth) return []
 
diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts
index c1d5460..29f308b 100644
--- a/src/main/file-watcher.ts
+++ b/src/main/file-watcher.ts
@@ -15,7 +15,7 @@ export class FileWatcher {
     if (!filePath) return
     try {
       this.currentPath = filePath
-      this.watcher = fs.watch(filePath, (eventType) => {
+      this.watcher = fs.watch(filePath, eventType => {
         if (eventType === 'change') {
           if (this.isSelfWriting) return
           const win = this.getMainWindow()
diff --git a/src/main/index.ts b/src/main/index.ts
index 25f1780..4ab87e6 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,140 +1,141 @@
-import { app, BrowserWindow } from 'electron'
-import { join } from 'path'
-import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
-import { FileWatcher, SidebarWatcher } from './file-watcher'
-import { registerIpcHandlers } from './ipc-handlers'
-import { readFileContent } from './file-system'
-
-let mainWindow: BrowserWindow | null = null
-const state = {
-  activeFilePath: null as string | null,
-  pendingFilePath: null as string | null,
-  isClosing: false,
-  closeTimeout: null as NodeJS.Timeout | null
-}
-
-const fileWatcher = new FileWatcher(() => mainWindow)
-const sidebarWatcher = new SidebarWatcher(() => mainWindow)
-
-function openFileInTab(filePath: string): void {
-  if (!mainWindow || mainWindow.isDestroyed()) return
-  readFileContent(filePath).then(result => {
-    if (result.success && mainWindow && !mainWindow.isDestroyed()) {
-      state.activeFilePath = filePath
-      fileWatcher.start(filePath)
-      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)
-  })
-}
-
-const lockOk = setupSingleInstanceLock((filePath) => {
-  if (mainWindow && !mainWindow.isDestroyed()) {
-    if (mainWindow.isMinimized()) mainWindow.restore()
-    mainWindow.focus()
-    if (filePath) openFileInTab(filePath)
-  }
-})
-
-if (!lockOk) {
-  app.quit()
-} else {
-
-  function setupCloseHandler(): void {
-    if (!mainWindow) return
-    mainWindow.on('close', (e) => {
-      if (state.isClosing) return
-      state.isClosing = true
-      e.preventDefault()
-      try {
-        if (mainWindow && !mainWindow.webContents.isDestroyed()) {
-          mainWindow.webContents.send('window:confirmClose')
-        } else {
-          mainWindow?.removeAllListeners('close')
-          mainWindow?.close()
-          return
-        }
-      } catch {
-        mainWindow?.removeAllListeners('close')
-        mainWindow?.close()
-        return
-      }
-      state.closeTimeout = setTimeout(() => {
-        state.closeTimeout = null
-        if (mainWindow && !mainWindow.isDestroyed()) {
-          mainWindow.removeAllListeners('close')
-          mainWindow.close()
-        }
-      }, 5000)
-    })
-  }
-
-  // C-05: 窗口重建时重置状态
-  function initWindow(): void {
-    state.isClosing = false
-    if (state.closeTimeout) {
-      clearTimeout(state.closeTimeout)
-      state.closeTimeout = null
-    }
-
-    mainWindow = createWindow()
-
-    if (process.env.ELECTRON_RENDERER_URL) {
-      mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
-    } else {
-      mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
-    }
-
-    mainWindow.webContents.on('did-finish-load', () => {
-      if (state.pendingFilePath) {
-        openFileInTab(state.pendingFilePath)
-        state.pendingFilePath = null
-      }
-    })
-
-    setupCloseHandler()
-
-    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()) {
-        state.pendingFilePath = filePath
-      } else if (mainWindow && !mainWindow.isDestroyed()) {
-        openFileInTab(filePath)
-      } else {
-        state.pendingFilePath = filePath
-      }
-    })
-
-    // C-03: IPC 处理器只注册一次
-    registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
-
-    initWindow()
-
-    const cmdFile = getFilePathFromArgs(process.argv)
-    if (cmdFile) {
-      state.pendingFilePath = cmdFile
-    }
-  })
-
-  app.on('window-all-closed', () => {
-    if (process.platform !== 'darwin') app.quit()
-  })
-
-  // C-03: activate 只重建窗口,不重复注册 IPC
-  app.on('activate', () => {
-    if (BrowserWindow.getAllWindows().length === 0) {
-      initWindow()
-    }
-  })
-}
+import { app, BrowserWindow } from 'electron'
+import { join } from 'path'
+import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
+import { FileWatcher, SidebarWatcher } from './file-watcher'
+import { registerIpcHandlers } from './ipc-handlers'
+import { readFileContent } from './file-system'
+
+let mainWindow: BrowserWindow | null = null
+const state = {
+  activeFilePath: null as string | null,
+  pendingFilePath: null as string | null,
+  isClosing: false,
+  closeTimeout: null as NodeJS.Timeout | null,
+}
+
+const fileWatcher = new FileWatcher(() => mainWindow)
+const sidebarWatcher = new SidebarWatcher(() => mainWindow)
+
+function openFileInTab(filePath: string): void {
+  if (!mainWindow || mainWindow.isDestroyed()) return
+  readFileContent(filePath)
+    .then(result => {
+      if (result.success && mainWindow && !mainWindow.isDestroyed()) {
+        state.activeFilePath = filePath
+        fileWatcher.start(filePath)
+        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)
+    })
+}
+
+const lockOk = setupSingleInstanceLock(filePath => {
+  if (mainWindow && !mainWindow.isDestroyed()) {
+    if (mainWindow.isMinimized()) mainWindow.restore()
+    mainWindow.focus()
+    if (filePath) openFileInTab(filePath)
+  }
+})
+
+if (!lockOk) {
+  app.quit()
+} else {
+  function setupCloseHandler(): void {
+    if (!mainWindow) return
+    mainWindow.on('close', e => {
+      if (state.isClosing) return
+      state.isClosing = true
+      e.preventDefault()
+      try {
+        if (mainWindow && !mainWindow.webContents.isDestroyed()) {
+          mainWindow.webContents.send('window:confirmClose')
+        } else {
+          mainWindow?.removeAllListeners('close')
+          mainWindow?.close()
+          return
+        }
+      } catch {
+        mainWindow?.removeAllListeners('close')
+        mainWindow?.close()
+        return
+      }
+      state.closeTimeout = setTimeout(() => {
+        state.closeTimeout = null
+        if (mainWindow && !mainWindow.isDestroyed()) {
+          mainWindow.removeAllListeners('close')
+          mainWindow.close()
+        }
+      }, 5000)
+    })
+  }
+
+  // C-05: 窗口重建时重置状态
+  function initWindow(): void {
+    state.isClosing = false
+    if (state.closeTimeout) {
+      clearTimeout(state.closeTimeout)
+      state.closeTimeout = null
+    }
+
+    mainWindow = createWindow()
+
+    if (process.env.ELECTRON_RENDERER_URL) {
+      mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
+    } else {
+      mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
+    }
+
+    mainWindow.webContents.on('did-finish-load', () => {
+      if (state.pendingFilePath) {
+        openFileInTab(state.pendingFilePath)
+        state.pendingFilePath = null
+      }
+    })
+
+    setupCloseHandler()
+
+    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()) {
+        state.pendingFilePath = filePath
+      } else if (mainWindow && !mainWindow.isDestroyed()) {
+        openFileInTab(filePath)
+      } else {
+        state.pendingFilePath = filePath
+      }
+    })
+
+    // C-03: IPC 处理器只注册一次
+    registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
+
+    initWindow()
+
+    const cmdFile = getFilePathFromArgs(process.argv)
+    if (cmdFile) {
+      state.pendingFilePath = cmdFile
+    }
+  })
+
+  app.on('window-all-closed', () => {
+    if (process.platform !== 'darwin') app.quit()
+  })
+
+  // C-03: activate 只重建窗口,不重复注册 IPC
+  app.on('activate', () => {
+    if (BrowserWindow.getAllWindows().length === 0) {
+      initWindow()
+    }
+  })
+}
diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts
index f9da7c9..2621d02 100644
--- a/src/main/ipc-handlers.ts
+++ b/src/main/ipc-handlers.ts
@@ -1,228 +1,284 @@
-import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
-import { readFileContent, saveFileContent, buildDirTree } from './file-system'
-import { FileWatcher, SidebarWatcher } from './file-watcher'
-import { IPC_CHANNELS } from '../shared/ipc-channels'
-import { stat } from 'fs/promises'
-import { basename, isAbsolute } from 'path'
-
-// 安全校验:拒绝路径遍历攻击
-function validatePath(filePath: string): boolean {
-  if (!filePath || typeof filePath !== 'string') return false
-  // 拒绝空字节
-  if (filePath.includes('\0')) return false
-  // 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
-  // H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
-  const sepPattern = /[/\\]/
-  const parts = filePath.split(sepPattern)
-  if (parts.some(part => part === '..')) return false
-  // 必须是绝对路径
-  if (!isAbsolute(filePath)) return false
-  return true
-}
-
-export function registerIpcHandlers(
-  getMainWindow: () => BrowserWindow | null,
-  fileWatcher: FileWatcher,
-  sidebarWatcher: SidebarWatcher,
-  state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
-): void {
-  // 打开文件对话框
-  ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
-    const win = getMainWindow()
-    if (!win) return null
-    try {
-      const result = await dialog.showOpenDialog(win, {
-        properties: ['openFile'],
-        filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
-      })
-      if (!result.canceled && result.filePaths.length > 0) {
-        const filePath = result.filePaths[0]
-        const fileResult = await readFileContent(filePath)
-        if (fileResult.success) {
-          state.activeFilePath = filePath
-          fileWatcher.start(filePath)
-          win.setTitle(`MarkLite - ${basename(filePath)}`)
-          return { filePath, content: fileResult.content }
-        }
-        return { error: fileResult.error }
-      }
-      return null
-    } catch (err) {
-      return { error: (err as Error).message }
-    }
-  })
-
-  // 读取文件
-  ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
-    if (!validatePath(filePath)) {
-      return { success: false, error: '无效的文件路径' }
-    }
-    return readFileContent(filePath)
-  })
-
-  // 保存文件
-  ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
-    if (data.filePath && !validatePath(data.filePath)) {
-      return { success: false, error: '无效的文件路径' }
-    }
-    const win = getMainWindow()
-    try {
-      if (data.filePath) {
-        // Mark self-writing before stopping watcher to suppress any events
-        // that arrive between stop and start
-        fileWatcher.setSelfWriting(true)
-        fileWatcher.stop()
-        const result = await saveFileContent(data.filePath, data.content)
-        // Restart watcher while selfWriting is still true so any immediate 'change'
-        // event from the restart is suppressed — prevents overwriting editor content
-        fileWatcher.start(data.filePath)
-        fileWatcher.setSelfWriting(false)
-        state.activeFilePath = data.filePath
-        if (win && !win.isDestroyed()) {
-          win.setTitle(`MarkLite - ${basename(data.filePath)}`)
-        }
-        return result
-      } else {
-        if (!win) return { success: false, error: '窗口不可用' }
-        const saveResult = await dialog.showSaveDialog(win, {
-          filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
-        })
-        if (!saveResult.canceled) {
-          fileWatcher.setSelfWriting(true)
-          const result = await saveFileContent(saveResult.filePath, data.content)
-          if (result.success) {
-            state.activeFilePath = saveResult.filePath
-            fileWatcher.start(saveResult.filePath)
-            win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
-          }
-          fileWatcher.setSelfWriting(false)
-          return result
-        }
-        return { success: false, canceled: true }
-      }
-    } catch (err) {
-      fileWatcher.setSelfWriting(false)
-      if (state.activeFilePath) {
-        fileWatcher.start(state.activeFilePath)
-      }
-      return { success: false, error: (err as Error).message }
-    }
-  })
-
-  // 另存为
-  ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
-    const win = getMainWindow()
-    if (!win) return { success: false, error: '窗口不可用' }
-    try {
-      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)
-        if (saveResult.success) {
-          state.activeFilePath = result.filePath
-          fileWatcher.start(result.filePath)
-          win.setTitle(`MarkLite - ${basename(result.filePath)}`)
-        }
-        fileWatcher.setSelfWriting(false)
-        return saveResult
-      }
-      return { success: false, canceled: true }
-    } catch (err) {
-      fileWatcher.setSelfWriting(false)
-      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) => {
-    if (!validatePath(filePath)) {
-      return { success: false, error: '无效的文件路径' }
-    }
-    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_RELOAD, async () => {
-    if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
-    const result = await readFileContent(state.activeFilePath)
-    return { ...result, filePath: state.activeFilePath }
-  })
-
-  // 目录树
-  ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => {
-    if (!validatePath(dirPath)) {
-      return { success: false, error: '无效的目录路径' }
-    }
-    try {
-      const tree = await buildDirTree(dirPath)
-      return { success: true, tree, rootPath: dirPath }
-    } catch (err) {
-      return { success: false, error: (err as Error).message }
-    }
-  })
-
-  // 打开文件夹对话框
-  ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
-    const win = getMainWindow()
-    if (!win) return null
-    const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
-    if (!result.canceled && result.filePaths.length > 0) {
-      return result.filePaths[0]
-    }
-    return null
-  })
-
-  // 目录监听
-  ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
-    if (!validatePath(dirPath)) return
-    sidebarWatcher.start(dirPath)
-  })
-
-  ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
-    sidebarWatcher.stop()
-  })
-
-  // 标签切换
-  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
-    }
-  })
-}
+import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
+import { readFileContent, saveFileContent, buildDirTree } from './file-system'
+import { FileWatcher, SidebarWatcher } from './file-watcher'
+import { IPC_CHANNELS } from '../shared/ipc-channels'
+import { stat, readFile, writeFile } from 'fs/promises'
+import { basename, isAbsolute } from 'path'
+
+// 安全校验:拒绝路径遍历攻击
+function validatePath(filePath: string): boolean {
+  if (!filePath || typeof filePath !== 'string') return false
+  // 拒绝空字节
+  if (filePath.includes('\0')) return false
+  // 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
+  // H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
+  const sepPattern = /[/\\]/
+  const parts = filePath.split(sepPattern)
+  if (parts.some(part => part === '..')) return false
+  // 必须是绝对路径
+  if (!isAbsolute(filePath)) return false
+  return true
+}
+
+export function registerIpcHandlers(
+  getMainWindow: () => BrowserWindow | null,
+  fileWatcher: FileWatcher,
+  sidebarWatcher: SidebarWatcher,
+  state: {
+    activeFilePath: string | null
+    pendingFilePath: string | null
+    isClosing: boolean
+    closeTimeout: NodeJS.Timeout | null
+  },
+): void {
+  // 打开文件对话框
+  ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
+    const win = getMainWindow()
+    if (!win) return null
+    try {
+      const result = await dialog.showOpenDialog(win, {
+        properties: ['openFile'],
+        filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
+      })
+      if (!result.canceled && result.filePaths.length > 0) {
+        const filePath = result.filePaths[0]
+        const fileResult = await readFileContent(filePath)
+        if (fileResult.success) {
+          state.activeFilePath = filePath
+          fileWatcher.start(filePath)
+          win.setTitle(`MarkLite - ${basename(filePath)}`)
+          return { filePath, content: fileResult.content }
+        }
+        return { error: fileResult.error }
+      }
+      return null
+    } catch (err) {
+      return { error: (err as Error).message }
+    }
+  })
+
+  // 读取文件
+  ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
+    if (!validatePath(filePath)) {
+      return { success: false, error: '无效的文件路径' }
+    }
+    return readFileContent(filePath)
+  })
+
+  // 保存文件
+  ipcMain.handle(
+    IPC_CHANNELS.FILE_SAVE,
+    async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
+      if (data.filePath && !validatePath(data.filePath)) {
+        return { success: false, error: '无效的文件路径' }
+      }
+      const win = getMainWindow()
+      try {
+        if (data.filePath) {
+          // Mark self-writing before stopping watcher to suppress any events
+          // that arrive between stop and start
+          fileWatcher.setSelfWriting(true)
+          fileWatcher.stop()
+          const result = await saveFileContent(data.filePath, data.content)
+          // Restart watcher while selfWriting is still true so any immediate 'change'
+          // event from the restart is suppressed — prevents overwriting editor content
+          fileWatcher.start(data.filePath)
+          fileWatcher.setSelfWriting(false)
+          state.activeFilePath = data.filePath
+          if (win && !win.isDestroyed()) {
+            win.setTitle(`MarkLite - ${basename(data.filePath)}`)
+          }
+          return result
+        } else {
+          if (!win) return { success: false, error: '窗口不可用' }
+          const saveResult = await dialog.showSaveDialog(win, {
+            filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
+          })
+          if (!saveResult.canceled) {
+            fileWatcher.setSelfWriting(true)
+            const result = await saveFileContent(saveResult.filePath, data.content)
+            if (result.success) {
+              state.activeFilePath = saveResult.filePath
+              fileWatcher.start(saveResult.filePath)
+              win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
+            }
+            fileWatcher.setSelfWriting(false)
+            return result
+          }
+          return { success: false, canceled: true }
+        }
+      } catch (err) {
+        fileWatcher.setSelfWriting(false)
+        if (state.activeFilePath) {
+          fileWatcher.start(state.activeFilePath)
+        }
+        return { success: false, error: (err as Error).message }
+      }
+    },
+  )
+
+  // 另存为
+  ipcMain.handle(
+    IPC_CHANNELS.FILE_SAVE_AS,
+    async (_event: IpcMainInvokeEvent, data: { content: string }) => {
+      const win = getMainWindow()
+      if (!win) return { success: false, error: '窗口不可用' }
+      try {
+        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)
+          if (saveResult.success) {
+            state.activeFilePath = result.filePath
+            fileWatcher.start(result.filePath)
+            win.setTitle(`MarkLite - ${basename(result.filePath)}`)
+          }
+          fileWatcher.setSelfWriting(false)
+          return saveResult
+        }
+        return { success: false, canceled: true }
+      } catch (err) {
+        fileWatcher.setSelfWriting(false)
+        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) => {
+    if (!validatePath(filePath)) {
+      return { success: false, error: '无效的文件路径' }
+    }
+    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_RELOAD, async () => {
+    if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
+    const result = await readFileContent(state.activeFilePath)
+    return { ...result, filePath: state.activeFilePath }
+  })
+
+  // 目录树
+  ipcMain.handle(
+    IPC_CHANNELS.DIR_READ_TREE,
+    async (_event: IpcMainInvokeEvent, dirPath: string) => {
+      if (!validatePath(dirPath)) {
+        return { success: false, error: '无效的目录路径' }
+      }
+      try {
+        const tree = await buildDirTree(dirPath)
+        return { success: true, tree, rootPath: dirPath }
+      } catch (err) {
+        return { success: false, error: (err as Error).message }
+      }
+    },
+  )
+
+  // 打开文件夹对话框
+  ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
+    const win = getMainWindow()
+    if (!win) return null
+    const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
+    if (!result.canceled && result.filePaths.length > 0) {
+      return result.filePaths[0]
+    }
+    return null
+  })
+
+  // 目录监听
+  ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
+    if (!validatePath(dirPath)) return
+    sidebarWatcher.start(dirPath)
+  })
+
+  ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
+    sidebarWatcher.stop()
+  })
+
+  // 标签切换
+  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 }
+    }
+  })
+}
diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts
index 448cf12..fa25535 100644
--- a/src/main/window-manager.ts
+++ b/src/main/window-manager.ts
@@ -13,16 +13,16 @@ export function createWindow(): BrowserWindow {
       preload: join(__dirname, '../preload/index.js'),
       contextIsolation: true,
       nodeIntegration: false,
-      sandbox: true
+      sandbox: true,
     },
     titleBarStyle: 'default',
-    show: false
+    show: false,
   })
 
   mainWindow.setMenu(null)
 
   // H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP
-  mainWindow.webContents.on('will-navigate', (event) => {
+  mainWindow.webContents.on('will-navigate', event => {
     event.preventDefault()
   })
   mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
@@ -35,7 +35,7 @@ export function createWindow(): BrowserWindow {
 }
 
 export function setupSingleInstanceLock(
-  onSecondInstance: (filePath: string | null) => void
+  onSecondInstance: (filePath: string | null) => void,
 ): boolean {
   const gotTheLock = app.requestSingleInstanceLock()
   if (!gotTheLock) {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index bc905e0..d56fd15 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,64 +1,79 @@
-import { contextBridge, ipcRenderer, shell } from 'electron'
-import { IPC_CHANNELS } from '../shared/ipc-channels'
-import type { ElectronAPI } from '../renderer/types/ipc'
-
-// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
-const api: ElectronAPI = {
-  // File operations
-  openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
-  readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
-  saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
-  saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
-  getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
-  getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
-  reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
-
-  // Tab management
-  tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
-
-  // Window control
-  forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
-  cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
-
-  // Shell — 仅允许 http/https 协议
-  openExternal: (url: string) => {
-    try {
-      const parsed = new URL(url)
-      if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
-        shell.openExternal(url)
-      }
-    } catch {
-      // 无效 URL,忽略
-    }
-  },
-
-  // File Tree (Sidebar)
-  readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
-  openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
-  watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
-  unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
-
-  // Events from main process — 返回取消订阅函数
-  onFileOpenInTab: (callback) => {
-    const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
-    ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
-    return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
-  },
-  onExternalModification: (callback) => {
-    const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
-    ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
-    return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
-  },
-  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)
+import { contextBridge, ipcRenderer, shell } from 'electron'
+import { IPC_CHANNELS } from '../shared/ipc-channels'
+import type { ElectronAPI } from '../renderer/types/ipc'
+
+// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
+const api: ElectronAPI = {
+  // File operations
+  openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
+  readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
+  saveFile: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
+  saveFileAs: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
+  getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
+  getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
+  reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
+
+  // Tab management
+  tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
+
+  // Window control
+  forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
+  cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
+
+  // Shell — 仅允许 http/https 协议
+  openExternal: (url: string) => {
+    try {
+      const parsed = new URL(url)
+      if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+        shell.openExternal(url)
+      }
+    } catch {
+      // 无效 URL,忽略
+    }
+  },
+
+  // File Tree (Sidebar)
+  readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
+  openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
+  watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
+  unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
+
+  // v0.6.0: 数据备份导出/导入
+  exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
+  importData: () => ipcRenderer.invoke(IPC_CHANNELS.DATA_IMPORT),
+
+  // Events from main process — 返回取消订阅函数
+  onFileOpenInTab: callback => {
+    const handler = (
+      _event: Electron.IpcRendererEvent,
+      data: { filePath: string; content: string },
+    ) => callback(data)
+    ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
+    return () => {
+      ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
+    }
+  },
+  onExternalModification: callback => {
+    const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
+    ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
+    return () => {
+      ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
+    }
+  },
+  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)
diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts
index 615e1d9..e337233 100644
--- a/src/preload/preload.d.ts
+++ b/src/preload/preload.d.ts
@@ -1,16 +1,16 @@
-/**
- * DX-02: Preload 类型安全声明
- * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
- * preload/index.ts 引入该类型做编译期契约检查。
- * 本文件仅保留全局 Window 增强声明。
- */
-
-import type { ElectronAPI } from '../renderer/types/ipc'
-
-declare global {
-  interface Window {
-    electronAPI: ElectronAPI
-  }
-}
-
-export {}
+/**
+ * DX-02: Preload 类型安全声明
+ * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
+ * preload/index.ts 引入该类型做编译期契约检查。
+ * 本文件仅保留全局 Window 增强声明。
+ */
+
+import type { ElectronAPI } from '../renderer/types/ipc'
+
+declare global {
+  interface Window {
+    electronAPI: ElectronAPI
+  }
+}
+
+export {}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 1109e03..2954543 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,120 +1,131 @@
-import React, { useState, useEffect, useCallback } from 'react'
-import { useTabStore } from './stores/tabStore'
-import { flushSaveToDB } from './stores/tabStore'
-import { useEditorStore } from './stores/editorStore'
-import { useTheme } from './hooks/useTheme'
-import { useSettingsInit } from './hooks/useSettingsInit'
-import { useKeyboard } from './hooks/useKeyboard'
-import { useUnsavedWarning } from './hooks/useUnsavedWarning'
-import { useFileWatch } from './hooks/useFileWatch'
-import { useFileOperations } from './hooks/useFileOperations'
-import { useDragDrop } from './hooks/useDragDrop'
-import { useAutoSave } from './hooks/useAutoSave'
-import { useIpcListeners } from './hooks/useIpcListeners'
-import { useConfirm } from './hooks/useConfirm'
-import { Toolbar } from './components/Toolbar/Toolbar'
-import { TabBar } from './components/TabBar/TabBar'
-import { Editor } from './components/Editor/Editor'
-import { Sidebar } from './components/Sidebar/Sidebar'
-import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
-import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
-import { DropOverlay } from './components/DropOverlay/DropOverlay'
-import { ErrorBoundary } from './components/ErrorBoundary'
-import { AboutDialog } from './components/AboutDialog'
-import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
-
-export function App() {
-  const tabs = useTabStore(s => s.tabs)
-  const activeTabId = useTabStore(s => s.activeTabId)
-  const createTab = useTabStore(s => s.createTab)
-  const setModified = useTabStore(s => s.setModified)
-  const updateTabContent = useTabStore(s => s.updateTabContent)
-  const loadFromDB = useTabStore(s => s.loadFromDB)
-  const viewMode = useEditorStore(s => s.viewMode)
-  const externallyModified = useEditorStore(s => s.externallyModified)
-  const setExternallyModified = useEditorStore(s => s.setExternallyModified)
-  const { themeMode, cycleTheme } = useTheme()
-  const { confirm, confirmDialogProps } = useConfirm()
-  const [showAbout, setShowAbout] = useState(false)
-  const handleCloseAbout = useCallback(() => setShowAbout(false), [])
-
-  useSettingsInit()
-  useEffect(() => { loadFromDB() }, [loadFromDB])
-
-  const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
-  const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
-  useDragDrop()
-  useFileWatch()
-  // useAutoSave() already called above to get state for toolbar
-
-  // UX-01: 传入 confirm 函数替代原生 confirm()
-  const handleConfirmClose = useCallback(async (message: string): Promise => {
-    return confirm({
-      title: '未保存的更改',
-      message,
-      variant: 'warning',
-      confirmLabel: '不保存',
-      cancelLabel: '取消'
-    })
-  }, [confirm])
-
-  useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
-  useKeyboard(handleOpenFile, handleSave, handleSaveAs)
-  useIpcListeners()
-
-  useEffect(() => {
-    if (!window.electronAPI) return
-    const activeTab = tabs.find(t => t.id === activeTabId)
-    window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
-  }, [activeTabId, tabs])
-
-  const handleReloadModified = useCallback(async () => {
-    if (!externallyModified?.filePath || !window.electronAPI) return
-    const tab = tabs.find(t => t.filePath === externallyModified.filePath)
-    if (tab?.isModified) return
-    if (!tab) return
-    const result = await window.electronAPI.readFile(externallyModified.filePath)
-    if (result.success && result.content !== undefined) {
-      updateTabContent(tab.id, result.content)
-      setModified(tab.id, false)
-    }
-    setExternallyModified(null)
-  }, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
-
-  return (
-    
-      
- setShowAbout(true)} - isAutoSaving={isAutoSaving} - autoSaveEnabled={autoSaveEnabled} - onToggleAutoSave={toggleAutoSave} - /> -
- -
- - {externallyModified && ( - setExternallyModified(null)} /> - )} - {tabs.length > 0 ? ( -
-
-
- ) : ( - createTab(null, '')} onOpenRecent={handleOpenRecent} /> - )} -
-
- - {showAbout && } - -
-
- ) -} - -App.displayName = 'App' -export default App +import React, { useState, useEffect, useCallback } from 'react' +import { useTabStore } from './stores/tabStore' +import { flushSaveToDB } from './stores/tabStore' +import { useEditorStore } from './stores/editorStore' +import { useTheme } from './hooks/useTheme' +import { useSettingsInit } from './hooks/useSettingsInit' +import { useKeyboard } from './hooks/useKeyboard' +import { useUnsavedWarning } from './hooks/useUnsavedWarning' +import { useFileWatch } from './hooks/useFileWatch' +import { useFileOperations } from './hooks/useFileOperations' +import { useDragDrop } from './hooks/useDragDrop' +import { useAutoSave } from './hooks/useAutoSave' +import { useIpcListeners } from './hooks/useIpcListeners' +import { MeToast } from './lib/toast' +import { Toolbar } from './components/Toolbar/Toolbar' +import { StatusBar } from './components/StatusBar' +import { TabBar } from './components/TabBar/TabBar' +import { Editor } from './components/Editor/Editor' +import { Sidebar } from './components/Sidebar/Sidebar' +import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' +import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' +import { DropOverlay } from './components/DropOverlay/DropOverlay' +import { ErrorBoundary } from './components/ErrorBoundary' +import { AboutDialog } from './components/AboutDialog' + +export function App() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const createTab = useTabStore(s => s.createTab) + const setModified = useTabStore(s => s.setModified) + const updateTabContent = useTabStore(s => s.updateTabContent) + const loadFromDB = useTabStore(s => s.loadFromDB) + const viewMode = useEditorStore(s => s.viewMode) + const externallyModified = useEditorStore(s => s.externallyModified) + const setExternallyModified = useEditorStore(s => s.setExternallyModified) + const { themeMode, cycleTheme } = useTheme() + const [showAbout, setShowAbout] = useState(false) + const handleCloseAbout = useCallback(() => setShowAbout(false), []) + + useSettingsInit() + useEffect(() => { + loadFromDB() + }, [loadFromDB]) + + const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations() + const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave() + useDragDrop() + useFileWatch() + // useAutoSave() already called above to get state for toolbar + + // UX-01: 传入 confirm 函数替代原生 confirm() + // v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false)) + const handleConfirmClose = useCallback(async (message: string): Promise => { + return MeToast.confirm(message, { + title: '未保存的更改', + confirmText: '不保存', + cancelText: '取消', + }) + }, []) + + useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) + useKeyboard(handleOpenFile, handleSave, handleSaveAs) + useIpcListeners() + + useEffect(() => { + if (!window.electronAPI) return + const activeTab = tabs.find(t => t.id === activeTabId) + window.electronAPI.tabSwitched(activeTab?.filePath ?? null) + }, [activeTabId, tabs]) + + const handleReloadModified = useCallback(async () => { + if (!externallyModified?.filePath || !window.electronAPI) return + const tab = tabs.find(t => t.filePath === externallyModified.filePath) + if (tab?.isModified) return + if (!tab) return + const result = await window.electronAPI.readFile(externallyModified.filePath) + if (result.success && result.content !== undefined) { + updateTabContent(tab.id, result.content) + setModified(tab.id, false) + } + setExternallyModified(null) + }, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified]) + + return ( + +
+ setShowAbout(true)} + isAutoSaving={isAutoSaving} + autoSaveEnabled={autoSaveEnabled} + onToggleAutoSave={toggleAutoSave} + /> +
+ +
+ + {externallyModified && ( + setExternallyModified(null)} + /> + )} + {tabs.length > 0 ? ( +
+
+ +
+
+ ) : ( + createTab(null, '')} + onOpenRecent={handleOpenRecent} + /> + )} +
+
+ + + {showAbout && } +
+
+ ) +} + +App.displayName = 'App' +export default App diff --git a/src/renderer/assets.d.ts b/src/renderer/assets.d.ts index 95beae6..0465136 100644 --- a/src/renderer/assets.d.ts +++ b/src/renderer/assets.d.ts @@ -1,14 +1,14 @@ -declare module '*.png' { - const src: string - export default src -} - -declare module '*.svg' { - const src: string - export default src -} - -declare module '*.ico' { - const src: string - export default src -} +declare module '*.png' { + const src: string + export default src +} + +declare module '*.svg' { + const src: string + export default src +} + +declare module '*.ico' { + const src: string + export default src +} diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx index ade4e05..9f65447 100644 --- a/src/renderer/components/AboutDialog/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog/AboutDialog.tsx @@ -1,57 +1,67 @@ -import React from 'react' -import { AppIcon, Gitee } from '../Icons' -import { APP_VERSION } from '../../lib/constants' - -interface AboutDialogProps { - onClose: () => void -} - -export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) { - const handleLinkClick = (e: React.MouseEvent): void => { - e.preventDefault() - if (window.electronAPI?.openExternal) { - window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite') - } else { - window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank') - } - } - - return ( -
-
e.stopPropagation()}> -
- -

MarkLite

- {APP_VERSION} -
-
-

一款轻量级的 Windows 本地 Markdown 编辑器

-
- 多标签页 - 实时预览 - 代码高亮 - 暗色主题 - 拖拽打开 - 搜索替换 - 文件树 - 文档大纲 - 浮动格式栏 - 状态持久化 -
-
-
- - - git.metona.cn/MetonaTeam/MarkLite - -

基于 Electron + React + TypeScript 构建

-

MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1

-

© 2026 thzxx

-
- -
-
- ) -}) - -AboutDialog.displayName = 'AboutDialog' +import React from 'react' +import { AppIcon, Gitee } from '../Icons' +import { APP_VERSION } from '../../lib/constants' + +interface AboutDialogProps { + onClose: () => void +} + +export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) { + const handleLinkClick = (e: React.MouseEvent): void => { + e.preventDefault() + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite') + } else { + window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank') + } + } + + return ( +
+
e.stopPropagation()}> +
+ +

MarkLite

+ {APP_VERSION} +
+
+

一款轻量级的 Windows 本地 Markdown 编辑器

+
+ 多标签页 + 实时预览 + 代码高亮 + 暗色主题 + 拖拽打开 + 搜索替换 + 文件树 + 文档大纲 + 浮动格式栏 + 状态栏 + 数据备份 + 状态持久化 +
+
+
+ + + git.metona.cn/MetonaTeam/MarkLite + +

基于 Electron + React + TypeScript 构建

+

MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1

+

© 2026 thzxx

+
+ +
+
+ ) +}) + +AboutDialog.displayName = 'AboutDialog' diff --git a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx deleted file mode 100644 index 5d41575..0000000 --- a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx +++ /dev/null @@ -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(null) - const previousFocusRef = useRef(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 ( -
-
e.stopPropagation()} - > -
-

{title}

-
-
-

{message}

-
-
- - -
-
-
- ) -}) - -ConfirmDialog.displayName = 'ConfirmDialog' diff --git a/src/renderer/components/ConfirmDialog/index.ts b/src/renderer/components/ConfirmDialog/index.ts deleted file mode 100644 index 114def4..0000000 --- a/src/renderer/components/ConfirmDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ConfirmDialog } from './ConfirmDialog' diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index edfb591..aed8022 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,249 +1,310 @@ -import React, { useEffect, useMemo, useRef } from 'react' -import MeEditor from '@metona-team/metona-editor' -import type { MarkdownEditor } from '@metona-team/metona-editor' -import mermaid from 'mermaid' -import { useTabStore } from '../../stores/tabStore' -import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore' -import { settingsRepository } from '../../db/settingsRepository' -import { renderMarkdownSync } from '../../lib/markdown' -import type { ThemeMode } from '../../types/settings' - -// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全 -type EditMode = 'edit' | 'split' | 'preview' -type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string - -// v0.4.5: Mermaid 初始化(全局一次性配置) -mermaid.initialize({ startOnLoad: false, theme: 'default' }) - -interface EditorProps { - themeMode: ThemeMode - onAppSave?: () => void -} - -/** 将应用 viewMode 映射到 MetonaEditor 的 mode */ -function mapViewMode(vm: string): EditMode { - if (vm === 'source') return 'edit' - if (vm === 'preview') return 'preview' - return 'split' -} - -/** 将 MetonaEditor mode 反向映射到应用 viewMode */ -function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' { - if (mode === 'edit') return 'source' - if (mode === 'preview') return 'preview' - return 'editor' -} - -/** 预设插件(字符串形式,与 demo 一致) */ -const EDITOR_PLUGINS: string[] = [ - 'searchReplace', // Ctrl+F/Ctrl+H - 'imagePaste', // Ctrl+V 粘贴图片 - 'exportTool', // 导出 MD/HTML - 'shortcutHelp', // 按 ? 弹出快捷键面板 -] - -/** - * Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。 - */ -export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), - // 后者每次返回新对象引用导致 Zustand 无条件重渲染 - const activeTab = useMemo( - () => tabs.find(t => t.id === activeTabId) ?? null, - [tabs, activeTabId] - ) - const updateTabContent = useTabStore(s => s.updateTabContent) - const setModified = useTabStore(s => s.setModified) - const updateTabScroll = useTabStore(s => s.updateTabScroll) - const viewMode = useEditorStore(s => s.viewMode) - const setViewMode = useEditorStore(s => s.setViewMode) - - const containerRef = useRef(null) - const editorRef = useRef(null) - const currentContentRef = useRef('') - - // 稳定的回调引用 - const activeTabIdRef = useRef(activeTabId) - activeTabIdRef.current = activeTabId - - // ── 初始化 MetonaEditor ────────────────────────────────── - useEffect(() => { - const container = containerRef.current - if (!container) return - - const config = { - value: activeTab?.content ?? '', - mode: mapViewMode(viewMode), - height: '100%', - toolbar: [ - 'bold', 'italic', 'strikethrough', 'underline', 'code', '|', - 'h1', 'h2', 'h3', '|', - 'quote', 'ul', 'ol', 'indent', 'outdent', '|', - 'link', 'image', 'table', 'hr', '|', - 'undo', 'redo', '|', - 'edit', 'split', 'preview', 'fullscreen' - ], - locale: 'zh-CN', - theme: themeMode as ThemeName, - placeholder: '在此输入 Markdown 内容...', - spellcheck: false, - tabSize: 2, - wordCount: true, - autofocus: true, - lineNumbers: true, - autoBrackets: true, - readOnly: viewMode === 'preview', - plugins: EDITOR_PLUGINS, - // v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮) - floatingToolbar: true, - // v0.2.4 新增配置项 - syncScroll: true, - wordWrap: true, - outline: false, // 使用自研 OutlinePanel - historyLimit: 100, - historyDebounce: 400, - - // 使用 unified 管线渲染,保留图片路径修复能力 - render: (md: string) => { - const tabId = activeTabIdRef.current - const filePath = tabId - ? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null) - : null - return renderMarkdownSync(md, filePath) - }, - - // 内容变化 → 同步到 tabStore - onChange: (value: string) => { - const tabId = activeTabIdRef.current - if (!tabId) return - const tab = useTabStore.getState().tabs.find(t => t.id === tabId) - if (tab?.content === value) return - currentContentRef.current = value - updateTabContent(tabId, value) - setModified(tabId, true) - }, - - // 模式切换 → 同步到 editorStore 并持久化 - onModeChange: (mode: string) => { - const mapped = reverseMapMode(mode as EditMode) - setViewMode(mapped) - settingsRepository.save({ viewMode: mapped }) - }, - - // Ctrl+S → 触发应用层保存(Electron IPC 写文件系统) - onSave: () => { - onAppSave?.() - }, - - // v0.2.4 新增回调: 链接点击 → 安全打开外部链接 - onLinkClick: (href: string) => { - if (window.electronAPI?.openExternal) { - window.electronAPI.openExternal(href) - } - }, - - // v0.2.4 新增回调: 焦点事件(预留扩展点) - onFocus: () => { - // 编辑器获得焦点 - }, - onBlur: () => { - // 编辑器失去焦点 - }, - } - - const editor = MeEditor.create(container, config) - - editorRef.current = editor - setMetonaEditorGetter(() => editor) - currentContentRef.current = activeTab?.content ?? '' - - // v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染 - const renderMermaid = () => { - try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ } - } - editor.on('afterRender', renderMermaid) - // 首次渲染后延迟触发一次 - setTimeout(renderMermaid, 300) - - return () => { - editor.destroy() - editorRef.current = null - setMetonaEditorGetter(() => null) - } - // 仅在挂载时创建一次 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // ── 标签切换:同步内容到编辑器 ────────────────────────── - useEffect(() => { - if (!activeTab || !editorRef.current) return - if (activeTab.content === currentContentRef.current) return - - currentContentRef.current = activeTab.content - - // silent: true — 不触发 onChange,避免重复更新 tabStore - editorRef.current.setValue(activeTab.content, { silent: true }) - - // 恢复滚动位置 - requestAnimationFrame(() => { - const c = containerRef.current - if (!c) return - const textarea = c.querySelector('textarea') - if (textarea) { - textarea.scrollTop = activeTab.scrollTop - } - const preview = c.querySelector('.me-preview') as HTMLElement | null - if (preview) { - preview.scrollTop = activeTab.scrollTop - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId]) - - // ── 取消挂载/标签切换前保存滚动位置 ────────────────────── - 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 ( -
-
-
- ) -}) - -Editor.displayName = 'Editor' +import React, { useEffect, useMemo, useRef } from 'react' +import MeEditor from '@metona-team/metona-editor' +import type { MarkdownEditor } from '@metona-team/metona-editor' +import mermaid from 'mermaid' +import { useTabStore } from '../../stores/tabStore' +import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore' +import { settingsRepository } from '../../db/settingsRepository' +import { renderMarkdownSync } from '../../lib/markdown' +import type { ThemeMode } from '../../types/settings' + +// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全 +type EditMode = 'edit' | 'split' | 'preview' +type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string + +// v0.4.5: Mermaid 初始化(全局一次性配置) +mermaid.initialize({ startOnLoad: false, theme: 'default' }) + +interface EditorProps { + themeMode: ThemeMode + onAppSave?: () => void +} + +/** 将应用 viewMode 映射到 MetonaEditor 的 mode */ +function mapViewMode(vm: string): EditMode { + if (vm === 'source') return 'edit' + if (vm === 'preview') return 'preview' + return 'split' +} + +/** 将 MetonaEditor mode 反向映射到应用 viewMode */ +function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' { + if (mode === 'edit') return 'source' + if (mode === 'preview') return 'preview' + return 'editor' +} + +/** 预设插件(字符串形式,与 demo 一致) */ +const EDITOR_PLUGINS: string[] = [ + 'searchReplace', // Ctrl+F/Ctrl+H + 'imagePaste', // Ctrl+V 粘贴图片 + 'exportTool', // 导出 MD/HTML + 'shortcutHelp', // 按 ? 弹出快捷键面板 +] + +/** + * Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。 + */ +export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), + // 后者每次返回新对象引用导致 Zustand 无条件重渲染 + const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId]) + const updateTabContent = useTabStore(s => s.updateTabContent) + const setModified = useTabStore(s => s.setModified) + const updateTabScroll = useTabStore(s => s.updateTabScroll) + const viewMode = useEditorStore(s => s.viewMode) + const setViewMode = useEditorStore(s => s.setViewMode) + const setStats = useEditorStore(s => s.setStats) + const setCursor = useEditorStore(s => s.setCursor) + const setZenMode = useEditorStore(s => s.setZenMode) + + const containerRef = useRef(null) + const editorRef = useRef(null) + const currentContentRef = useRef('') + + // 稳定的回调引用 + const activeTabIdRef = useRef(activeTabId) + activeTabIdRef.current = activeTabId + + // ── 初始化 MetonaEditor ────────────────────────────────── + useEffect(() => { + const container = containerRef.current + if (!container) return + + const config = { + value: activeTab?.content ?? '', + mode: mapViewMode(viewMode), + height: '100%', + toolbar: [ + 'bold', + 'italic', + 'strikethrough', + 'underline', + 'code', + '|', + 'h1', + 'h2', + 'h3', + '|', + 'quote', + 'ul', + 'ol', + 'indent', + 'outdent', + '|', + 'link', + 'image', + 'table', + 'hr', + '|', + 'undo', + 'redo', + '|', + 'edit', + 'split', + 'preview', + 'fullscreen', + ], + locale: 'zh-CN', + theme: themeMode as ThemeName, + placeholder: '在此输入 Markdown 内容...', + spellcheck: false, + tabSize: 2, + wordCount: true, + autofocus: true, + lineNumbers: true, + autoBrackets: true, + readOnly: viewMode === 'preview', + plugins: EDITOR_PLUGINS, + // v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮) + floatingToolbar: true, + // v0.2.4 新增配置项 + syncScroll: true, + wordWrap: true, + outline: false, // 使用自研 OutlinePanel + historyLimit: 100, + historyDebounce: 400, + + // 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力 + // v0.6.0: highlight 使用内置零依赖高亮器(16 种语言) + highlight: MeEditor.highlight, + render: (md: string) => { + const tabId = activeTabIdRef.current + const filePath = tabId + ? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null) + : null + return renderMarkdownSync(md, filePath) + }, + + // 内容变化 → 同步到 tabStore + onChange: (value: string) => { + const tabId = activeTabIdRef.current + if (!tabId) return + const tab = useTabStore.getState().tabs.find(t => t.id === tabId) + if (tab?.content === value) return + currentContentRef.current = value + updateTabContent(tabId, value) + setModified(tabId, true) + }, + + // 模式切换 → 同步到 editorStore 并持久化 + onModeChange: (mode: string) => { + const mapped = reverseMapMode(mode as EditMode) + setViewMode(mapped) + settingsRepository.save({ viewMode: mapped }) + }, + + // Ctrl+S → 触发应用层保存(Electron IPC 写文件系统) + onSave: () => { + onAppSave?.() + }, + + // v0.2.4 新增回调: 链接点击 → 安全打开外部链接 + onLinkClick: (href: string) => { + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal(href) + } + }, + + // v0.2.4 新增回调: 焦点事件(预留扩展点) + onFocus: () => { + // 编辑器获得焦点 + }, + onBlur: () => { + // 编辑器失去焦点 + }, + } + + const editor = MeEditor.create(container, config) + + editorRef.current = editor + setMetonaEditorGetter(() => editor) + currentContentRef.current = activeTab?.content ?? '' + + // v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染 + const renderMermaid = () => { + try { + mermaid.run({ querySelector: '.me-mermaid .mermaid' }) + } catch { + /* 容错 */ + } + } + editor.on('afterRender', renderMermaid) + // 首次渲染后延迟触发一次 + setTimeout(renderMermaid, 300) + + // v0.6.0: 实时状态同步 — getStats + 光标/zen 事件 → editorStore(状态栏消费) + const syncStats = () => { + try { + const s = editor.getStats() + setStats({ + characters: s.characters, + words: s.words, + chineseChars: s.chineseChars, + englishWords: s.englishWords, + lines: s.lines, + readingTime: s.readingTime, + }) + } catch { + /* 容错 */ + } + } + const syncCursor = (pos?: { line: number; column: number }) => { + try { + setCursor(pos ?? editor.getCursorPosition()) + } catch { + /* 容错 */ + } + } + const syncZen = (zen: boolean) => { + setZenMode(Boolean(zen)) + } + editor.on('change', syncStats) + editor.on('input', syncStats) + editor.on('cursorMove', syncCursor) + editor.on('zenChange', syncZen) + syncStats() + syncCursor() + + return () => { + editor.destroy() + editorRef.current = null + setMetonaEditorGetter(() => null) + } + // 仅在挂载时创建一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ── 标签切换:同步内容到编辑器 ────────────────────────── + useEffect(() => { + if (!activeTab || !editorRef.current) return + if (activeTab.content === currentContentRef.current) return + + currentContentRef.current = activeTab.content + + // silent: true — 不触发 onChange,避免重复更新 tabStore + editorRef.current.setValue(activeTab.content, { silent: true }) + + // 恢复滚动位置 + requestAnimationFrame(() => { + const c = containerRef.current + if (!c) return + const textarea = c.querySelector('textarea') + if (textarea) { + textarea.scrollTop = activeTab.scrollTop + } + const preview = c.querySelector('.me-preview') as HTMLElement | null + if (preview) { + preview.scrollTop = activeTab.scrollTop + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTabId]) + + // ── 取消挂载/标签切换前保存滚动位置 ────────────────────── + 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 ( +
+
+
+ ) +}) + +Editor.displayName = 'Editor' diff --git a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx index 24bcf71..239b530 100644 --- a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,60 +1,56 @@ -import { Component, ErrorInfo, ReactNode } from 'react' - -interface Props { - children: ReactNode - fallback?: ReactNode -} - -interface State { - hasError: boolean - error: Error | null -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - // eslint-disable-next-line no-console -- React error boundary standard pattern - console.error('ErrorBoundary caught an error:', error, errorInfo) - } - - handleReset = (): void => { - this.setState({ hasError: false, error: null }) - } - - render(): ReactNode { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback - } - - const isDev = - (typeof import.meta !== 'undefined' && - (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? - false - - return ( -
-

应用遇到了错误

- {isDev && ( -
-              {this.state.error?.message}
-            
- )} - -
- ) - } - - return this.props.children - } -} +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // eslint-disable-next-line no-console -- React error boundary standard pattern + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + handleReset = (): void => { + this.setState({ hasError: false, error: null }) + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + const isDev = + (typeof import.meta !== 'undefined' && + (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? + false + + return ( +
+

应用遇到了错误

+ {isDev &&
{this.state.error?.message}
} + +
+ ) + } + + return this.props.children + } +} diff --git a/src/renderer/components/FileTree/FileTree.tsx b/src/renderer/components/FileTree/FileTree.tsx index 60aa4c2..ae4c1a5 100644 --- a/src/renderer/components/FileTree/FileTree.tsx +++ b/src/renderer/components/FileTree/FileTree.tsx @@ -18,7 +18,7 @@ export const FileTree = React.memo(function FileTree({ expandedDirs, toggleDir, activeFilePath, - onFileClick + onFileClick, }: FileTreeProps) { return ( <> @@ -30,7 +30,7 @@ export const FileTree = React.memo(function FileTree({
{ + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() if (node.type === 'dir') { diff --git a/src/renderer/components/Icons.tsx b/src/renderer/components/Icons.tsx index 8c8a32a..b6cc96f 100644 --- a/src/renderer/components/Icons.tsx +++ b/src/renderer/components/Icons.tsx @@ -1,176 +1,337 @@ -import React from 'react' -import appIconUrl from '../assets/icon.png' - -// 统一图标 Props -interface IconProps { - size?: number - className?: string - style?: React.CSSProperties -} - -const defaultProps: Partial = { size: 18 } - -// ===== 应用图标 ===== -export function AppIcon({ size = 80 }: IconProps) { - return ( - MarkLite - ) -} - -// ===== 工具栏图标 ===== -export function FolderOpen({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -export function Save({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -export function Moon({ size = defaultProps.size }: IconProps) { - return ( - - - - - ) -} - -export function Sun({ size = defaultProps.size }: IconProps) { - return ( - - - - - - - - - - - - - ) -} - -// ===== 工具栏右侧图标 ===== -export function Gitee({ size = defaultProps.size }: IconProps) { - return ( - - - - ) -} - -export function Info({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -// ===== 标签栏图标 ===== -export function Close({ size = 10 }: IconProps) { - return ( - - - - - ) -} - -export function Plus({ size = 14 }: IconProps) { - return ( - - - - - ) -} - -// ===== 侧边栏图标 ===== -export function Folder({ size = 14 }: IconProps) { - return ( - - - - - ) -} - -export function File({ size = 14 }: IconProps) { - return ( - - - - - - - ) -} - -export function ChevronRight({ size = 10 }: IconProps) { - return ( - - - - ) -} - -export function FolderPlus({ size = 14 }: IconProps) { - return ( - - - - - - ) -} - -// ===== 拖拽覆盖层图标 ===== -export function UploadCloud({ size = 64 }: IconProps) { - return ( - - - - - - - ) -} - -// ===== 欢迎屏幕图标 ===== -export function WelcomeFile({ size = 20 }: IconProps) { - return ( - - - - - - ) -} - -export function WelcomeNew({ size = 20 }: IconProps) { - return ( - - - - - - - ) -} +import React from 'react' +import appIconUrl from '../assets/icon.png' + +// 统一图标 Props +interface IconProps { + size?: number + className?: string + style?: React.CSSProperties +} + +const defaultProps: Partial = { size: 18 } + +// ===== 应用图标 ===== +export function AppIcon({ size = 80 }: IconProps) { + return MarkLite +} + +// ===== 工具栏图标 ===== +export function FolderOpen({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +export function Save({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +export function Moon({ size = defaultProps.size }: IconProps) { + return ( + + + + + ) +} + +export function Sun({ size = defaultProps.size }: IconProps) { + return ( + + + + + + + + + + + + + ) +} + +// ===== 工具栏右侧图标 ===== +export function Gitee({ size = defaultProps.size }: IconProps) { + return ( + + + + ) +} + +export function Info({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +// ===== 标签栏图标 ===== +export function Close({ size = 10 }: IconProps) { + return ( + + + + + ) +} + +export function Plus({ size = 14 }: IconProps) { + return ( + + + + + ) +} + +// ===== 侧边栏图标 ===== +export function Folder({ size = 14 }: IconProps) { + return ( + + + + + ) +} + +export function File({ size = 14 }: IconProps) { + return ( + + + + + + + ) +} + +export function ChevronRight({ size = 10 }: IconProps) { + return ( + + + + ) +} + +export function FolderPlus({ size = 14 }: IconProps) { + return ( + + + + + + ) +} + +// ===== 拖拽覆盖层图标 ===== +export function UploadCloud({ size = 64 }: IconProps) { + return ( + + + + + + + ) +} + +// ===== v0.6.0: 数据备份图标 ===== +export function Download({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +export function Upload({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +// ===== 欢迎屏幕图标 ===== +export function WelcomeFile({ size = 20 }: IconProps) { + return ( + + + + + + ) +} + +export function WelcomeNew({ size = 20 }: IconProps) { + return ( + + + + + + + ) +} diff --git a/src/renderer/components/LoadingSpinner/LoadingSpinner.tsx b/src/renderer/components/LoadingSpinner/LoadingSpinner.tsx deleted file mode 100644 index 5fd6138..0000000 --- a/src/renderer/components/LoadingSpinner/LoadingSpinner.tsx +++ /dev/null @@ -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 = ( -
- - {label && {label}} -
- ) - - if (!overlay) return spinner - - return ( -
- {spinner} -
- ) -}) - -LoadingSpinner.displayName = 'LoadingSpinner' diff --git a/src/renderer/components/LoadingSpinner/index.ts b/src/renderer/components/LoadingSpinner/index.ts deleted file mode 100644 index 03d497f..0000000 --- a/src/renderer/components/LoadingSpinner/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoadingSpinner } from './LoadingSpinner' diff --git a/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx b/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx index f4738c8..f1b0190 100644 --- a/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx +++ b/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx @@ -5,12 +5,19 @@ interface ModifiedBannerProps { onDismiss: () => void } -export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) { +export const ModifiedBanner = React.memo(function ModifiedBanner({ + onReload, + onDismiss, +}: ModifiedBannerProps) { return ( ) }) diff --git a/src/renderer/components/OutlinePanel/OutlinePanel.tsx b/src/renderer/components/OutlinePanel/OutlinePanel.tsx index 904fee7..16ca773 100644 --- a/src/renderer/components/OutlinePanel/OutlinePanel.tsx +++ b/src/renderer/components/OutlinePanel/OutlinePanel.tsx @@ -1,71 +1,71 @@ -import React, { memo } from 'react' -import type { Heading } from './outlineUtils' - -// --- Component --- - -interface OutlinePanelProps { - headings: Heading[] - onNavigate: (heading: Heading, index: number) => void - activeHeadingIndex: number | null -} - -interface OutlineItemProps { - heading: Heading - index: number - isActive: boolean - onNavigate: (heading: Heading, index: number) => void -} - -const OutlineItem = memo(function OutlineItem({ - heading, - index, - isActive, - onNavigate -}: OutlineItemProps) { - return ( - - ) -}) - -export const OutlinePanel = memo(function OutlinePanel({ - headings, - onNavigate, - activeHeadingIndex -}: OutlinePanelProps) { - if (headings.length === 0) { - return ( -
-
文档大纲
-
当前文档无标题
-
- ) - } - - return ( -
-
文档大纲
-
- {headings.map((h, i) => ( - - ))} -
-
- ) -}) - -OutlinePanel.displayName = 'OutlinePanel' +import React, { memo } from 'react' +import type { Heading } from './outlineUtils' + +// --- Component --- + +interface OutlinePanelProps { + headings: Heading[] + onNavigate: (heading: Heading, index: number) => void + activeHeadingIndex: number | null +} + +interface OutlineItemProps { + heading: Heading + index: number + isActive: boolean + onNavigate: (heading: Heading, index: number) => void +} + +const OutlineItem = memo(function OutlineItem({ + heading, + index, + isActive, + onNavigate, +}: OutlineItemProps) { + return ( + + ) +}) + +export const OutlinePanel = memo(function OutlinePanel({ + headings, + onNavigate, + activeHeadingIndex, +}: OutlinePanelProps) { + if (headings.length === 0) { + return ( +
+
文档大纲
+
当前文档无标题
+
+ ) + } + + return ( +
+
文档大纲
+
+ {headings.map((h, i) => ( + + ))} +
+
+ ) +}) + +OutlinePanel.displayName = 'OutlinePanel' diff --git a/src/renderer/components/OutlinePanel/index.ts b/src/renderer/components/OutlinePanel/index.ts index 4c2f808..148c75c 100644 --- a/src/renderer/components/OutlinePanel/index.ts +++ b/src/renderer/components/OutlinePanel/index.ts @@ -1,3 +1,3 @@ -export { OutlinePanel } from './OutlinePanel' -export { parseHeadings } from './outlineUtils' -export type { Heading } from './outlineUtils' +export { OutlinePanel } from './OutlinePanel' +export { parseHeadings } from './outlineUtils' +export type { Heading } from './outlineUtils' diff --git a/src/renderer/components/OutlinePanel/outlineUtils.ts b/src/renderer/components/OutlinePanel/outlineUtils.ts index 53c6807..8b66e6a 100644 --- a/src/renderer/components/OutlinePanel/outlineUtils.ts +++ b/src/renderer/components/OutlinePanel/outlineUtils.ts @@ -1,27 +1,27 @@ -export interface Heading { - level: number - text: string - /** Position in document (character offset from markdown source) */ - pos: number -} - -const HEADING_RE = /^(#{1,6})\s+(.+)$/gm - -/** - * Parse headings from raw markdown content using regex. - */ -export function parseHeadings(markdown: string): Heading[] { - const headings: Heading[] = [] - let match: RegExpExecArray | null - - // Reset regex state - HEADING_RE.lastIndex = 0 - - while ((match = HEADING_RE.exec(markdown)) !== null) { - const level = match[1].length - const text = match[2].trim() - headings.push({ level, text, pos: match.index }) - } - - return headings -} +export interface Heading { + level: number + text: string + /** Position in document (character offset from markdown source) */ + pos: number +} + +const HEADING_RE = /^(#{1,6})\s+(.+)$/gm + +/** + * Parse headings from raw markdown content using regex. + */ +export function parseHeadings(markdown: string): Heading[] { + const headings: Heading[] = [] + let match: RegExpExecArray | null + + // Reset regex state + HEADING_RE.lastIndex = 0 + + while ((match = HEADING_RE.exec(markdown)) !== null) { + const level = match[1].length + const text = match[2].trim() + headings.push({ level, text, pos: match.index }) + } + + return headings +} diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 3350701..8102720 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,190 +1,209 @@ -import React, { useCallback, useMemo, useEffect, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useSidebarStore } from '../../stores/sidebarStore' -import { getFileName } from '../../lib/fileUtils' -import { recentFilesRepository } from '../../db/recentFilesRepository' -import { FolderPlus, File } from '../Icons' -import { FileTree } from '../FileTree' -import { useSidebarResize } from '../../hooks/useSidebarResize' -import { useFolderOperations } from '../../hooks/useFolderOperations' -import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' -import { useActiveHeading } from '../../hooks/useActiveHeading' -import { OutlinePanel, parseHeadings } from '../OutlinePanel' -import type { Heading } from '../OutlinePanel' -import { getMetonaEditor, useEditorStore } from '../../stores/editorStore' - -const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -export const Sidebar = React.memo(function Sidebar() { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), - // 后者每次返回新对象引用导致 Zustand 无条件重渲染 - const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId]) - const switchToTab = useTabStore(s => s.switchToTab) - const createTab = useTabStore(s => s.createTab) - const rootPath = useSidebarStore(s => s.rootPath) - const tree = useSidebarStore(s => s.tree) - const expandedDirs = useSidebarStore(s => s.expandedDirs) - const toggleDir = useSidebarStore(s => s.toggleDir) - const isVisible = useSidebarStore(s => s.isVisible) - - const activeFilePath = activeTab?.filePath ?? null - const { sidebarRef, startResize } = useSidebarResize() - const { handleOpenFolder } = useFolderOperations() - const setLoading = useEditorStore(s => s.setLoading) - const viewMode = useEditorStore(s => s.viewMode) - useAutoExpandDir(activeFilePath) - - // Parse headings from active tab content - const headings = useMemo(() => { - if (!activeTab?.content) return [] - return parseHeadings(activeTab.content) - }, [activeTab?.content]) - - // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview) - const previewRef = useRef(null) - useEffect(() => { - if (viewMode === 'preview') { - previewRef.current = document.querySelector('.me-preview') as HTMLElement | null - } else { - previewRef.current = null - } - }, [viewMode]) - - const activeHeadingIndex = useActiveHeading( - viewMode === 'preview' ? previewRef : { current: null }, - headings - ) - - // Navigate to heading in MetonaEditor - const handleHeadingNavigate = useCallback((heading: Heading) => { - const editor = getMetonaEditor() - if (!editor) return - - try { - // 获取当前内容,查找标题文本在源代码中的位置 - const content = editor.getValue() - const headingPattern = new RegExp( - `^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, - 'm' - ) - const match = headingPattern.exec(content) - if (!match) return - - const pos = match.index - - // 通过 DOM 操作滚动 textarea 到对应位置 - const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null - if (!container) return - - const textarea = container.querySelector('textarea') - if (!textarea) return - - // 估算滚动位置(简单方法:按行数比例) - const linesBefore = content.substring(0, pos).split('\n').length - const lineHeight = 24 // 估算行高 - textarea.scrollTop = linesBefore * lineHeight - - // 设置光标位置 - textarea.focus() - textarea.setSelectionRange(pos, pos) - } catch { - // 导航失败,静默忽略 - } - }, []) - - const handleFileClick = useCallback(async (path: string) => { - const existing = tabs.find(t => t.filePath === path) - if (existing) { switchToTab(existing.id); return } - if (!window.electronAPI) return - setLoading('file-open', true) - try { - const result = await window.electronAPI.readFile(path) - if (result.success && result.content !== undefined) { - createTab(path, result.content) - recentFilesRepository.add(path) - } - } finally { - setLoading('file-open', false) - } - }, [tabs, switchToTab, createTab, setLoading]) - - const independentFiles = tabs.filter(t => { - if (!t.filePath) return false - if (!rootPath) return true - return !norm(t.filePath).startsWith(norm(rootPath)) - }) - - if (!isVisible) return null - - return ( - - ) -}) - -Sidebar.displayName = 'Sidebar' +import React, { useCallback, useMemo, useEffect, useRef } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { useSidebarStore } from '../../stores/sidebarStore' +import { getFileName } from '../../lib/fileUtils' +import { recentFilesRepository } from '../../db/recentFilesRepository' +import { FolderPlus, File } from '../Icons' +import { FileTree } from '../FileTree' +import { useSidebarResize } from '../../hooks/useSidebarResize' +import { useFolderOperations } from '../../hooks/useFolderOperations' +import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' +import { useActiveHeading } from '../../hooks/useActiveHeading' +import { OutlinePanel, parseHeadings } from '../OutlinePanel' +import type { Heading } from '../OutlinePanel' +import { getMetonaEditor, useEditorStore } from '../../stores/editorStore' + +const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export const Sidebar = React.memo(function Sidebar() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), + // 后者每次返回新对象引用导致 Zustand 无条件重渲染 + const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId]) + const switchToTab = useTabStore(s => s.switchToTab) + const createTab = useTabStore(s => s.createTab) + const rootPath = useSidebarStore(s => s.rootPath) + const tree = useSidebarStore(s => s.tree) + const expandedDirs = useSidebarStore(s => s.expandedDirs) + const toggleDir = useSidebarStore(s => s.toggleDir) + const isVisible = useSidebarStore(s => s.isVisible) + + const activeFilePath = activeTab?.filePath ?? null + const { sidebarRef, startResize } = useSidebarResize() + const { handleOpenFolder } = useFolderOperations() + const setLoading = useEditorStore(s => s.setLoading) + const viewMode = useEditorStore(s => s.viewMode) + useAutoExpandDir(activeFilePath) + + // Parse headings from active tab content + const headings = useMemo(() => { + if (!activeTab?.content) return [] + return parseHeadings(activeTab.content) + }, [activeTab?.content]) + + // D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview) + const previewRef = useRef(null) + useEffect(() => { + if (viewMode === 'preview') { + previewRef.current = document.querySelector('.me-preview') as HTMLElement | null + } else { + previewRef.current = null + } + }, [viewMode]) + + const activeHeadingIndex = useActiveHeading( + viewMode === 'preview' ? previewRef : { current: null }, + headings, + ) + + // Navigate to heading in MetonaEditor + // v0.6.0: 使用官方 API(scrollToLine + setCursorPosition)替代 DOM hack + const handleHeadingNavigate = useCallback((heading: Heading) => { + const editor = getMetonaEditor() + if (!editor) return + + try { + // 获取当前内容,查找标题文本在源代码中的位置 + const content = editor.getValue() + const headingPattern = new RegExp(`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm') + const match = headingPattern.exec(content) + if (!match) return + + // 按行号导航 — 官方 API 处理滚动与光标 + const line = content.substring(0, match.index).split('\n').length + editor.scrollToLine(line) + editor.setCursorPosition(line, 0) + editor.focus() + } catch { + // 导航失败,静默忽略 + } + }, []) + + const handleFileClick = useCallback( + async (path: string) => { + const existing = tabs.find(t => t.filePath === path) + if (existing) { + switchToTab(existing.id) + return + } + if (!window.electronAPI) return + setLoading('file-open', true) + try { + const result = await window.electronAPI.readFile(path) + if (result.success && result.content !== undefined) { + createTab(path, result.content) + recentFilesRepository.add(path) + } + } finally { + setLoading('file-open', false) + } + }, + [tabs, switchToTab, createTab, setLoading], + ) + + const independentFiles = tabs.filter(t => { + if (!t.filePath) return false + if (!rootPath) return true + return !norm(t.filePath).startsWith(norm(rootPath)) + }) + + if (!isVisible) return null + + return ( + + ) +}) + +Sidebar.displayName = 'Sidebar' diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx new file mode 100644 index 0000000..9974f7f --- /dev/null +++ b/src/renderer/components/StatusBar/StatusBar.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { useEditorStore } from '../../stores/editorStore' +import { useAutoSaveStore } from '../../stores/autoSaveStore' + +/** + * v0.6.0: 状态栏 — 展示文档统计 / 光标位置 / 自动保存状态。 + * 数据来自 editorStore(Editor 组件绑定 MetonaEditor 事件实时写入)。 + */ +export const StatusBar = React.memo(function StatusBar() { + const stats = useEditorStore(s => s.stats) + const cursor = useEditorStore(s => s.cursor) + const zenMode = useEditorStore(s => s.zenMode) + const isAutoSaving = useAutoSaveStore(s => s.isAutoSaving) + const autoSaveEnabled = useAutoSaveStore(s => s.autoSaveEnabled) + + return ( +
+ {zenMode && 🧘 Zen} + {stats && ( + + {stats.words} 词 · {stats.lines} 行 · {stats.readingTime} 分钟 + + )} + + {cursor && ( + // getCursorPosition: line 为 1-based,column 为 0-based + + 行 {cursor.line}, 列 {cursor.column + 1} + + )} + + {isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动保存' : '手动保存'} + +
+ ) +}) + +StatusBar.displayName = 'StatusBar' diff --git a/src/renderer/components/StatusBar/index.ts b/src/renderer/components/StatusBar/index.ts new file mode 100644 index 0000000..05e9f44 --- /dev/null +++ b/src/renderer/components/StatusBar/index.ts @@ -0,0 +1 @@ +export { StatusBar } from './StatusBar' diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 066bcbd..c0049f9 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -1,304 +1,306 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react' -import { useTabStore } from '../../stores/tabStore' -import { useConfirm } from '../../hooks/useConfirm' -import { getFileName } from '../../lib/fileUtils' -import { Close, Plus } from '../Icons' -import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog' - -interface ContextMenuState { - visible: boolean - x: number - y: number - tabId: string -} - -export const TabBar = React.memo(function TabBar() { - const tabs = useTabStore(s => s.tabs) - const activeTabId = useTabStore(s => s.activeTabId) - const switchToTab = useTabStore(s => s.switchToTab) - const closeTab = useTabStore(s => s.closeTab) - const createTab = useTabStore(s => s.createTab) - const closeOtherTabs = useTabStore(s => s.closeOtherTabs) - const closeAllTabs = useTabStore(s => s.closeAllTabs) - const closeTabsToRight = useTabStore(s => s.closeTabsToRight) - const moveTab = useTabStore(s => s.moveTab) - - const tabListRef = useRef(null) - const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) - const [dragOverIndex, setDragOverIndex] = useState(null) - const dragTabIdRef = useRef(null) - const { confirm, confirmDialogProps } = useConfirm() - - // 滚动到活动标签 - const scrollToActiveTab = useCallback(() => { - const tabList = tabListRef.current - if (!tabList) return - const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement - if (!activeTab) return - - const listRect = tabList.getBoundingClientRect() - const tabRect = activeTab.getBoundingClientRect() - - // 如果标签在可视区域左侧之外 - 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) - } - }, []) - - // 自动滚动到活动标签 - useEffect(() => { - requestAnimationFrame(scrollToActiveTab) - }, [activeTabId, scrollToActiveTab]) - - // 支持鼠标滚轮水平滚动标签栏 - useEffect(() => { - const tabList = tabListRef.current - if (!tabList) return - - const handleWheel = (e: WheelEvent) => { - // 检查是否有水平溢出 - if (tabList.scrollWidth <= tabList.clientWidth) return - - // 阻止默认滚动 - e.preventDefault() - - // 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY - 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 }) - return () => tabList.removeEventListener('wheel', handleWheel) - }, []) - - const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => { - e.stopPropagation() - const tab = tabs.find(t => t.id === tabId) - if (tab?.isModified) { - const name = tab.filePath ? getFileName(tab.filePath) : '未命名' - const confirmed = await confirm({ - title: '关闭标签', - message: `"${name}" 尚未保存,确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTab(tabId) - }, [tabs, closeTab, confirm]) - - // C-06: 右键菜单(带边界修正) - const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { - e.preventDefault() - e.stopPropagation() - const MENU_WIDTH = 170 - const MENU_HEIGHT = 140 - const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) - const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) - setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) - }, []) - - useEffect(() => { - if (!menu.visible) return - const handleClick = () => setMenu(prev => ({ ...prev, visible: false })) - document.addEventListener('click', handleClick) - return () => document.removeEventListener('click', handleClick) - }, [menu.visible]) - - const handleMenuClose = useCallback(async () => { - const tab = tabs.find(t => t.id === menu.tabId) - if (tab?.isModified) { - const name = tab.filePath ? getFileName(tab.filePath) : '未命名' - const confirmed = await confirm({ - title: '关闭标签', - message: `"${name}" 尚未保存,确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTab(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeTab, confirm]) - - const handleMenuCloseOthers = useCallback(async () => { - const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) - if (otherModified.length > 0) { - const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭其他标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeOtherTabs(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeOtherTabs, confirm]) - - const handleMenuCloseAll = useCallback(async () => { - const modified = tabs.filter(t => t.isModified) - if (modified.length > 0) { - const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭全部标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeAllTabs() - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, closeAllTabs, confirm]) - - const handleMenuCloseRight = useCallback(async () => { - const index = tabs.findIndex(t => t.id === menu.tabId) - const rightTabs = tabs.slice(index + 1) - const modified = rightTabs.filter(t => t.isModified) - if (modified.length > 0) { - const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') - const confirmed = await confirm({ - title: '关闭右侧标签', - message: `以下文件尚未保存:${names},确定要关闭吗?`, - variant: 'warning', - confirmLabel: '关闭' - }) - if (!confirmed) return - } - closeTabsToRight(menu.tabId) - setMenu(prev => ({ ...prev, visible: false })) - }, [tabs, menu.tabId, closeTabsToRight, confirm]) - - // D1: 拖拽排序事件处理 - const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { - dragTabIdRef.current = tabId - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', tabId) - // 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获 - requestAnimationFrame(() => { - const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement - el?.classList.add('dragging') - }) - }, []) - - const handleDragOver = useCallback((e: React.DragEvent, index: number) => { - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - setDragOverIndex(index) - }, []) - - const handleDragLeave = useCallback(() => { - setDragOverIndex(null) - }, []) - - const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => { - e.preventDefault() - setDragOverIndex(null) - const fromId = dragTabIdRef.current - if (fromId) { - moveTab(fromId, toIndex) - } - dragTabIdRef.current = null - // 清理 dragging 类 - document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging')) - }, [moveTab]) - - const handleDragEnd = useCallback(() => { - setDragOverIndex(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) - return index < tabs.length - 1 - })() - - if (tabs.length === 0) return null - - return ( - <> -
-
- {tabs.map((tab, index) => ( -
switchToTab(tab.id)} - onContextMenu={(e) => handleContextMenu(e, tab.id)} - onDragStart={(e) => handleDragStart(e, tab.id)} - onDragOver={(e) => handleDragOver(e, index)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, index)} - onDragEnd={handleDragEnd} - > - - {tab.filePath ? getFileName(tab.filePath) : '未命名'} - - -
- ))} -
- - - {/* 右键菜单 */} - {menu.visible && ( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > -
- 关闭 -
- {tabs.length > 1 && ( -
- 关闭其他标签 -
- )} - {hasRightTabs && ( -
- 关闭右侧标签 -
- )} -
-
- 关闭全部标签 -
-
- )} -
- - - ) -}) - -TabBar.displayName = 'TabBar' +import React, { useCallback, useState, useEffect, useRef } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { getFileName } from '../../lib/fileUtils' +import { MeToast } from '../../lib/toast' +import { Close, Plus } from '../Icons' + +interface ContextMenuState { + visible: boolean + x: number + y: number + tabId: string +} + +export const TabBar = React.memo(function TabBar() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const switchToTab = useTabStore(s => s.switchToTab) + const closeTab = useTabStore(s => s.closeTab) + const createTab = useTabStore(s => s.createTab) + const closeOtherTabs = useTabStore(s => s.closeOtherTabs) + const closeAllTabs = useTabStore(s => s.closeAllTabs) + const closeTabsToRight = useTabStore(s => s.closeTabsToRight) + const moveTab = useTabStore(s => s.moveTab) + + const tabListRef = useRef(null) + const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) + const [dragOverIndex, setDragOverIndex] = useState(null) + const dragTabIdRef = useRef(null) + + // 滚动到活动标签 + const scrollToActiveTab = useCallback(() => { + const tabList = tabListRef.current + if (!tabList) return + const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement + if (!activeTab) return + + const listRect = tabList.getBoundingClientRect() + const tabRect = activeTab.getBoundingClientRect() + + // 如果标签在可视区域左侧之外 + 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 + } + }, []) + + // 自动滚动到活动标签 + useEffect(() => { + requestAnimationFrame(scrollToActiveTab) + }, [activeTabId, scrollToActiveTab]) + + // 支持鼠标滚轮水平滚动标签栏 + useEffect(() => { + const tabList = tabListRef.current + if (!tabList) return + + const handleWheel = (e: WheelEvent) => { + // 检查是否有水平溢出 + if (tabList.scrollWidth <= tabList.clientWidth) return + + // 阻止默认滚动 + e.preventDefault() + + // 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY + 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 }) + return () => tabList.removeEventListener('wheel', handleWheel) + }, []) + + const handleClose = useCallback( + async (e: React.MouseEvent, tabId: string) => { + e.stopPropagation() + const tab = tabs.find(t => t.id === tabId) + if (tab?.isModified) { + const name = tab.filePath ? getFileName(tab.filePath) : '未命名' + const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, { + title: '关闭标签', + confirmText: '关闭', + cancelText: '取消', + }) + if (!confirmed) return + } + closeTab(tabId) + }, + [tabs, closeTab], + ) + + // C-06: 右键菜单(带边界修正) + const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { + e.preventDefault() + e.stopPropagation() + const MENU_WIDTH = 170 + const MENU_HEIGHT = 140 + const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) + const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) + setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) + }, []) + + useEffect(() => { + if (!menu.visible) return + const handleClick = () => setMenu(prev => ({ ...prev, visible: false })) + document.addEventListener('click', handleClick) + return () => document.removeEventListener('click', handleClick) + }, [menu.visible]) + + const handleMenuClose = useCallback(async () => { + const tab = tabs.find(t => t.id === menu.tabId) + if (tab?.isModified) { + const name = tab.filePath ? getFileName(tab.filePath) : '未命名' + const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, { + title: '关闭标签', + confirmText: '关闭', + cancelText: '取消', + }) + if (!confirmed) return + } + closeTab(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeTab]) + + const handleMenuCloseOthers = useCallback(async () => { + const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) + if (otherModified.length > 0) { + const names = otherModified + .map(t => (t.filePath ? getFileName(t.filePath) : '未命名')) + .join('、') + const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, { + title: '关闭其他标签', + confirmText: '关闭', + cancelText: '取消', + }) + if (!confirmed) return + } + closeOtherTabs(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeOtherTabs]) + + const handleMenuCloseAll = useCallback(async () => { + const modified = tabs.filter(t => t.isModified) + if (modified.length > 0) { + const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、') + const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, { + title: '关闭全部标签', + confirmText: '关闭', + cancelText: '取消', + }) + if (!confirmed) return + } + closeAllTabs() + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, closeAllTabs]) + + const handleMenuCloseRight = useCallback(async () => { + const index = tabs.findIndex(t => t.id === menu.tabId) + const rightTabs = tabs.slice(index + 1) + const modified = rightTabs.filter(t => t.isModified) + if (modified.length > 0) { + const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、') + const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, { + title: '关闭右侧标签', + confirmText: '关闭', + cancelText: '取消', + }) + if (!confirmed) return + } + closeTabsToRight(menu.tabId) + setMenu(prev => ({ ...prev, visible: false })) + }, [tabs, menu.tabId, closeTabsToRight]) + + // D1: 拖拽排序事件处理 + const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { + dragTabIdRef.current = tabId + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', tabId) + // 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获 + requestAnimationFrame(() => { + const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement + el?.classList.add('dragging') + }) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDragOverIndex(index) + }, []) + + const handleDragLeave = useCallback(() => { + setDragOverIndex(null) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, toIndex: number) => { + e.preventDefault() + setDragOverIndex(null) + const fromId = dragTabIdRef.current + if (fromId) { + moveTab(fromId, toIndex) + } + dragTabIdRef.current = null + // 清理 dragging 类 + document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging')) + }, + [moveTab], + ) + + const handleDragEnd = useCallback(() => { + setDragOverIndex(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) + return index < tabs.length - 1 + })() + + if (tabs.length === 0) return null + + return ( + <> +
+
+ {tabs.map((tab, index) => ( +
switchToTab(tab.id)} + onContextMenu={e => handleContextMenu(e, tab.id)} + onDragStart={e => handleDragStart(e, tab.id)} + onDragOver={e => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={e => handleDrop(e, index)} + onDragEnd={handleDragEnd} + > + + {tab.filePath ? getFileName(tab.filePath) : '未命名'} + + +
+ ))} +
+ + + {/* 右键菜单 */} + {menu.visible && ( +
e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > +
+ 关闭 +
+ {tabs.length > 1 && ( +
+ 关闭其他标签 +
+ )} + {hasRightTabs && ( +
+ 关闭右侧标签 +
+ )} +
+
+ 关闭全部标签 +
+
+ )} +
+ + ) +}) + +TabBar.displayName = 'TabBar' diff --git a/src/renderer/components/Toolbar/Toolbar.tsx b/src/renderer/components/Toolbar/Toolbar.tsx index 7aaf677..77dece7 100644 --- a/src/renderer/components/Toolbar/Toolbar.tsx +++ b/src/renderer/components/Toolbar/Toolbar.tsx @@ -1,71 +1,173 @@ -import React from 'react' -import { FolderOpen, Save, Moon, Sun, Info } from '../Icons' -import type { ThemeMode } from '../../types/settings' - -interface ToolbarProps { - onOpen: () => void - onSave: () => void - themeMode: ThemeMode - onCycleTheme: () => void - onShowAbout: () => void - isAutoSaving: boolean - autoSaveEnabled: boolean - onToggleAutoSave: () => void -} - -const THEME_LABELS: Record = { - light: '亮色', - dark: '暗色', - warm: '暖色', -} - -/** - * 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。 - * 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。 - */ -export const Toolbar = React.memo(function Toolbar({ - onOpen, onSave, themeMode, onCycleTheme, onShowAbout, - isAutoSaving, autoSaveEnabled, onToggleAutoSave -}: ToolbarProps) { - const nextLabel = THEME_LABELS[themeMode] ?? '主题' - - return ( -