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 →
…
+ └── 安全: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 (
-
- )
-}
-
-// ===== 工具栏图标 =====
-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
+}
+
+// ===== 工具栏图标 =====
+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 (
+
+ )
+})
+
+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 (
-
-
-
-
-
-
-
-
-
-
-
-
- )
-})
-
-Toolbar.displayName = 'Toolbar'
+import React, { useCallback } from 'react'
+import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
+import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
+import { backupRepository } from '../../db/backupRepository'
+import { showToast } from '../../lib/toast'
+import { logError } from '../../lib/errorHandler'
+import type { ThemeMode } from '../../types/settings'
+
+interface ToolbarProps {
+ 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] ?? '主题'
+ const zenMode = useEditorStore(s => s.zenMode)
+
+ // v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
+ const handleToggleZen = useCallback(() => {
+ const editor = getMetonaEditor()
+ if (!editor) return
+ editor.toggleZen()
+ }, [])
+
+ // v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
+ const handleExport = useCallback(async () => {
+ if (!window.electronAPI) return
+ const data = await backupRepository.exportAll()
+ if (!data) return
+ const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
+ if (result.success) {
+ showToast('备份已导出', 'success')
+ } else if (!result.canceled) {
+ showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
+ }
+ }, [])
+
+ // v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable)
+ const handleImport = useCallback(async () => {
+ if (!window.electronAPI) return
+ const result = await window.electronAPI.importData()
+ if (!result.success) return
+ if (result.canceled || !result.content) return
+ try {
+ const data = JSON.parse(result.content) as Record[]>
+ const ok = await backupRepository.importAll(data)
+ if (ok) {
+ // 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
+ // 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
+ showToast('备份已恢复', 'success')
+ setTimeout(() => window.location.reload(), 800)
+ } else {
+ showToast('恢复备份失败', 'error')
+ }
+ } catch (error) {
+ logError('解析备份文件失败', error)
+ showToast('备份文件格式无效', 'error')
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+})
+
+Toolbar.displayName = 'Toolbar'
diff --git a/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx
index 12fd9c3..4e34752 100644
--- a/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx
+++ b/src/renderer/components/WelcomeScreen/WelcomeScreen.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository'
+import { getDb } from '../../db/schema'
import { getFileName } from '../../lib/fileUtils'
interface WelcomeScreenProps {
@@ -9,13 +10,47 @@ interface WelcomeScreenProps {
onOpenRecent?: (filePath: string) => void
}
-export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
+export const WelcomeScreen = React.memo(function WelcomeScreen({
+ onOpen,
+ onNew,
+ onOpenRecent,
+}: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState([])
+ // v0.6.0: 订阅 recentFiles 表变更,文件打开/删除时自动刷新
useEffect(() => {
- recentFilesRepository.getAll(10).then((files: string[]) => {
- setRecentFiles(files)
- })
+ let cancelled = false
+ let unsubscribe: (() => void) | null = null
+
+ const refresh = async () => {
+ const files = await recentFilesRepository.getAll(10)
+ if (!cancelled) setRecentFiles(files)
+ }
+
+ refresh()
+
+ getDb()
+ .then(db => {
+ if (cancelled) return
+ unsubscribe = db.subscribe('recentFiles', event => {
+ if (
+ event.type === 'insert' ||
+ event.type === 'update' ||
+ event.type === 'delete' ||
+ event.type === 'external'
+ ) {
+ refresh()
+ }
+ })
+ })
+ .catch(() => {
+ /* 订阅失败不阻塞页面 */
+ })
+
+ return () => {
+ cancelled = true
+ unsubscribe?.()
+ }
}, [])
return (
@@ -51,7 +86,14 @@ export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew,
aria-label={`打开 ${getFileName(filePath)}`}
>