Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbaea2df3e | ||
|
|
cf920ab2e6 | ||
|
|
cb243c5c95 | ||
|
|
b814114ff0 | ||
|
|
63fa0d3040 | ||
|
|
8bee37cb89 | ||
|
|
a981d9fd08 | ||
|
|
8398e3f124 | ||
|
|
6450cd6435 | ||
|
|
c647b7e602 | ||
|
|
764d3d831f | ||
|
|
7e0e579dac | ||
|
|
b762488ac9 | ||
|
|
0e06219969 | ||
|
|
58c8a1a3a0 | ||
|
|
9fcbec790d | ||
|
|
26d54f042b | ||
|
|
ee5f35177e |
@@ -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 → <div class="me-mermaid">…
|
||||
└── XSS 防护(escapeHTML + safeUrl)
|
||||
│
|
||||
▼
|
||||
fixImageSrcs(HTML 后处理:相对路径 → file://)
|
||||
│
|
||||
▼
|
||||
MetonaEditor 预览区渲染
|
||||
@@ -306,35 +307,24 @@ MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变
|
||||
|
||||
## 7. Markdown 渲染管线
|
||||
|
||||
v0.6.0: 渲染完全由 MetonaEditor 内置解析器承担(零依赖),应用侧仅保留图片路径修复后处理:
|
||||
|
||||
```
|
||||
Markdown 文本
|
||||
│
|
||||
▼
|
||||
remark-parse 解析为 MDAST
|
||||
parseMarkdown(MetonaEditor 内置解析器)
|
||||
├── 块级:标题 / 列表 / 引用 / 代码块 / 表格 / 水平线 / 脚注 / 数学公式 / 定义列表
|
||||
├── 行内:粗体 / 斜体 / 删除线 / 高亮 / 上下标 / 行内代码 / 链接 / 图片 / emoji
|
||||
├── mermaid:<div class="me-mermaid"><pre class="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 图表渲染 |
|
||||
|
||||
### 开发依赖
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||
<img src="https://img.shields.io/badge/Version-v0.5.0-orange?style=flat-square" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Version-v0.6.1-orange?style=flat-square" alt="Version">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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),编辑器主题同步切换 |
|
||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||
@@ -41,6 +41,7 @@
|
||||
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
||||
| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
|
||||
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlark(AriaEngine)持久化,关闭后可恢复 |
|
||||
| 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable) |
|
||||
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
||||
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
||||
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
|
||||
@@ -146,11 +147,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.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + 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 的语法高亮 |
|
||||
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.4 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
|
||||
| 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 安装包 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||
<title>MetonaEditor v0.4.0 — 全功能演示</title>
|
||||
<title>MetonaEditor v0.4.1 — 全功能演示</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
||||
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<h1><span class="grad">Metona</span>Editor v0.4.0</h1>
|
||||
<h1><span class="grad">Metona</span>Editor v0.4.1</h1>
|
||||
<div class="badges">
|
||||
<span class="badge">TypeScript</span>
|
||||
<span class="badge">零运行时依赖</span>
|
||||
<span class="badge">826 tests</span>
|
||||
<span class="badge">841 tests</span>
|
||||
<span class="badge">6 个插件</span>
|
||||
<span class="badge">桌面端优先</span>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
||||
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizer,js / ts / python / bash 等 16 个语言标识</p></div>
|
||||
<div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div>
|
||||
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
|
||||
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div>
|
||||
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,宽度可配置(zenMaxWidth,默认 960px)</p></div>
|
||||
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
||||
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
||||
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
||||
@@ -93,7 +93,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
MetonaEditor v0.4.0 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
||||
MetonaEditor v0.4.1 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
@@ -101,13 +101,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
||||
<script>
|
||||
(function(){
|
||||
var demoMd=[
|
||||
'# 🚀 MetonaEditor v0.4.0 全功能演示',
|
||||
'# 🚀 MetonaEditor v0.4.1 全功能演示',
|
||||
'',
|
||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||
'> 全模块 TypeScript 严格模式,17 个源文件,826 个测试全部通过。',
|
||||
'> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。',
|
||||
'',
|
||||
'## ✨ v0.4.0 新特性',
|
||||
'## ✨ v0.4.1 新特性',
|
||||
'',
|
||||
'- **Zen 专注模式宽度可配置**:`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽',
|
||||
'- **修复 5 处问题**:`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新',
|
||||
'- **Playwright 浏览器冒烟测试**:22 项断言真实 Chromium 验证',
|
||||
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
|
||||
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
|
||||
@@ -139,6 +141,7 @@ var demoMd=[
|
||||
'',
|
||||
'| 版本 | 日期 | 测试 | 主题 |',
|
||||
'| :--- | :---: | ---: | --- |',
|
||||
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
|
||||
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
|
||||
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
|
||||
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
|
||||
@@ -200,8 +203,8 @@ var demoMd=[
|
||||
'```',
|
||||
'',
|
||||
'```bash',
|
||||
'# 安装与启动',
|
||||
'npm install @metona-team/metona-editor',
|
||||
'# 安装(Gitea 私有源)',
|
||||
'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
|
||||
'npm run dev',
|
||||
'```',
|
||||
'',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
||||
<title>MetonaEditor v0.4.0 · 文档</title>
|
||||
<title>MetonaEditor v0.4.1 · 文档</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
||||
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
|
||||
|
||||
<header class="docs-header">
|
||||
<h1><span class="grad">API 文档</span></h1>
|
||||
<p class="sub">MetonaEditor v0.4.0 · TypeScript · 完整配置、API 与使用指南</p>
|
||||
<p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、API 与使用指南</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
@@ -109,8 +109,11 @@ footer a{color:var(--accent);text-decoration:none}
|
||||
<!-- 快速入门 -->
|
||||
<section id="quickstart">
|
||||
<h2>安装与引入</h2>
|
||||
<pre><span class="c-com"># npm 安装</span>
|
||||
<span class="c-kw">npm</span> install @metona-team/metona-editor
|
||||
<pre><span class="c-com"># npm 安装(Gitea 私有源)</span>
|
||||
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
|
||||
|
||||
<span class="c-com">// 或项目 .npmrc 配置 scope 后直接安装</span>
|
||||
<span class="c-com">// @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/</span>
|
||||
|
||||
<span class="c-com">// TypeScript / ES Module</span>
|
||||
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
@@ -146,6 +149,8 @@ footer a{color:var(--accent);text-decoration:none}
|
||||
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||
<span class="c-com">// Zen 内容区最大宽度(数字为 px / 字符串为 CSS 值 / false 不限宽)</span>
|
||||
zenMaxWidth<span class="c-punc">:</span> <span class="c-num">960</span><span class="c-punc">,</span>
|
||||
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
|
||||
floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
|
||||
|
||||
@@ -252,6 +257,8 @@ editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class=
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> <span class="c-com">// 或配置 zenMode: true 启动即进入</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false)</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre>
|
||||
</section>
|
||||
@@ -487,7 +494,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
MetonaEditor v0.4.0 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||
MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧪 在线演示 — MetonaSqlark v0.4.1</title>
|
||||
<title>🧪 在线演示 — MetonaSqlark v0.4.4</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
@@ -84,7 +84,7 @@
|
||||
<a href="demo.html" class="nav-active">演示</a>
|
||||
<a href="benchmark.html">基准</a>
|
||||
</nav>
|
||||
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.1</div>
|
||||
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.4</div>
|
||||
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
|
||||
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||
</header>
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="main">
|
||||
<div class="editor-panel">
|
||||
<div class="editor-area">
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.1 在线演示
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.4 在线演示
|
||||
-- 已预置 users / orders / products 表数据
|
||||
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('v040')" style="color:#22c55e;border-color:#22c55e;">🚰 v0.4.0 新特性</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('onupdate')" style="color:#fbbf24;border-color:#fbbf24;">🔄 ON UPDATE</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('repair')" style="color:#22c55e;border-color:#22c55e;">🛡 自愈</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +176,7 @@ async function initDB() {
|
||||
db = new DBClass({ name: 'demo', mode: engine });
|
||||
await db.init();
|
||||
|
||||
// v0.4.1: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
|
||||
// v0.4.2: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
|
||||
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
|
||||
await db.getEngine().clearAll();
|
||||
}
|
||||
@@ -230,7 +232,7 @@ async function seedDemoData(db) {
|
||||
]);
|
||||
}
|
||||
|
||||
// v0.4.1: 切换存储引擎(重建数据库实例)
|
||||
// v0.4.2: 切换存储引擎(重建数据库实例)
|
||||
async function switchEngine(engine) {
|
||||
if (engine === currentEngine) return;
|
||||
currentEngine = engine;
|
||||
@@ -552,7 +554,7 @@ SELECT COUNT(*) as total FROM temp_logs;
|
||||
|
||||
-- 清理
|
||||
DROP TABLE temp_logs;`,
|
||||
aria: `-- 🌲 AriaEngine 演示 (v0.4.1)
|
||||
aria: `-- 🌲 AriaEngine 演示 (v0.4.4)
|
||||
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
|
||||
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter)
|
||||
-- 基础 CRUD 与 Memory 引擎完全兼容
|
||||
@@ -587,12 +589,13 @@ DROP TABLE temp_logs;`,
|
||||
|
||||
-- AriaEngine 特性:
|
||||
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
|
||||
-- • WAL: Write-Ahead Log 保证崩溃恢复 + 批量组提交
|
||||
-- • WAL: 原子写入 + 崩溃恢复(残缺 SSTable 打开自动跳过)+ 批量组提交
|
||||
-- • MVCC: 版本链 + 快照隔离
|
||||
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
|
||||
-- • Bloom Filter: FNV-1a + Murmur 双哈希
|
||||
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
|
||||
-- • 外键级联: CASCADE / SET NULL / RESTRICT (v0.4.1)
|
||||
-- • 二级索引: 每列独立 LSM + 跨重启自动恢复
|
||||
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT (v0.4.2)
|
||||
-- • 自愈: db.repair() 无需删库重建 (v0.4.2)
|
||||
|
||||
-- 生产环境 API(与演示页右上角切换等价)
|
||||
-- const db = await MetonaSqlark.create({
|
||||
@@ -674,7 +677,7 @@ WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 10
|
||||
-- 为 orders.user_id 创建索引(已有数据自动回填)
|
||||
CREATE INDEX idx_orders_user ON orders (user_id);
|
||||
|
||||
-- 索引查找(v0.4.1: JOIN 主表 WHERE 条件下推到引擎,真正走二级索引)
|
||||
-- 索引查找(v0.4.2: JOIN 主表 WHERE 条件下推 + 二级索引跨重启恢复)
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM orders o JOIN users u ON u.id = o.user_id
|
||||
WHERE o.user_id = '1';
|
||||
@@ -687,6 +690,51 @@ SELECT * FROM orders WHERE user_id = '3';
|
||||
|
||||
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND)
|
||||
-- DROP INDEX idx_nonexist ON orders (user_id);`,
|
||||
onupdate: `-- 🔄 ON UPDATE 外键级联 (v0.4.2)
|
||||
-- 更新父表主键 → 子表外键自动级联(CASCADE / SET NULL / RESTRICT)
|
||||
|
||||
-- 建带 onUpdate 外键的表
|
||||
DROP TABLE IF EXISTS accounts;
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
CREATE TABLE accounts (id STRING PRIMARY KEY, owner STRING);
|
||||
CREATE TABLE audit_log (
|
||||
id STRING PRIMARY KEY,
|
||||
account_id STRING REFERENCES accounts.id ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 种子数据
|
||||
INSERT INTO accounts VALUES ('a1', 'Alice');
|
||||
INSERT INTO audit_log VALUES ('l1', 'a1');
|
||||
INSERT INTO audit_log VALUES ('l2', 'a1');
|
||||
|
||||
-- 更新父表主键 a1 → a2
|
||||
UPDATE accounts SET id = 'a2' WHERE id = 'a1';
|
||||
|
||||
-- 子表外键已级联为 a2
|
||||
SELECT * FROM audit_log ORDER BY id;
|
||||
|
||||
-- 再按新主键反查
|
||||
SELECT * FROM accounts WHERE id = 'a2';`,
|
||||
repair: `-- 🛡 崩溃恢复自愈 (v0.4.2)
|
||||
-- db.repair(): 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL
|
||||
-- db.clearAll(): 清空全部数据与表结构(保留库本身)
|
||||
-- 打开数据库时自动跳过残缺 SSTable,无需删库重建
|
||||
|
||||
-- 数据写入
|
||||
CREATE TABLE IF NOT EXISTS notes (id STRING PRIMARY KEY, body STRING);
|
||||
INSERT INTO notes VALUES ('n1', 'Hello');
|
||||
INSERT INTO notes VALUES ('n2', 'World');
|
||||
|
||||
-- 模拟数据(正常数据)
|
||||
SELECT * FROM notes ORDER BY id;
|
||||
|
||||
-- API 自愈(控制台执行):
|
||||
-- await db.repair(); → 校验+清理损坏文件,重建索引
|
||||
-- await db.clearAll(); → 清空全部表与数据
|
||||
|
||||
-- 迁移版本持久化(重启后不重跑已执行迁移)
|
||||
-- db.addMigration(1, async () => { ... });
|
||||
-- await db.migrateTo(1);`,
|
||||
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
|
||||
|
||||
-- 分号分隔的多语句一次执行
|
||||
@@ -753,7 +801,7 @@ document.addEventListener('keydown', e => {
|
||||
document.getElementById('btn-aria').style.opacity = '0.6';
|
||||
document.getElementById('engine-status').textContent = '⚡ Memory';
|
||||
initDB().then(() => {
|
||||
console.log('✅ MetonaSqlark v0.4.1 demo ready');
|
||||
console.log('✅ MetonaSqlark v0.4.2 demo ready');
|
||||
setTimeout(runQuery, 300);
|
||||
}).catch(err => {
|
||||
renderError('初始化失败: ' + err.message);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📖 API 文档 — MetonaSqlark v0.4.1</title>
|
||||
<title>📖 API 文档 — MetonaSqlark v0.4.4</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
@@ -95,6 +95,7 @@
|
||||
<a href="#foreign-key">外键级联</a>
|
||||
<a href="#connection-pool">连接池</a>
|
||||
<a href="#migration">数据迁移</a>
|
||||
<a href="#selfheal">崩溃自愈 🆕</a>
|
||||
<a href="#export">导入导出</a>
|
||||
<a href="#plugin">插件 & 钩子</a>
|
||||
<a href="#subscribe">发布订阅</a>
|
||||
@@ -197,8 +198,8 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
|
||||
<tr><td><code>maxLength</code></td><td><code>number</code></td><td>字符串最大长度</td></tr>
|
||||
<tr><td><code>min</code>/<code>max</code></td><td><code>number</code></td><td>数值范围</td></tr>
|
||||
<tr><td><code>references</code></td><td><code>string</code></td><td>外键引用 <code>'table.column'</code></td></tr>
|
||||
<tr><td><code>onDelete</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>删除级联 🆕</td></tr>
|
||||
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联 🆕</td></tr>
|
||||
<tr><td><code>onDelete</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>删除级联 ✅ v0.4.1</td></tr>
|
||||
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联(更新主键时触发)✅ v0.4.2</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="sql-query">🔍 SQL 查询</h2>
|
||||
@@ -549,6 +550,19 @@ db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k"
|
||||
|
||||
<span class="c">// 执行迁移到目标版本</span>
|
||||
<span class="k">await</span> db.<span class="f">migrateTo</span>(<span class="n">3</span>); <span class="c">// 依次执行 v2, v3 的迁移函数</span></pre>
|
||||
<p>✅ <strong>v0.4.2: 迁移版本持久化到库内</strong> — 重启后从持久化版本继续执行,已执行迁移不重跑(此前 version 每次从 config 重置,可能重跑不幂等的迁移)。</p>
|
||||
|
||||
<h2 id="selfheal">🛡 崩溃恢复自愈(v0.4.2)</h2>
|
||||
<p>异常退出(强杀/断电)后无需删库重建:打开数据库时自动跳过残缺 SSTable,应用层可调用自愈 API 恢复一致性。</p>
|
||||
|
||||
<pre><span class="c">// 自愈 — 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL</span>
|
||||
<span class="k">await</span> db.<span class="f">repair</span>();
|
||||
|
||||
<span class="c">// 清空全部数据与表结构(保留库本身,实例可继续使用)</span>
|
||||
<span class="k">await</span> db.<span class="f">clearAll</span>();
|
||||
|
||||
<span class="c">// 引擎级元数据(迁移版本等)</span>
|
||||
<span class="c">// 引擎接口 IStorageEngine 可选扩展:repair() / clearAll() / getMeta() / setMeta()</span></pre>
|
||||
|
||||
<h2 id="export">📤 导入导出</h2>
|
||||
<pre><span class="c">// 导出单表 — 返回 JSON 数组</span>
|
||||
@@ -747,7 +761,9 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
|
||||
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
|
||||
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。<br>
|
||||
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
|
||||
<strong>v0.4.1 Aria 级联与演示页引擎切换</strong> — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。</p>
|
||||
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
|
||||
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待。<br>
|
||||
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2("SSTC")与 v1("SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持 · 958 测试 52 套件。</p>
|
||||
|
||||
<h3>存储模式对比</h3>
|
||||
<table>
|
||||
|
||||
+4
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marklite",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.1",
|
||||
"description": "Lightweight Markdown Editor for Windows",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
@@ -28,22 +28,13 @@
|
||||
"author": "MarkLite",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@metona-team/metona-editor": "^0.4.0",
|
||||
"@metona-team/metona-sqlark": "^0.4.1",
|
||||
"@metona-team/metona-editor": "0.4.1",
|
||||
"@metona-team/metona-sqlark": "0.4.4",
|
||||
"@metona-team/metona-toast": "^0.5.0",
|
||||
"mermaid": "^10.9.6",
|
||||
"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": {
|
||||
@@ -60,6 +51,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",
|
||||
|
||||
+15
-8
@@ -10,26 +10,29 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
||||
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<string>
|
||||
visited?: Set<string>,
|
||||
): Promise<FileNode[]> {
|
||||
if (depth > maxDepth) return []
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+7
-6
@@ -10,7 +10,7 @@ const state = {
|
||||
activeFilePath: null as string | null,
|
||||
pendingFilePath: null as string | null,
|
||||
isClosing: false,
|
||||
closeTimeout: null as NodeJS.Timeout | null
|
||||
closeTimeout: null as NodeJS.Timeout | null,
|
||||
}
|
||||
|
||||
const fileWatcher = new FileWatcher(() => mainWindow)
|
||||
@@ -18,20 +18,22 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow)
|
||||
|
||||
function openFileInTab(filePath: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
readFileContent(filePath).then(result => {
|
||||
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) => {
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console -- IPC file open error
|
||||
console.error('openFileInTab failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const lockOk = setupSingleInstanceLock((filePath) => {
|
||||
const lockOk = setupSingleInstanceLock(filePath => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
@@ -42,10 +44,9 @@ const lockOk = setupSingleInstanceLock((filePath) => {
|
||||
if (!lockOk) {
|
||||
app.quit()
|
||||
} else {
|
||||
|
||||
function setupCloseHandler(): void {
|
||||
if (!mainWindow) return
|
||||
mainWindow.on('close', (e) => {
|
||||
mainWindow.on('close', e => {
|
||||
if (state.isClosing) return
|
||||
state.isClosing = true
|
||||
e.preventDefault()
|
||||
|
||||
+69
-13
@@ -2,7 +2,7 @@ import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electro
|
||||
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 { stat, readFile, writeFile } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
@@ -24,7 +24,12 @@ export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
|
||||
state: {
|
||||
activeFilePath: string | null
|
||||
pendingFilePath: string | null
|
||||
isClosing: boolean
|
||||
closeTimeout: NodeJS.Timeout | null
|
||||
},
|
||||
): void {
|
||||
// 打开文件对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
||||
@@ -33,7 +38,7 @@ export function registerIpcHandlers(
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
@@ -61,7 +66,9 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_SAVE,
|
||||
async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
if (data.filePath && !validatePath(data.filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
@@ -85,7 +92,7 @@ export function registerIpcHandlers(
|
||||
} else {
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
const saveResult = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
|
||||
})
|
||||
if (!saveResult.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
@@ -107,15 +114,18 @@ export function registerIpcHandlers(
|
||||
}
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_SAVE_AS,
|
||||
async (_event: IpcMainInvokeEvent, data: { content: string }) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
|
||||
})
|
||||
if (!result.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
@@ -133,7 +143,8 @@ export function registerIpcHandlers(
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 获取当前路径
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
|
||||
@@ -159,7 +170,9 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 目录树
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIR_READ_TREE,
|
||||
async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
@@ -169,7 +182,8 @@ export function registerIpcHandlers(
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 打开文件夹对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
|
||||
@@ -193,7 +207,9 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TAB_SWITCHED,
|
||||
(_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
const normalizedPath = filePath || null
|
||||
if (normalizedPath && !validatePath(normalizedPath)) {
|
||||
fileWatcher.stop()
|
||||
@@ -205,7 +221,8 @@ export function registerIpcHandlers(
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
|
||||
@@ -225,4 +242,43 @@ export function registerIpcHandlers(
|
||||
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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+26
-11
@@ -7,8 +7,8 @@ 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),
|
||||
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),
|
||||
@@ -38,27 +38,42 @@ const api: ElectronAPI = {
|
||||
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)
|
||||
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) }
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
|
||||
}
|
||||
},
|
||||
onExternalModification: (callback) => {
|
||||
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) }
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
|
||||
}
|
||||
},
|
||||
onDirChanged: (callback) => {
|
||||
onDirChanged: callback => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
||||
}
|
||||
},
|
||||
onConfirmClose: (callback) => {
|
||||
onConfirmClose: callback => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||
|
||||
+33
-17
@@ -11,7 +11,8 @@ 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 { MeToast } from './lib/toast'
|
||||
import { closeDatabase } from './db/schema'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
@@ -21,7 +22,6 @@ 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)
|
||||
@@ -34,12 +34,13 @@ export function App() {
|
||||
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])
|
||||
useEffect(() => {
|
||||
loadFromDB()
|
||||
}, [loadFromDB])
|
||||
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
|
||||
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
|
||||
@@ -48,17 +49,22 @@ export function App() {
|
||||
// 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<boolean> => {
|
||||
return confirm({
|
||||
return MeToast.confirm(message, {
|
||||
title: '未保存的更改',
|
||||
message,
|
||||
variant: 'warning',
|
||||
confirmLabel: '不保存',
|
||||
cancelLabel: '取消'
|
||||
confirmText: '不保存',
|
||||
cancelText: '取消',
|
||||
})
|
||||
}, [confirm])
|
||||
}, [])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
|
||||
// v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
|
||||
const handleBeforeForceClose = useCallback(async () => {
|
||||
await flushSaveToDB()
|
||||
await closeDatabase()
|
||||
}, [])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useIpcListeners()
|
||||
|
||||
@@ -85,8 +91,10 @@ export function App() {
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
themeMode={themeMode} onCycleTheme={cycleTheme}
|
||||
onOpen={handleOpenFile}
|
||||
onSave={handleSave}
|
||||
themeMode={themeMode}
|
||||
onCycleTheme={cycleTheme}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
isAutoSaving={isAutoSaving}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
@@ -97,20 +105,28 @@ export function App() {
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
|
||||
<ModifiedBanner
|
||||
onReload={handleReloadModified}
|
||||
onDismiss={() => setExternallyModified(null)}
|
||||
/>
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
|
||||
<div id="editor-panel">
|
||||
<Editor themeMode={themeMode} onAppSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
<WelcomeScreen
|
||||
onOpen={handleOpenFile}
|
||||
onNew={() => createTab(null, '')}
|
||||
onOpenRecent={handleOpenRecent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,13 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div
|
||||
className="about-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="关于 MarkLite"
|
||||
>
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
@@ -36,6 +42,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>浮动格式栏</span>
|
||||
<span>数据备份</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,10 +52,12 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</p>
|
||||
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.4.4</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
<button className="about-close-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Only restore focus if the element is still in the DOM
|
||||
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
|
||||
previousFocusRef.current.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
@@ -1 +0,0 @@
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
@@ -50,15 +50,13 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
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 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 setZenMode = useEditorStore(s => s.setZenMode)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<MarkdownEditor | null>(null)
|
||||
@@ -78,12 +76,34 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
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'
|
||||
'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,
|
||||
@@ -98,6 +118,8 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
plugins: EDITOR_PLUGINS,
|
||||
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
|
||||
floatingToolbar: true,
|
||||
// v0.6.1: 专注模式宽度使用 0.4.1 官方 API — 100% 全宽(默认 960px 偏窄)
|
||||
zenMaxWidth: '100%',
|
||||
// v0.2.4 新增配置项
|
||||
syncScroll: true,
|
||||
wordWrap: true,
|
||||
@@ -105,7 +127,9 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
historyLimit: 100,
|
||||
historyDebounce: 400,
|
||||
|
||||
// 使用 unified 管线渲染,保留图片路径修复能力
|
||||
// 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
|
||||
// v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
|
||||
highlight: MeEditor.highlight,
|
||||
render: (md: string) => {
|
||||
const tabId = activeTabIdRef.current
|
||||
const filePath = tabId
|
||||
@@ -161,12 +185,23 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
|
||||
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
|
||||
const renderMermaid = () => {
|
||||
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ }
|
||||
try {
|
||||
mermaid.run({ querySelector: '.me-mermaid .mermaid' })
|
||||
} catch {
|
||||
/* 容错 */
|
||||
}
|
||||
}
|
||||
editor.on('afterRender', renderMermaid)
|
||||
// 首次渲染后延迟触发一次
|
||||
setTimeout(renderMermaid, 300)
|
||||
|
||||
// v0.6.0: Zen 状态同步 → editorStore(Toolbar 消费)
|
||||
// 文档统计由编辑器自带底栏展示(wordCount: true),无需应用层同步
|
||||
const syncZen = (zen: boolean) => {
|
||||
setZenMode(Boolean(zen))
|
||||
}
|
||||
editor.on('zenChange', syncZen)
|
||||
|
||||
return () => {
|
||||
editor.destroy()
|
||||
editorRef.current = null
|
||||
|
||||
@@ -43,11 +43,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return (
|
||||
<div className="error-boundary-root" role="alert">
|
||||
<h2 className="error-boundary-title">应用遇到了错误</h2>
|
||||
{isDev && (
|
||||
<pre className="error-boundary-detail">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
)}
|
||||
{isDev && <pre className="error-boundary-detail">{this.state.error?.message}</pre>}
|
||||
<button className="error-boundary-reset" onClick={this.handleReset}>
|
||||
重试
|
||||
</button>
|
||||
|
||||
@@ -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({
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${isActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
style={{ paddingLeft: 8 + depth * 16 + 'px' }}
|
||||
role="treeitem"
|
||||
aria-expanded={node.type === 'dir' ? isExpanded : undefined}
|
||||
aria-selected={isActive}
|
||||
@@ -43,7 +43,7 @@ export const FileTree = React.memo(function FileTree({
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (node.type === 'dir') {
|
||||
|
||||
@@ -12,15 +12,22 @@ const defaultProps: Partial<IconProps> = { size: 18 }
|
||||
|
||||
// ===== 应用图标 =====
|
||||
export function AppIcon({ size = 80 }: IconProps) {
|
||||
return (
|
||||
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||
)
|
||||
return <img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||
}
|
||||
|
||||
// ===== 工具栏图标 =====
|
||||
export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1" />
|
||||
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none" />
|
||||
<path d="M12 11h4" strokeDasharray="2 2" />
|
||||
@@ -30,7 +37,16 @@ export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
||||
|
||||
export function Save({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
@@ -40,7 +56,16 @@ export function Save({ size = defaultProps.size }: IconProps) {
|
||||
|
||||
export function Moon({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
@@ -49,7 +74,16 @@ export function Moon({ size = defaultProps.size }: IconProps) {
|
||||
|
||||
export function Sun({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
@@ -75,7 +109,16 @@ export function Gitee({ size = defaultProps.size }: IconProps) {
|
||||
|
||||
export function Info({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
@@ -86,7 +129,15 @@ export function Info({ size = defaultProps.size }: IconProps) {
|
||||
// ===== 标签栏图标 =====
|
||||
export function Close({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
@@ -95,7 +146,15 @@ export function Close({ size = 10 }: IconProps) {
|
||||
|
||||
export function Plus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
@@ -105,7 +164,16 @@ export function Plus({ size = 14 }: IconProps) {
|
||||
// ===== 侧边栏图标 =====
|
||||
export function Folder({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4" />
|
||||
</svg>
|
||||
@@ -114,7 +182,16 @@ export function Folder({ size = 14 }: IconProps) {
|
||||
|
||||
export function File({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4" />
|
||||
@@ -125,7 +202,16 @@ export function File({ size = 14 }: IconProps) {
|
||||
|
||||
export function ChevronRight({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)
|
||||
@@ -133,7 +219,16 @@ export function ChevronRight({ size = 10 }: IconProps) {
|
||||
|
||||
export function FolderPlus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
@@ -144,7 +239,16 @@ export function FolderPlus({ size = 14 }: IconProps) {
|
||||
// ===== 拖拽覆盖层图标 =====
|
||||
export function UploadCloud({ size = 64 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<polyline points="12 16 12 8" />
|
||||
<polyline points="8 12 12 8 16 12" />
|
||||
@@ -153,10 +257,58 @@ export function UploadCloud({ size = 64 }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== v0.6.0: 数据备份图标 =====
|
||||
export function Download({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Upload({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
@@ -166,7 +318,16 @@ export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
|
||||
export function WelcomeNew({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
label?: string
|
||||
/** 是否全屏覆盖 */
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 36
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-02: 通用加载指示器组件
|
||||
*/
|
||||
export const LoadingSpinner = React.memo(function LoadingSpinner({
|
||||
size = 'medium',
|
||||
label,
|
||||
overlay = false
|
||||
}: LoadingSpinnerProps) {
|
||||
const px = sizeMap[size]
|
||||
|
||||
const spinner = (
|
||||
<div
|
||||
className={`loading-spinner loading-spinner-${size}`}
|
||||
role="status"
|
||||
aria-label={label || '加载中'}
|
||||
>
|
||||
<svg
|
||||
className="loading-spinner-svg"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label && <span className="loading-spinner-label">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!overlay) return spinner
|
||||
|
||||
return (
|
||||
<div className="loading-overlay" aria-busy="true">
|
||||
{spinner}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner'
|
||||
@@ -1 +0,0 @@
|
||||
export { LoadingSpinner } from './LoadingSpinner'
|
||||
@@ -5,12 +5,19 @@ interface ModifiedBannerProps {
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({
|
||||
onReload,
|
||||
onDismiss,
|
||||
}: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner" role="alert" aria-live="assertive">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">忽略</button>
|
||||
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
|
||||
重新加载
|
||||
</button>
|
||||
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
index,
|
||||
isActive,
|
||||
onNavigate
|
||||
onNavigate,
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
@@ -39,7 +39,7 @@ const OutlineItem = memo(function OutlineItem({
|
||||
export const OutlinePanel = memo(function OutlinePanel({
|
||||
headings,
|
||||
onNavigate,
|
||||
activeHeadingIndex
|
||||
activeHeadingIndex,
|
||||
}: OutlinePanelProps) {
|
||||
if (headings.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -58,10 +58,11 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
|
||||
const activeHeadingIndex = useActiveHeading(
|
||||
viewMode === 'preview' ? previewRef : { current: null },
|
||||
headings
|
||||
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
|
||||
@@ -69,38 +70,27 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
try {
|
||||
// 获取当前内容,查找标题文本在源代码中的位置
|
||||
const content = editor.getValue()
|
||||
const headingPattern = new RegExp(
|
||||
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
|
||||
'm'
|
||||
)
|
||||
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)
|
||||
// 按行号导航 — 官方 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 handleFileClick = useCallback(
|
||||
async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
return
|
||||
}
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
@@ -112,7 +102,9 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab, setLoading])
|
||||
},
|
||||
[tabs, switchToTab, createTab, setLoading],
|
||||
)
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
@@ -138,9 +130,12 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
<div className="independent-files-header" id="independent-files-label">
|
||||
已打开的文件
|
||||
</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
@@ -148,22 +143,46 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
switchToTab(tab.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-icon">
|
||||
<File size={14} />
|
||||
</span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
{tab.isModified && (
|
||||
<span className="independent-modified-dot" aria-label="已修改">
|
||||
{' '}
|
||||
•
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">
|
||||
文件夹目录树
|
||||
</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
nodes={[
|
||||
{
|
||||
name: rootPath.split(/[/\\]/).pop() || rootPath,
|
||||
path: rootPath,
|
||||
type: 'dir' as const,
|
||||
children: tree,
|
||||
},
|
||||
]}
|
||||
depth={0}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeFilePath={activeFilePath}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { MeToast } from '../../lib/toast'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
@@ -27,7 +26,6 @@ export const TabBar = React.memo(function TabBar() {
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const dragTabIdRef = useRef<string | null>(null)
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
@@ -41,11 +39,11 @@ export const TabBar = React.memo(function TabBar() {
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
tabList.scrollLeft -= listRect.left - tabRect.left + 20
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
tabList.scrollLeft += tabRect.right - listRect.right + 20
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -76,21 +74,23 @@ export const TabBar = React.memo(function TabBar() {
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
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({
|
||||
const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
},
|
||||
[tabs, closeTab],
|
||||
)
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
@@ -114,67 +114,65 @@ export const TabBar = React.memo(function TabBar() {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
}, [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 confirm({
|
||||
const names = otherModified
|
||||
.map(t => (t.filePath ? getFileName(t.filePath) : '未命名'))
|
||||
.join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
}, [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 confirm({
|
||||
const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
}, [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 confirm({
|
||||
const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
}, [tabs, menu.tabId, closeTabsToRight])
|
||||
|
||||
// D1: 拖拽排序事件处理
|
||||
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
@@ -198,7 +196,8 @@ export const TabBar = React.memo(function TabBar() {
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(null)
|
||||
const fromId = dragTabIdRef.current
|
||||
@@ -208,7 +207,9 @@ export const TabBar = React.memo(function TabBar() {
|
||||
dragTabIdRef.current = null
|
||||
// 清理 dragging 类
|
||||
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
||||
}, [moveTab])
|
||||
},
|
||||
[moveTab],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
@@ -216,7 +217,9 @@ export const TabBar = React.memo(function TabBar() {
|
||||
dragTabIdRef.current = null
|
||||
}, [])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const hasRightTabs =
|
||||
menu.visible &&
|
||||
(() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
@@ -237,11 +240,11 @@ export const TabBar = React.memo(function TabBar() {
|
||||
data-tab-id={tab.id}
|
||||
draggable
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
onDragStart={(e) => handleDragStart(e, tab.id)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onContextMenu={e => handleContextMenu(e, tab.id)}
|
||||
onDragStart={e => handleDragStart(e, tab.id)}
|
||||
onDragOver={e => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDrop={e => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<span className="tab-name">
|
||||
@@ -249,7 +252,7 @@ export const TabBar = React.memo(function TabBar() {
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
onClick={e => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
@@ -273,8 +276,8 @@ export const TabBar = React.memo(function TabBar() {
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
@@ -296,7 +299,6 @@ export const TabBar = React.memo(function TabBar() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||
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 {
|
||||
@@ -20,23 +24,83 @@ const THEME_LABELS: Record<ThemeMode, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
|
||||
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、数据备份、关于。
|
||||
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
||||
*/
|
||||
export const Toolbar = React.memo(function Toolbar({
|
||||
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
|
||||
isAutoSaving, autoSaveEnabled, onToggleAutoSave
|
||||
onOpen,
|
||||
onSave,
|
||||
themeMode,
|
||||
onCycleTheme,
|
||||
onShowAbout,
|
||||
isAutoSaving,
|
||||
autoSaveEnabled,
|
||||
onToggleAutoSave,
|
||||
}: ToolbarProps) {
|
||||
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
|
||||
const zenMode = useEditorStore(s => s.zenMode)
|
||||
|
||||
// v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
|
||||
const handleToggleZen = useCallback(() => {
|
||||
const editor = getMetonaEditor()
|
||||
if (!editor) return
|
||||
editor.toggleZen()
|
||||
}, [])
|
||||
|
||||
// v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const data = await backupRepository.exportAll()
|
||||
if (!data) return
|
||||
const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
|
||||
if (result.success) {
|
||||
showToast('备份已导出', 'success')
|
||||
} else if (!result.canceled) {
|
||||
showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable)
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.importData()
|
||||
if (!result.success) return
|
||||
if (result.canceled || !result.content) return
|
||||
try {
|
||||
const data = JSON.parse(result.content) as Record<string, Record<string, unknown>[]>
|
||||
const ok = await backupRepository.importAll(data)
|
||||
if (ok) {
|
||||
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
|
||||
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
|
||||
showToast('备份已恢复', 'success')
|
||||
setTimeout(() => window.location.reload(), 800)
|
||||
} else {
|
||||
showToast('恢复备份失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('解析备份文件失败', error)
|
||||
showToast('备份文件格式无效', 'error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onOpen}
|
||||
title="打开文件 (Ctrl+O)"
|
||||
aria-label="打开文件"
|
||||
>
|
||||
<FolderOpen size={18} />
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onSave}
|
||||
title="保存文件 (Ctrl+S)"
|
||||
aria-label="保存文件"
|
||||
>
|
||||
<Save size={18} />
|
||||
<span>保存</span>
|
||||
</button>
|
||||
@@ -44,13 +108,46 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
<button
|
||||
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||
onClick={onToggleAutoSave}
|
||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||
title={
|
||||
isAutoSaving
|
||||
? '正在自动保存...'
|
||||
: autoSaveEnabled
|
||||
? '自动保存已开启 — 点击关闭'
|
||||
: '自动保存已关闭 — 点击开启'
|
||||
}
|
||||
aria-label={
|
||||
isAutoSaving ? '正在自动保存' : autoSaveEnabled ? '关闭自动保存' : '开启自动保存'
|
||||
}
|
||||
>
|
||||
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
||||
<span>{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动' : '手动'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<button
|
||||
className={`toolbar-btn${zenMode ? ' active' : ''}`}
|
||||
onClick={handleToggleZen}
|
||||
title={zenMode ? '退出专注模式' : '专注模式 — 隐藏编辑器工具栏'}
|
||||
aria-label={zenMode ? '退出专注模式' : '进入专注模式'}
|
||||
aria-pressed={zenMode}
|
||||
>
|
||||
<span>🧘 {zenMode ? '专注中' : '专注'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleExport}
|
||||
title="导出数据备份"
|
||||
aria-label="导出数据备份"
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleImport}
|
||||
title="导入数据备份"
|
||||
aria-label="导入数据备份"
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onCycleTheme}
|
||||
@@ -60,7 +157,12 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onShowAbout}
|
||||
title="关于"
|
||||
aria-label="关于 MarkLite"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<string[]>([])
|
||||
|
||||
// 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)}`}
|
||||
>
|
||||
<span className="recent-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { recentFilesRepository } from '../recentFilesRepository'
|
||||
|
||||
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
|
||||
it('should add and read recent files', async () => {
|
||||
await recentFilesRepository.add('/test/a.md')
|
||||
const files = await recentFilesRepository.getAll(10)
|
||||
expect(files).toContain('/test/a.md')
|
||||
})
|
||||
|
||||
it('should update lastOpened on re-add', async () => {
|
||||
await recentFilesRepository.add('/test/b.md')
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
await recentFilesRepository.add('/test/b.md')
|
||||
const files = await recentFilesRepository.getAll(10)
|
||||
expect(files[0]).toBe('/test/b.md')
|
||||
})
|
||||
|
||||
it('should enforce 50 item limit', async () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
await recentFilesRepository.add(`/test/f${String(i).padStart(2, '0')}.md`)
|
||||
}
|
||||
const files = await recentFilesRepository.getAll(60)
|
||||
expect(files.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('should remove a file', async () => {
|
||||
await recentFilesRepository.add('/test/remove-me.md')
|
||||
await recentFilesRepository.remove('/test/remove-me.md')
|
||||
const files = await recentFilesRepository.getAll(60)
|
||||
expect(files).not.toContain('/test/remove-me.md')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getDb } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/** v0.6.0: 备份数据格式 — { 表名: 行[] }(与 db.exportAll() 一致) */
|
||||
export type BackupData = Record<string, Record<string, unknown>[]>
|
||||
|
||||
const BACKUP_TABLES = ['tabSnapshots', 'settings', 'recentFiles', 'activeTab']
|
||||
|
||||
/**
|
||||
* v0.6.0: 数据备份/恢复 — 基于 sqlark 的 exportAll / importTable。
|
||||
*/
|
||||
export const backupRepository = {
|
||||
async exportAll(): Promise<BackupData | null> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
return (await db.exportAll()) as BackupData
|
||||
} catch (error) {
|
||||
logError('导出备份失败', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async importAll(data: BackupData): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
// 先清空现有四表,再按表导入(清空走事务保证原子性)
|
||||
await db.transaction(async trx => {
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
await trx.table(tableName).clear()
|
||||
}
|
||||
})
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
const rows = data[tableName]
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
await db.importTable(tableName, rows)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logError('导入备份失败', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type RecentFile } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/**
|
||||
* v0.6.0: 本地写操作完成后 emit 表变更事件 —
|
||||
* sqlark 的 Table 写操作只通过 BroadcastChannel 通知其他标签页,
|
||||
* 本地订阅(subscribe)需要手动 emit,WelcomeScreen 借此自动刷新最近文件。
|
||||
*/
|
||||
async function emitChange(db: MetonaSqlark, type: string): Promise<void> {
|
||||
try {
|
||||
db.emit('recentFiles', { type })
|
||||
} catch {
|
||||
// 通知失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
export const recentFilesRepository = {
|
||||
async add(filePath: string): Promise<void> {
|
||||
try {
|
||||
@@ -17,8 +30,12 @@ export const recentFilesRepository = {
|
||||
const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
|
||||
if (all.length > 50) {
|
||||
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
|
||||
await tbl.delete().where({ filePath: { $in: toDelete } }).execute()
|
||||
await tbl
|
||||
.delete()
|
||||
.where({ filePath: { $in: toDelete } })
|
||||
.execute()
|
||||
}
|
||||
await emitChange(db, 'update')
|
||||
} catch (error) {
|
||||
logError('添加最近文件失败', error)
|
||||
}
|
||||
@@ -44,6 +61,7 @@ export const recentFilesRepository = {
|
||||
try {
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').delete().where({ filePath }).execute()
|
||||
await emitChange(db, 'delete')
|
||||
} catch (error) {
|
||||
logError('删除最近文件失败', error)
|
||||
}
|
||||
@@ -53,8 +71,9 @@ export const recentFilesRepository = {
|
||||
try {
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').clear()
|
||||
await emitChange(db, 'delete')
|
||||
} catch (error) {
|
||||
logError('清空最近文件失败', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+144
-16
@@ -1,5 +1,6 @@
|
||||
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { MeToast } from '../lib/toast'
|
||||
|
||||
// 注意: 使用 type 别名而非 interface —
|
||||
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>,
|
||||
@@ -36,10 +37,17 @@ export type RecentFile = {
|
||||
}
|
||||
|
||||
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
|
||||
// v0.5.0: 存储引擎选用 AriaEngine(自研 LSM-Tree + WAL + MVCC,对标 SQLite)
|
||||
// v0.6.1: 存储引擎回归 AriaEngine(LSM-Tree + WAL + MVCC,功能最强)。
|
||||
// sqlark 0.4.4 修复 SSTable 大 value 编码缺陷(v2 格式 "SSTC":
|
||||
// u32 长度字段 + UTF-8 字节精确估算 + 超大条目独立成块,兼容旧 v1 格式)。
|
||||
const DB_NAME = 'MarkLiteV2'
|
||||
const DB_VERSION = 1
|
||||
|
||||
/**
|
||||
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
|
||||
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
|
||||
* 使用标准配置 version: 1 即可,不再需要手动解析 IDB 版本号。
|
||||
* 未来 schema 变更: 在 createDatabase 的幂等建表段追加增量变更逻辑。
|
||||
*/
|
||||
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
filePath: { type: 'string' },
|
||||
@@ -70,33 +78,153 @@ const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.0: MetonaSqlark 初始化 — 懒加载单例。
|
||||
* create() 为异步,无法像 Dexie 那样模块顶层同步实例化;
|
||||
* 首次调用时创建,之后复用同一 Promise。
|
||||
* v0.6.0: 数据损坏自愈 —
|
||||
* 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。
|
||||
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
|
||||
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
|
||||
*
|
||||
* 注意:自愈(删库/重载)只允许在页面生命周期内首次初始化时触发;
|
||||
* 运行中(如应用关闭流程的 flushSaveToDB)getDb 失败时直接抛错,
|
||||
* 绝不删库/重载,避免关闭流程被数据库操作阻塞。
|
||||
*/
|
||||
let dbPromise: Promise<MetonaSqlark> | null = null
|
||||
let initFailedNotified = false
|
||||
let healTriggered = false
|
||||
// v0.6.1: 关闭流程标记 — closeDatabase 后禁止 getDb 重新打开库,
|
||||
// 避免 forceClose 前其他组件重新建立连接(未关闭即被进程终止)
|
||||
let isClosing = false
|
||||
// v0.6.1: 初始化串行链 — 防止 dbPromise 失败重置后多个 getDb 并发触发 createDatabase
|
||||
let initChain: Promise<unknown> = Promise.resolve()
|
||||
|
||||
async function defineTablesIfNeeded(db: MetonaSqlark): Promise<void> {
|
||||
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
|
||||
// AriaEngine 的 IndexedDBBackend 内部库名 = `aria-${name}`(sqlark 源码约定)
|
||||
const DB_STORAGE_NAME = `aria-${DB_NAME}`
|
||||
|
||||
/** 删除损坏数据库(无活动连接时立即成功) */
|
||||
function deleteStorageDatabase(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => resolve()
|
||||
req.onblocked = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/** 自愈入口:存在重置标记时删除损坏库(保留标记,create 成功后才清除) */
|
||||
async function resetCorruptDatabaseIfNeeded(): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') return
|
||||
await deleteStorageDatabase()
|
||||
try {
|
||||
MeToast?.info('数据库已重置(原数据损坏,无法恢复)')
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
} catch {
|
||||
/* 重置失败不阻塞启动 */
|
||||
}
|
||||
}
|
||||
|
||||
async function createDatabase(): Promise<MetonaSqlark> {
|
||||
const db = await create({
|
||||
name: DB_NAME,
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离(sqlark 0.4.4 修复大 value 编码)
|
||||
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
|
||||
onError: (err: Error) => logError('数据库错误', err),
|
||||
})
|
||||
|
||||
// 幂等建表(getTableNames 基于持久化 schema,重启后不会重复建表)
|
||||
const tables = await db.getTableNames()
|
||||
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
|
||||
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
|
||||
if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
|
||||
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
async function initDb(): Promise<MetonaSqlark> {
|
||||
// 页面生命周期内仅首次初始化走自愈流程(删损坏库 + 失败重载)
|
||||
if (!healTriggered) {
|
||||
healTriggered = true
|
||||
await resetCorruptDatabaseIfNeeded()
|
||||
try {
|
||||
const db = await createDatabase()
|
||||
// 创建成功:清除重置标记(自愈完成)
|
||||
try {
|
||||
localStorage.removeItem(RESET_PENDING_KEY)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
return db
|
||||
} catch (err) {
|
||||
logError('数据库打开失败', err)
|
||||
// 启动早期失败:标记 + 重载页面(干净环境删除损坏库重建)— 仅此一次
|
||||
try {
|
||||
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
|
||||
localStorage.setItem(RESET_PENDING_KEY, '1')
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// 运行中(自愈已尝试过):不再删库/重载,直接尝试创建,失败抛给调用方
|
||||
return createDatabase()
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.1: 关闭数据库 — 应用退出前调用,确保 WAL/SSTable 干净落盘,
|
||||
* 避免渲染进程被销毁时 flush 中断留下半写文件导致下次启动损坏。
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
isClosing = true
|
||||
const current = dbPromise
|
||||
dbPromise = null
|
||||
if (!current) return
|
||||
try {
|
||||
const db = await current.catch(() => null)
|
||||
if (db) await db.close()
|
||||
} catch (err) {
|
||||
logError('关闭数据库失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Promise<MetonaSqlark> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = (async () => {
|
||||
const db = await create({
|
||||
name: DB_NAME,
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||
version: DB_VERSION,
|
||||
onError: (err: Error) => logError('数据库错误', err),
|
||||
if (isClosing) {
|
||||
// 关闭流程中禁止重开(返回 rejected Promise,调用方 catch 吞掉)
|
||||
return Promise.reject(new Error('数据库正在关闭'))
|
||||
}
|
||||
// 串行化初始化:失败后的重试排队执行,避免并发 createDatabase
|
||||
initChain = initChain.catch(() => {
|
||||
/* 前序失败不影响后续 */
|
||||
})
|
||||
dbPromise = initChain
|
||||
.then(() => initDb())
|
||||
.catch(err => {
|
||||
// v0.6.1: 初始化失败不再静默 — 记录日志并提示用户(否则所有数据持久化静默失效)
|
||||
logError('数据库初始化失败', err)
|
||||
if (!initFailedNotified) {
|
||||
initFailedNotified = true
|
||||
try {
|
||||
const code = (err as { code?: string })?.code ?? 'UNKNOWN'
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
// ARIA_OPEN_ERROR 等包装错误的具体原因在 details 里(原始异常)
|
||||
const details = (err as { details?: { message?: string } })?.details?.message
|
||||
const detailText = details ? ` | 详情: ${details}` : ''
|
||||
setTimeout(() => {
|
||||
MeToast?.error(`数据库初始化失败(${code}):${msg}${detailText}`)
|
||||
}, 0)
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
throw err
|
||||
})
|
||||
await defineTablesIfNeeded(db)
|
||||
return db
|
||||
})()
|
||||
// 初始化失败时允许下次重试
|
||||
dbPromise.catch(() => {
|
||||
dbPromise = null
|
||||
|
||||
@@ -7,18 +7,14 @@ export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('settings')
|
||||
.select()
|
||||
.where({ id: 'default' })
|
||||
.execute()
|
||||
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
|
||||
const record = rows[0] as SettingsRecord | undefined
|
||||
if (record) {
|
||||
return {
|
||||
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
||||
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
||||
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -37,12 +33,15 @@ export const settingsRepository = {
|
||||
const tbl = db.table('settings') as Table<SettingsRecord>
|
||||
const rows = await tbl.select().where({ id: 'default' }).execute()
|
||||
if (rows.length > 0) {
|
||||
await tbl.update({ ...merged }).where({ id: 'default' }).execute()
|
||||
await tbl
|
||||
.update({ ...merged })
|
||||
.where({ id: 'default' })
|
||||
.execute()
|
||||
} else {
|
||||
await tbl.insert(merged)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存设置失败', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const tabRepository = {
|
||||
try {
|
||||
const db = await getDb()
|
||||
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
|
||||
await db.transaction(async (trx) => {
|
||||
await db.transaction(async trx => {
|
||||
await trx.table('tabSnapshots').clear()
|
||||
if (tabs.length > 0) {
|
||||
await trx.table('tabSnapshots').insertMany(tabs)
|
||||
@@ -21,11 +21,7 @@ export const tabRepository = {
|
||||
async loadAll(): Promise<TabSnapshot[]> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('tabSnapshots')
|
||||
.select()
|
||||
.orderBy('updatedAt', 'asc')
|
||||
.execute()
|
||||
const rows = await db.table('tabSnapshots').select().orderBy('updatedAt', 'asc').execute()
|
||||
return rows as TabSnapshot[]
|
||||
} catch (error) {
|
||||
logError('加载标签快照失败', error)
|
||||
@@ -60,11 +56,7 @@ export const tabRepository = {
|
||||
async loadActiveTabId(): Promise<string | null> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const rows = await db
|
||||
.table('activeTab')
|
||||
.select()
|
||||
.where({ id: 'current' })
|
||||
.execute()
|
||||
const rows = await db.table('activeTab').select().where({ id: 'current' }).execute()
|
||||
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
|
||||
} catch (error) {
|
||||
logError('加载活动标签ID失败', error)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeDocStats } from '../useDocStats'
|
||||
|
||||
describe('computeDocStats', () => {
|
||||
it('should return zeros for undefined content', () => {
|
||||
expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should return zeros for null content', () => {
|
||||
expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should return zeros for empty string', () => {
|
||||
expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should count single word', () => {
|
||||
const result = computeDocStats('hello')
|
||||
expect(result.words).toBe(1)
|
||||
expect(result.chars).toBe(5)
|
||||
expect(result.charsNoSpace).toBe(5)
|
||||
expect(result.lines).toBe(1)
|
||||
})
|
||||
|
||||
it('should count multiple words', () => {
|
||||
const result = computeDocStats('hello world')
|
||||
expect(result.words).toBe(2)
|
||||
expect(result.chars).toBe(11) // 'hello world' = 11 chars
|
||||
expect(result.charsNoSpace).toBe(10) // without the space
|
||||
})
|
||||
|
||||
it('should count characters without spaces', () => {
|
||||
const result = computeDocStats('a b c')
|
||||
expect(result.chars).toBe(5)
|
||||
expect(result.charsNoSpace).toBe(3)
|
||||
})
|
||||
|
||||
it('should count lines', () => {
|
||||
const result = computeDocStats('line1\nline2\nline3')
|
||||
expect(result.lines).toBe(3)
|
||||
expect(result.words).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle Windows line endings', () => {
|
||||
const result = computeDocStats('line1\r\nline2\r\nline3')
|
||||
expect(result.lines).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle trailing newline', () => {
|
||||
const result = computeDocStats('hello\n')
|
||||
expect(result.lines).toBe(2)
|
||||
expect(result.words).toBe(1)
|
||||
})
|
||||
|
||||
it('should count markdown content correctly', () => {
|
||||
const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
|
||||
const result = computeDocStats(md)
|
||||
expect(result.words).toBe(17)
|
||||
expect(result.lines).toBe(7)
|
||||
expect(result.chars).toBe(md.length)
|
||||
})
|
||||
|
||||
it('should handle whitespace-only content', () => {
|
||||
const result = computeDocStats(' \n \n ')
|
||||
expect(result.words).toBe(0)
|
||||
expect(result.chars).toBe(9)
|
||||
expect(result.charsNoSpace).toBe(0)
|
||||
expect(result.lines).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle content with non-ASCII characters', () => {
|
||||
const result = computeDocStats('中文测试 日本語 한국어')
|
||||
expect(result.words).toBe(3)
|
||||
expect(result.chars).toBe(12)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
*/
|
||||
export function useActiveHeading(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
headings: { level: number; text: string }[]
|
||||
headings: { level: number; text: string }[],
|
||||
): number | null {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useActiveHeading(
|
||||
|
||||
// 收集容器内所有 h1-h6 元素的 offsetTop
|
||||
const headingElements = Array.from(
|
||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6'),
|
||||
) as HTMLElement[]
|
||||
|
||||
if (headingElements.length === 0) {
|
||||
@@ -48,7 +48,7 @@ export function useActiveHeading(
|
||||
// 找到 headings 中匹配的索引
|
||||
const text = el.textContent?.trim() ?? ''
|
||||
const matchIdx = headings.findIndex(
|
||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level),
|
||||
)
|
||||
if (matchIdx >= 0) bestIndex = matchIdx
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ export function toggleAutoSaveExternal(): void {
|
||||
* Captures the tabId at debounce start so the timeout always saves the
|
||||
* correct tab even if the user switches tabs during the debounce window.
|
||||
*/
|
||||
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
|
||||
export function useAutoSave(): {
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
toggleAutoSave: () => void
|
||||
} {
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false)
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -50,7 +54,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
||||
mountedRef.current = true
|
||||
|
||||
// Subscribe to Zustand store — fires on every state change
|
||||
const unsub = useTabStore.subscribe((state) => {
|
||||
const unsub = useTabStore.subscribe(state => {
|
||||
if (!enabledRef.current) return
|
||||
|
||||
const tab = state.getActiveTab()
|
||||
@@ -79,7 +83,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tabToSave.filePath,
|
||||
content: tabToSave.content
|
||||
content: tabToSave.content,
|
||||
})
|
||||
if (result.success && mountedRef.current) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-01: Promise-based 确认对话框 hook
|
||||
* 替代原生 confirm(),返回 Promise<boolean>
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
resolve: null
|
||||
})
|
||||
|
||||
// 使用 ref 确保回调中能拿到最新的 resolve
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
setState({
|
||||
open: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel,
|
||||
cancelLabel: options.cancelLabel,
|
||||
variant: options.variant,
|
||||
resolve
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolveRef.current?.(true)
|
||||
setState(prev => ({ ...prev, open: false, resolve: null }))
|
||||
resolveRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolveRef.current?.(false)
|
||||
setState(prev => ({ ...prev, open: false, resolve: null }))
|
||||
resolveRef.current = null
|
||||
}, [])
|
||||
|
||||
return {
|
||||
confirm,
|
||||
confirmDialogProps: {
|
||||
open: state.open,
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
confirmLabel: state.confirmLabel,
|
||||
cancelLabel: state.cancelLabel,
|
||||
variant: state.variant,
|
||||
onConfirm: handleConfirm,
|
||||
onCancel: handleCancel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export interface DocStats {
|
||||
/** 单词数(按空白字符分割) */
|
||||
words: number
|
||||
/** 总字符数(含空白字符) */
|
||||
chars: number
|
||||
/** 字符数(不含空白字符) */
|
||||
charsNoSpace: number
|
||||
/** 行数 */
|
||||
lines: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文档统计信息:单词数、字符数(含/不含空格)、行数。
|
||||
* 对空文档或 undefined 返回全零值。
|
||||
*/
|
||||
export function computeDocStats(content: string | undefined | null): DocStats {
|
||||
if (!content) {
|
||||
return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 }
|
||||
}
|
||||
|
||||
const chars = content.length
|
||||
const charsNoSpace = content.replace(/\s/g, '').length
|
||||
const words = content.trim()
|
||||
? content.trim().split(/\s+/).length
|
||||
: 0
|
||||
const lines = content === '' ? 0 : content.split(/\r?\n/).length
|
||||
|
||||
return { words, chars, charsNoSpace, lines }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook:根据文档内容实时计算统计信息。
|
||||
* 使用 useMemo 避免不必要的重新计算。
|
||||
*/
|
||||
export function useDocStats(content: string | undefined | null): DocStats {
|
||||
return useMemo(() => computeDocStats(content), [content])
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { showToast } from '../lib/toast'
|
||||
export function useDragDrop() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -51,7 +52,9 @@ export function useDragDrop() {
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
}, [createTab])
|
||||
},
|
||||
[createTab],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: DragEvent) => {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { MeToast, showToast } from '../lib/toast'
|
||||
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
|
||||
* v0.6.0: loading 改用 MeToast.loading 链式转换,保存结果用 MeToast.promise 提示
|
||||
*/
|
||||
export function useFileOperations() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
@@ -19,18 +20,22 @@ export function useFileOperations() {
|
||||
const savingGate = useRef(false)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
const loading = MeToast.loading('打开文件...')
|
||||
try {
|
||||
setLoading('file-open', true)
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
loading.success('文件已打开')
|
||||
} else {
|
||||
loading.dismiss()
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
showToast('打开文件失败', 'error')
|
||||
loading.error('打开文件失败')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
@@ -43,20 +48,31 @@ export function useFileOperations() {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
// v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
|
||||
const result = await MeToast.promise(
|
||||
window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
content: tab.content,
|
||||
}),
|
||||
{
|
||||
loading: '保存中...',
|
||||
success: '已保存',
|
||||
error: '保存失败',
|
||||
},
|
||||
)
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
} else {
|
||||
// IPC 返回 success:false 不 reject,promise 的 error 文案不会触发,需显式提示
|
||||
showToast('保存失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
} finally {
|
||||
// 300ms 后释放锁,允许下次保存
|
||||
setTimeout(() => { savingGate.current = false }, 300)
|
||||
setTimeout(() => {
|
||||
savingGate.current = false
|
||||
}, 300)
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
@@ -64,18 +80,26 @@ export function useFileOperations() {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
const result = await MeToast.promise(
|
||||
window.electronAPI.saveFileAs({ content: tab.content }),
|
||||
{
|
||||
loading: '另存为...',
|
||||
success: '已保存',
|
||||
error: '另存为失败',
|
||||
},
|
||||
)
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
} else if (!result.canceled) {
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||
const handleOpenRecent = useCallback(
|
||||
async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
@@ -88,7 +112,9 @@ export function useFileOperations() {
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
},
|
||||
[createTab, setLoading],
|
||||
)
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
|
||||
@@ -7,17 +7,38 @@ import { useTabStore } from '../stores/tabStore'
|
||||
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
||||
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
||||
*/
|
||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||
export function useKeyboard(
|
||||
handleOpenFile: () => void,
|
||||
handleSave: () => void,
|
||||
handleSaveAs: () => void,
|
||||
) {
|
||||
const handleKeydown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||
if (isCtrl && e.key === 'o') {
|
||||
e.preventDefault()
|
||||
handleOpenFile()
|
||||
return
|
||||
}
|
||||
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault()
|
||||
handleSaveAs()
|
||||
return
|
||||
}
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||
if (isCtrl && e.key === 't') {
|
||||
e.preventDefault()
|
||||
tabState.createTab(null, '')
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
||||
@@ -37,14 +58,14 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
|
||||
}
|
||||
}
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = e.shiftKey
|
||||
? (idx - 1 + tabs.length) % tabs.length
|
||||
: (idx + 1) % tabs.length
|
||||
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length
|
||||
tabState.switchToTab(tabs[next].id)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [handleOpenFile, handleSave, handleSaveAs])
|
||||
},
|
||||
[handleOpenFile, handleSave, handleSaveAs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
@@ -18,8 +18,9 @@ export function useSettingsInit() {
|
||||
if (isInitialized.current) return
|
||||
isInitialized.current = true
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
settingsRepository
|
||||
.load()
|
||||
.then(settings => {
|
||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
@@ -34,7 +35,7 @@ export function useSettingsInit() {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
_loaded: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { ThemeMode } from '../types/settings'
|
||||
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
||||
function hexWithAlpha(hex: string, alpha: number): string {
|
||||
if (!hex || !hex.startsWith('#')) return hex
|
||||
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
|
||||
const a = Math.round(alpha * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
||||
}
|
||||
|
||||
@@ -45,13 +47,17 @@ function syncAppColorsToEditor(): void {
|
||||
set('--search-border', vars['--md-border'])
|
||||
// 阴影根据主题适配
|
||||
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
|
||||
root.style.setProperty('--shadow', isDark
|
||||
? '0 1px 3px rgba(0,0,0,0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.08)')
|
||||
root.style.setProperty('--shadow-lg', isDark
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 4px 12px rgba(0,0,0,0.1)')
|
||||
} catch { /* 容错 */ }
|
||||
root.style.setProperty(
|
||||
'--shadow',
|
||||
isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)',
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--shadow-lg',
|
||||
isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.1)',
|
||||
)
|
||||
} catch {
|
||||
/* 容错 */
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>,
|
||||
// D5: 关闭前回调(flush 待保存数据)
|
||||
onBeforeForceClose?: () => Promise<void>
|
||||
onBeforeForceClose?: () => Promise<void>,
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderMarkdown } from '../markdown'
|
||||
import { renderMarkdown, renderMarkdownSync } from '../markdown'
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
describe('renderMarkdown (MetonaEditor 内置解析器)', () => {
|
||||
it('should render a simple heading', async () => {
|
||||
const result = await renderMarkdown('# Hello World')
|
||||
expect(result).toContain('<h1')
|
||||
@@ -26,7 +26,7 @@ describe('renderMarkdown', () => {
|
||||
expect(result).toContain('code')
|
||||
})
|
||||
|
||||
it('should render code blocks with syntax highlighting', async () => {
|
||||
it('should render code blocks', async () => {
|
||||
const md = '```javascript\nconst x = 1;\n```'
|
||||
const result = await renderMarkdown(md)
|
||||
expect(result).toContain('<pre')
|
||||
@@ -75,32 +75,106 @@ describe('renderMarkdown', () => {
|
||||
expect(result).toContain('deleted')
|
||||
})
|
||||
|
||||
it('should render task list', async () => {
|
||||
const result = await renderMarkdown('- [x] done\n- [ ] todo')
|
||||
expect(result).toContain('me-task-item')
|
||||
expect(result).toContain('checked')
|
||||
})
|
||||
|
||||
it('should render mermaid as .me-mermaid container', async () => {
|
||||
const md = '```mermaid\ngraph TD\nA-->B\n```'
|
||||
const result = await renderMarkdown(md)
|
||||
expect(result).toContain('me-mermaid')
|
||||
expect(result).toContain('class="mermaid"')
|
||||
expect(result).toContain('graph TD')
|
||||
})
|
||||
|
||||
it('should handle empty content', async () => {
|
||||
const result = await renderMarkdown('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return error HTML on rendering failure with invalid input', async () => {
|
||||
// unified should still handle gracefully, but verify error path works
|
||||
const result = await renderMarkdown('normal text')
|
||||
expect(result).not.toContain('渲染错误')
|
||||
it('should handle null and undefined filePath', async () => {
|
||||
expect(await renderMarkdown('# No File', null)).toContain('No File')
|
||||
expect(await renderMarkdown('# No File')).toContain('No File')
|
||||
})
|
||||
|
||||
it('should cache processors for same filePath', async () => {
|
||||
// Call twice with same filePath to test caching
|
||||
const result1 = await renderMarkdown('# Test', '/test/file.md')
|
||||
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
|
||||
expect(result1).toContain('<h1')
|
||||
expect(result2).toContain('Test 2')
|
||||
it('should be sync-callable via renderMarkdownSync', () => {
|
||||
const result = renderMarkdownSync('# Sync')
|
||||
expect(result).toContain('<h1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null filePath', async () => {
|
||||
const result = await renderMarkdown('# No File', null)
|
||||
expect(result).toContain('No File')
|
||||
describe('fixImageSrcs — 相对路径图片修复', () => {
|
||||
const FILE = '/home/user/docs/note.md'
|
||||
|
||||
it('should fix relative image paths to file://', async () => {
|
||||
const result = await renderMarkdown('', FILE)
|
||||
expect(result).toContain('src="file:///home/user/docs/pic.png"')
|
||||
})
|
||||
|
||||
it('should handle undefined filePath', async () => {
|
||||
const result = await renderMarkdown('# No File')
|
||||
expect(result).toContain('No File')
|
||||
it('should fix nested relative paths', async () => {
|
||||
const result = await renderMarkdown('', FILE)
|
||||
expect(result).toContain('src="file:///home/user/docs/assets/a.png"')
|
||||
})
|
||||
|
||||
it('should fix unix absolute paths', async () => {
|
||||
const result = await renderMarkdown('', FILE)
|
||||
expect(result).toContain('src="file:///abs/pic.png"')
|
||||
})
|
||||
|
||||
it('should not touch http/https/data:/file: URLs', async () => {
|
||||
const md = '  '
|
||||
const result = await renderMarkdown(md, FILE)
|
||||
expect(result).toContain('https://x.com/a.png')
|
||||
expect(result).toContain('data:image/png;base64,xx')
|
||||
expect(result).toContain('file:///c.png')
|
||||
})
|
||||
|
||||
it('should skip out-of-directory traversal (../ and ../../)', async () => {
|
||||
const md = ' '
|
||||
const result = await renderMarkdown(md, FILE)
|
||||
// 越界路径保持原样(不注入 file://)
|
||||
expect(result).not.toContain('file:')
|
||||
expect(result).toContain('../outside.png')
|
||||
expect(result).toContain('../../outside.png')
|
||||
})
|
||||
|
||||
it('should not treat sibling directory with same prefix as inside', async () => {
|
||||
// /home/user/docs-other/ 与 /home/user/docs/ 前缀相似但目录不同
|
||||
const result = await renderMarkdown('', FILE)
|
||||
expect(result).not.toContain('file:')
|
||||
expect(result).toContain('../docs-other/pic.png')
|
||||
})
|
||||
|
||||
it('should not touch images when filePath is null', async () => {
|
||||
const result = await renderMarkdown('')
|
||||
expect(result).toContain('./pic.png')
|
||||
expect(result).not.toContain('file:')
|
||||
})
|
||||
|
||||
it('should fix relative image paths from root-level file', async () => {
|
||||
const result = await renderMarkdown('', '/note.md')
|
||||
expect(result).toContain('src="file:///pic.png"')
|
||||
})
|
||||
|
||||
it('should escape special chars in fixed path', async () => {
|
||||
const result = await renderMarkdown('', FILE)
|
||||
expect(result).toContain('src="file:///home/user/docs/a&b.png"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('XSS 防护(内置 safeUrl)', () => {
|
||||
it('should not emit anchor for javascript: URLs', async () => {
|
||||
const result = await renderMarkdown('[x](javascript:alert(1))')
|
||||
// 内置解析器将危险 URL 转义原样输出(不生成 <a>),文本不构成可执行链接
|
||||
expect(result).not.toContain('<a')
|
||||
expect(result).not.toContain('href=')
|
||||
})
|
||||
|
||||
it('should not emit img for javascript: image src', async () => {
|
||||
const result = await renderMarkdown(')')
|
||||
expect(result).not.toContain('<img')
|
||||
expect(result).not.toContain('src=')
|
||||
})
|
||||
})
|
||||
|
||||
+58
-157
@@ -1,13 +1,9 @@
|
||||
import { unified, type Plugin } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Element, Root } from 'hast'
|
||||
import { parseMarkdown } from '@metona-team/metona-editor'
|
||||
|
||||
// v0.6.0: 渲染管线迁移至 MetonaEditor 内置解析器(parseMarkdown),
|
||||
// 移除 unified/remark/rehype 自研管线。
|
||||
// 内置解析器原生支持:GFM / 任务列表 / 脚注 / 数学公式 / 定义列表 / emoji /
|
||||
// mermaid(输出 .me-mermaid 容器)/ XSS 防护(escapeHTML + safeUrl)。
|
||||
|
||||
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
|
||||
@@ -30,152 +26,65 @@ function resolveRelativePath(base: string, rel: string): string {
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
if (!filePath) return
|
||||
/** 属性值转义(与内置解析器 escapeAttr 行为一致) */
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片路径修复 — HTML 后处理。
|
||||
*
|
||||
* v0.6.0: 内置解析器的 safeUrl 会过滤 file: 协议,且 sanitize 钩子不参与预览渲染,
|
||||
* 因此相对路径图片的修复必须在 parseMarkdown 之后、输出到 DOM 之前完成。
|
||||
* 内置解析器输出的 img 标签格式固定(src 属性在最前、属性值经 escapeAttr 转义),
|
||||
* 用正则精确匹配 src 属性即可安全替换。
|
||||
*
|
||||
* CQ-09: 保持与原 rehypeFixImages 一致的安全策略 —
|
||||
* 解析完整路径并检查是否越界到 markdown 文件所在目录之外。
|
||||
*/
|
||||
function fixImageSrcs(html: string, filePath: string | null): string {
|
||||
if (!filePath) return html
|
||||
|
||||
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||
|
||||
function visit(node: Element | Root): void {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'element' && child.tagName === 'img') {
|
||||
const src = child.properties?.src as string | undefined
|
||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||
return html.replace(/<img\b[^>]*\bsrc="([^"]*)"/g, (full, src) => {
|
||||
const raw = String(src).replace(/&/g, '&') // 先还原转义,避免二次转义
|
||||
if (!raw) return full
|
||||
if (
|
||||
raw.startsWith('http://') ||
|
||||
raw.startsWith('https://') ||
|
||||
raw.startsWith('data:') ||
|
||||
raw.startsWith('file:')
|
||||
) {
|
||||
return full
|
||||
}
|
||||
// 处理 Unix 风格绝对路径(以 / 开头)
|
||||
if (src.startsWith('/')) {
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + src
|
||||
if (raw.startsWith('/')) {
|
||||
return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||
const resolvedPath = resolveRelativePath(dir, src)
|
||||
// E1: 统一规范化后比较,防止 ../ 路径越界
|
||||
// 解析完整路径并检查是否越界
|
||||
// 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
|
||||
// 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
|
||||
const resolvedPath = resolveRelativePath(filePath, raw)
|
||||
const normalizedBase = normPath(dir)
|
||||
if (!normPath(resolvedPath).startsWith(normalizedBase)) return
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'element') {
|
||||
visit(child as Element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 rehype 插件:将 mermaid 代码块转为 .me-mermaid 容器
|
||||
// 在 rehypeHighlight 之后运行,此时 code 元素已有 language-mermaid 类名。
|
||||
// 输出结构需与 MetonaEditor 内置解析器一致,以便 mermaid.run() 统一查询。
|
||||
function rehypeMermaid(): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element, index, parent) => {
|
||||
if (node.tagName !== 'pre') return
|
||||
if (parent === null || parent === undefined || index === undefined) return
|
||||
|
||||
const code = node.children.find(
|
||||
(child): child is Element => child.type === 'element' && child.tagName === 'code'
|
||||
)
|
||||
if (!code) return
|
||||
|
||||
const className: string[] = (code.properties?.className as string[] | undefined) ?? []
|
||||
if (!className.some(c => c === 'language-mermaid' || c === 'mermaid')) return
|
||||
|
||||
// 提取代码文本内容
|
||||
const text = (code.children ?? [])
|
||||
.filter(c => c.type === 'text')
|
||||
.map(c => (c as { type: 'text'; value: string }).value)
|
||||
.join('')
|
||||
|
||||
// 替换为 mermaid 渲染容器(与 MetonaEditor 内置输出一致)
|
||||
const mermaidContainer: Element = {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: ['me-mermaid'] },
|
||||
children: [{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: ['mermaid'] },
|
||||
children: [{ type: 'text', value: text }]
|
||||
}]
|
||||
}
|
||||
|
||||
parent.children[index] = mermaidContainer
|
||||
// 用 base + '/' 前缀判断,避免 /home/docs-other/ 误判为在 /home/docs/ 内
|
||||
const boundary = normalizedBase === '' ? '/' : normalizedBase + '/'
|
||||
if (!normPath(resolvedPath).startsWith(boundary)) return full
|
||||
const fixed = 'file://' + resolvedPath
|
||||
return full.replace(`src="${src}"`, `src="${escapeAttr(fixed)}"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PF-01: Markdown处理器LRU缓存
|
||||
const MAX_CACHE_SIZE = 20
|
||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||
|
||||
function buildProcessor(filePath: string | null) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
|
||||
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
},
|
||||
protocols: {
|
||||
...(defaultSchema.protocols ?? {}),
|
||||
src: [
|
||||
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
|
||||
'file:',
|
||||
],
|
||||
},
|
||||
// H-05: 显式 strip 事件处理器属性,纵深防御
|
||||
strip: ['script', 'on*', 'javascript:'],
|
||||
})
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeMermaid())
|
||||
.use(rehypeStringify)
|
||||
}
|
||||
|
||||
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
|
||||
const key: string = filePath ?? '__null__'
|
||||
|
||||
if (processorCache.has(key)) {
|
||||
const cached = processorCache.get(key)!
|
||||
processorCache.delete(key)
|
||||
processorCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const processor = buildProcessor(filePath)
|
||||
|
||||
if (processorCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = processorCache.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
processorCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
processorCache.set(key, processor)
|
||||
return processor
|
||||
}
|
||||
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
/**
|
||||
* 渲染 Markdown — 使用 MetonaEditor 内置解析器。
|
||||
*
|
||||
* @param content Markdown 源码
|
||||
* @param filePath 文件路径(用于修复相对路径图片;null 时不做图片修复)
|
||||
*/
|
||||
export function renderMarkdownSync(content: string, filePath?: string | null): string {
|
||||
try {
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
const html = parseMarkdown(content)
|
||||
return fixImageSrcs(html, filePath ?? null)
|
||||
} catch (e) {
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
@@ -183,16 +92,8 @@ export async function renderMarkdown(content: string, filePath?: string | null):
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版本 — 供 MetonaEditor 的 render 钩子使用。
|
||||
* 所有 unified 插件均为同步转换器,可以在 render 钩子中同步调用。
|
||||
* 异步版本 — 内置解析器为同步实现,此函数仅保持兼容签名。
|
||||
*/
|
||||
export function renderMarkdownSync(content: string, filePath?: string | null): string {
|
||||
try {
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = processor.processSync(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
}
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
return renderMarkdownSync(content, filePath)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import MeToast from '@metona-team/metona-toast'
|
||||
import { logError } from './errorHandler'
|
||||
|
||||
/**
|
||||
* MeToast 全局配置
|
||||
@@ -7,6 +8,7 @@ import MeToast from '@metona-team/metona-toast'
|
||||
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||
*
|
||||
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
|
||||
* v0.6.0: 接入 dedupe 去重插件与 onError 全局错误回调。
|
||||
*/
|
||||
MeToast.configure({
|
||||
position: 'top-right',
|
||||
@@ -20,6 +22,8 @@ MeToast.configure({
|
||||
draggable: true,
|
||||
locale: 'zh-CN',
|
||||
width: 360,
|
||||
// v0.6.0: 钩子/定时器异常统一走应用错误日志
|
||||
onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
|
||||
})
|
||||
|
||||
// 安装内置插件
|
||||
@@ -27,6 +31,8 @@ MeToast.configure({
|
||||
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
|
||||
|
||||
/** 与原 showToast 保持兼容的类型子集 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
@@ -8,5 +8,5 @@ import './styles/markdown-body.css'
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -100,4 +100,17 @@ describe('editorStore', () => {
|
||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor live state (v0.6.0)', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({ zenMode: false })
|
||||
})
|
||||
|
||||
it('should set zen mode', () => {
|
||||
useEditorStore.getState().setZenMode(true)
|
||||
expect(useEditorStore.getState().zenMode).toBe(true)
|
||||
useEditorStore.getState().setZenMode(false)
|
||||
expect(useEditorStore.getState().zenMode).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@ interface AutoSaveStore extends AutoSaveState {
|
||||
setState: (partial: Partial<AutoSaveState>) => void
|
||||
}
|
||||
|
||||
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
|
||||
export const useAutoSaveStore = create<AutoSaveStore>(set => ({
|
||||
isAutoSaving: false,
|
||||
autoSaveEnabled: true,
|
||||
setState: (partial) => set(partial)
|
||||
setState: partial => set(partial),
|
||||
}))
|
||||
|
||||
@@ -23,6 +23,8 @@ interface EditorState {
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
// v0.6.0: Zen 模式状态(Toolbar 消费)
|
||||
zenMode: boolean
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
@@ -31,6 +33,7 @@ interface EditorState {
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
isLoading: (key: string) => boolean
|
||||
setZenMode: (zen: boolean) => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
@@ -38,6 +41,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
zenMode: false,
|
||||
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
||||
@@ -49,8 +53,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
return next
|
||||
},
|
||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||
setLoading: (key: string, loading: boolean) =>
|
||||
set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading },
|
||||
})),
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false,
|
||||
setZenMode: (zenMode: boolean) => set({ zenMode }),
|
||||
}))
|
||||
|
||||
@@ -18,7 +18,7 @@ interface SidebarState {
|
||||
setSidebarWidth: (width: number) => void
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
export const useSidebarStore = create<SidebarState>(set => ({
|
||||
isVisible: true,
|
||||
rootPath: null,
|
||||
tree: [],
|
||||
@@ -36,17 +36,15 @@ export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
set(state => ({
|
||||
expandedDirs: state.expandedDirs.includes(path)
|
||||
? state.expandedDirs.filter(p => p !== path)
|
||||
: [...state.expandedDirs, path]
|
||||
: [...state.expandedDirs, path],
|
||||
})),
|
||||
expandDirs: (paths: string[]) =>
|
||||
set(state => {
|
||||
const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
|
||||
return newDirs.length > 0
|
||||
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
|
||||
: state
|
||||
return newDirs.length > 0 ? { expandedDirs: [...state.expandedDirs, ...newDirs] } : state
|
||||
}),
|
||||
setSidebarWidth: (width: number) => {
|
||||
set({ sidebarWidth: width })
|
||||
settingsRepository.save({ sidebarWidth: width })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { Tab } from '../types/tab'
|
||||
import { tabRepository } from '../db/tabRepository'
|
||||
|
||||
// PF-08: 防抖工具函数
|
||||
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
|
||||
function debounce<F extends (...args: unknown[]) => void>(
|
||||
fn: F,
|
||||
delay: number,
|
||||
): F & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
@@ -38,13 +41,16 @@ interface TabState {
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
|
||||
updateTabScroll: (
|
||||
tabId: string,
|
||||
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
|
||||
) => void
|
||||
loadFromDB: () => Promise<void>
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
|
||||
// PF-08: 模块级防抖保存函数(500ms延迟)
|
||||
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
|
||||
let _debouncedSaveToDB: ((() => void) & { cancel: () => void }) | null = null
|
||||
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
@@ -61,7 +67,7 @@ function getActualSaveToDB(get: () => TabState) {
|
||||
scrollTop: t.scrollTop,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
@@ -83,7 +89,7 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
tabRepository.loadActiveTabId(),
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
@@ -93,9 +99,10 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
selectionEnd: s.selectionEnd,
|
||||
}))
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
const activeTabId =
|
||||
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
@@ -131,12 +138,12 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
selectionEnd: 0,
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
activeTabId: tab.id,
|
||||
}))
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
@@ -192,13 +199,14 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
const newTabs = state.tabs.slice(0, index + 1)
|
||||
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
const newActiveId =
|
||||
state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)),
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
@@ -237,15 +245,13 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
// A3: 内容未变时不变更对象引用(避免误标记 isModified)
|
||||
if (t.content === content) return t
|
||||
return { ...t, content, isModified: true }
|
||||
})
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, isModified: modified } : t)),
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -254,13 +260,14 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
|
||||
updateTabScroll: (
|
||||
tabId: string,
|
||||
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
|
||||
) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, ...scroll } : t)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -906,174 +906,6 @@ body {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ===== UX-01: ConfirmDialog ===== */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.confirm-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm-header.confirm-danger h3 {
|
||||
color: #d93025;
|
||||
}
|
||||
.confirm-header.confirm-warning h3 {
|
||||
color: #e37400;
|
||||
}
|
||||
:root.dark .confirm-header.confirm-warning h3 {
|
||||
color: #fdd663;
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
.confirm-body p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.confirm-btn-cancel:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.confirm-btn-danger {
|
||||
background: #d93025;
|
||||
border-color: #d93025;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-danger:hover {
|
||||
background: #b5271d;
|
||||
}
|
||||
|
||||
.confirm-btn-warning {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-warning:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.confirm-btn-info {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn-info:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.confirm-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===== UX-02: LoadingSpinner ===== */
|
||||
.loading-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner-svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner-label {
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:root.dark .loading-overlay {
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== UX-07: 通用可访问性增强 ===== */
|
||||
|
||||
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3,
|
||||
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
@@ -28,10 +32,19 @@
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.markdown-body h3 { font-size: 1.25em; }
|
||||
.markdown-body h4 { font-size: 1em; }
|
||||
.markdown-body h5 { font-size: 0.875em; }
|
||||
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); }
|
||||
.markdown-body h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
.markdown-body h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
.markdown-body h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.markdown-body h6 {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
@@ -44,8 +57,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body strong { font-weight: 600; }
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markdown-body strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
@@ -71,16 +88,23 @@
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote p:last-child { margin-bottom: 0; }
|
||||
.markdown-body blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body li { margin-top: 4px; }
|
||||
.markdown-body li + li { margin-top: 4px; }
|
||||
.markdown-body li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.markdown-body li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: var(--font-mono);
|
||||
@@ -91,7 +115,9 @@
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
:root.dark .markdown-body code { color: #f48fb1; }
|
||||
:root.dark .markdown-body code {
|
||||
color: #f48fb1;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin-top: 0;
|
||||
@@ -119,7 +145,8 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body table th, .markdown-body table td {
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
@@ -130,9 +157,11 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); }
|
||||
.markdown-body table tr:nth-child(even) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.markdown-body input[type="checkbox"] {
|
||||
.markdown-body input[type='checkbox'] {
|
||||
margin-right: 6px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
SaveAsPayload,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult
|
||||
ReadDirTreeResult,
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
@@ -24,6 +24,11 @@ export interface IpcInvokeMap {
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
'data:export': [
|
||||
string,
|
||||
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
|
||||
]
|
||||
'data:import': [void, { success: boolean; canceled?: boolean; content?: string; error?: string }]
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
@@ -44,6 +49,15 @@ export interface ElectronAPI {
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
exportData: (
|
||||
content: string,
|
||||
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
|
||||
importData: () => Promise<{
|
||||
success: boolean
|
||||
canceled?: boolean
|
||||
content?: string
|
||||
error?: string
|
||||
}>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||
onDirChanged: (callback: () => void) => Unsubscribe
|
||||
|
||||
@@ -12,5 +12,5 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
themeMode: 'light',
|
||||
viewMode: 'editor',
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
sidebarWidth: 240,
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
+11
-3
@@ -1,8 +1,16 @@
|
||||
// 共享常量 — 主进程和渲染进程共用
|
||||
export const APP_VERSION = 'v0.5.0'
|
||||
export const APP_VERSION = 'v0.6.1'
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||
export const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'dist',
|
||||
'out',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'__pycache__',
|
||||
'.DS_Store',
|
||||
])
|
||||
|
||||
@@ -16,11 +16,15 @@ export const IPC_CHANNELS = {
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
|
||||
// v0.6.0: 数据备份导出/导入(JSON)
|
||||
DATA_EXPORT: 'data:export',
|
||||
DATA_IMPORT: 'data:import',
|
||||
|
||||
// 主进程 → 渲染进程 (send)
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged',
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
|
||||
Reference in New Issue
Block a user