Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073978d3ca | ||
|
|
bbaea2df3e | ||
|
|
cf920ab2e6 | ||
|
|
cb243c5c95 | ||
|
|
b814114ff0 | ||
|
|
63fa0d3040 | ||
|
|
8bee37cb89 | ||
|
|
a981d9fd08 | ||
|
|
8398e3f124 | ||
|
|
6450cd6435 | ||
|
|
c647b7e602 | ||
|
|
764d3d831f | ||
|
|
7e0e579dac | ||
|
|
b762488ac9 | ||
|
|
0e06219969 | ||
|
|
58c8a1a3a0 | ||
|
|
9fcbec790d | ||
|
|
26d54f042b | ||
|
|
ee5f35177e | ||
|
|
20eb39efc5 | ||
|
|
d6ae9e3c8e | ||
|
|
2e7baa1073 | ||
|
|
8bbbb7ff70 | ||
|
|
1dd22be0a8 | ||
|
|
20b6d03617 | ||
|
|
7bd625b9ce | ||
|
|
10cfe0066b | ||
|
|
05e5667e48 |
+19
-15
@@ -1,15 +1,19 @@
|
||||
# MarkLite npm 配置模板
|
||||
# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
|
||||
#
|
||||
# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
|
||||
# registry=https://registry.npmmirror.com
|
||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
#
|
||||
# 配置说明:
|
||||
# registry — npm 包下载源
|
||||
# ELECTRON_MIRROR — Electron 二进制文件下载源
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
|
||||
#
|
||||
# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||
# 详见 DEVSETUP.md
|
||||
# MarkLite npm 配置模板
|
||||
# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
|
||||
#
|
||||
# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
|
||||
# registry=https://registry.npmmirror.com
|
||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
#
|
||||
# @metona-team 私有 registry(metona-editor / metona-toast / metona-sqlark):
|
||||
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||
# //git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
||||
#
|
||||
# 配置说明:
|
||||
# registry — npm 包下载源
|
||||
# ELECTRON_MIRROR — Electron 二进制文件下载源
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
|
||||
#
|
||||
# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||
# 详见 DEVSETUP.md
|
||||
|
||||
+123
-113
@@ -1,113 +1,123 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。
|
||||
|
||||
## 开发环境
|
||||
|
||||
1. **前置要求**
|
||||
- Node.js >= 18.x
|
||||
- npm >= 9.x
|
||||
- Windows 10/11 x64
|
||||
|
||||
2. **克隆并安装**
|
||||
```bash
|
||||
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||
cd MarkLite
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **启动开发**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **TypeScript**: 严格模式,所有函数参数需显式类型标注
|
||||
- **ESLint**: 提交前确保 `npm run lint` 通过
|
||||
- **Prettier**: 提交前确保 `npm run format:check` 通过
|
||||
- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck
|
||||
|
||||
## 分支策略
|
||||
|
||||
- `main` — 稳定版本
|
||||
- `dev` — 开发分支
|
||||
- `feature/*` — 功能分支
|
||||
- `fix/*` — 修复分支
|
||||
|
||||
## 提交规范
|
||||
|
||||
使用语义化提交信息:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
类型(type):
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 Bug
|
||||
- `docs`: 文档变更
|
||||
- `style`: 代码格式(不影响逻辑)
|
||||
- `refactor`: 重构
|
||||
- `perf`: 性能优化
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具链变更
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(editor): 添加代码折叠功能
|
||||
fix(tab): 修复关闭标签后焦点丢失问题
|
||||
perf(markdown): 引入 processor LRU 缓存
|
||||
```
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
1. Fork 仓库并创建功能分支
|
||||
2. 确保所有检查通过:
|
||||
```bash
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run test # 单元测试
|
||||
```
|
||||
3. 编写清晰的 PR 描述,说明变更内容和原因
|
||||
4. 等待代码审查
|
||||
|
||||
## 添加测试
|
||||
|
||||
- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx`
|
||||
- 使用 Vitest + React Testing Library
|
||||
- 至少覆盖 stores、hooks、lib 目录下的核心模块
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
|
||||
# 覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # 主进程 (Node.js)
|
||||
├── preload/ # 预加载脚本
|
||||
├── renderer/ # 渲染进程 (React)
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── lib/ # 工具库
|
||||
│ ├── db/ # IndexedDB 持久化
|
||||
│ ├── types/ # TypeScript 类型
|
||||
│ └── styles/ # 全局样式
|
||||
└── shared/ # 主进程/渲染进程共享
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。
|
||||
|
||||
## 开发环境
|
||||
|
||||
1. **前置要求**
|
||||
- Node.js >= 18.x
|
||||
- npm >= 9.x
|
||||
- Windows 10/11 x64
|
||||
|
||||
2. **克隆并安装**
|
||||
```bash
|
||||
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||
cd MarkLite
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **启动开发**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **TypeScript**: 严格模式,所有函数参数需显式类型标注
|
||||
- **ESLint**: 提交前确保 `npm run lint` 通过
|
||||
- **Prettier**: 提交前确保 `npm run format:check` 通过
|
||||
- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck
|
||||
|
||||
## 分支策略
|
||||
|
||||
- `master` — 稳定版本
|
||||
- `dev` — 开发分支
|
||||
- `feature/*` — 功能分支
|
||||
- `fix/*` — 修复分支
|
||||
|
||||
## 提交规范
|
||||
|
||||
使用语义化提交信息:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
类型(type):
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 Bug
|
||||
- `docs`: 文档变更
|
||||
- `style`: 代码格式(不影响逻辑)
|
||||
- `refactor`: 重构
|
||||
- `perf`: 性能优化
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建/工具链变更
|
||||
|
||||
示例:
|
||||
```
|
||||
feat(editor): 添加代码折叠功能
|
||||
fix(tab): 修复关闭标签后焦点丢失问题
|
||||
perf(markdown): 引入 processor LRU 缓存
|
||||
```
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
1. Fork 仓库并创建功能分支
|
||||
2. 确保所有检查通过:
|
||||
```bash
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run test # 单元测试
|
||||
```
|
||||
3. 编写清晰的 PR 描述,说明变更内容和原因
|
||||
4. 等待代码审查
|
||||
|
||||
## 添加测试
|
||||
|
||||
- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx`
|
||||
- 使用 Vitest + React Testing Library
|
||||
- 至少覆盖 stores、hooks、lib 目录下的核心模块
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
|
||||
# 覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # 主进程 (Node.js)
|
||||
├── preload/ # 预加载脚本
|
||||
├── renderer/ # 渲染进程 (React)
|
||||
│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等)
|
||||
│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore)
|
||||
│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等)
|
||||
│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast)
|
||||
│ ├── db/ # IndexedDB 持久化(schema · repositories)
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ └── styles/ # 全局样式(variables · global · markdown-body)
|
||||
└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量)
|
||||
```
|
||||
|
||||
### 编辑器架构
|
||||
|
||||
MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心:
|
||||
|
||||
- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换
|
||||
- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮
|
||||
- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片)
|
||||
- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage)
|
||||
- **主题同步**:应用暗色模式与编辑器主题双向同步
|
||||
|
||||
## 许可证
|
||||
|
||||
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
||||
|
||||
@@ -1,380 +1,425 @@
|
||||
# MarkLite v0.3.13 — 架构设计文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Milkdown v7 WYSIWYG 编辑器 + 源码编辑模式、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义
|
||||
2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types)
|
||||
3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize
|
||||
4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦
|
||||
|
||||
## 2. 技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 组件 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
||||
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
||||
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
||||
| 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 |
|
||||
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
||||
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
|
||||
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 |
|
||||
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
|
||||
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
|
||||
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
|
||||
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
||||
|
||||
### 2.2 进程架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Main Process (src/main/) 5 文件 │
|
||||
│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │
|
||||
│ ipc-handlers.ts 所有 ipcMain.handle 注册 │
|
||||
│ file-system.ts 文件读写、目录树构建、BOM 剥离 │
|
||||
│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │
|
||||
│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │
|
||||
└───────────────────────┬──────────────────────────────────────┘
|
||||
│ contextBridge (安全隔离)
|
||||
┌───────────────────────▼──────────────────────────────────────┐
|
||||
│ Preload Script (src/preload/) 1 文件 │
|
||||
│ index.ts contextBridge 类型安全暴露 │
|
||||
│ electronAPI 22 个方法/事件的类型安全接口 │
|
||||
└───────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────▼──────────────────────────────────────┐
|
||||
│ Renderer Process (src/renderer/) React 18 │
|
||||
│ │
|
||||
│ components/ (23) Toolbar · TabBar · Editor · EditorToolbar │
|
||||
│ SourceEditor · Preview · Sidebar · FileTree│
|
||||
│ OutlinePanel · StatusBar · WelcomeScreen │
|
||||
│ Toast · ConfirmDialog · ModifiedBanner │
|
||||
│ SearchReplace · DropOverlay · ErrorBoundary│
|
||||
│ AboutDialog · LoadingSpinner · Icons │
|
||||
│ │
|
||||
│ stores/ (5) tabStore · editorStore · sidebarStore │
|
||||
│ statusBarStore · autoSaveStore │
|
||||
│ hooks/ (19) useTheme · useSettings · useSettingsInit │
|
||||
│ useKeyboard · useDragDrop · useFileWatch │
|
||||
│ useAutoSave · useIpcListeners ... │
|
||||
│ lib/ (4) markdown · fileUtils · errorHandler │
|
||||
│ constants │
|
||||
│ db/ (4) schema · tabRepository · settingsRepo │
|
||||
│ recentFilesRepository │
|
||||
│ types/ (5) tab · file · settings · ipc · index │
|
||||
│ styles/ (3) variables · global · markdown-body │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Shared (src/shared/) 3 文件 │
|
||||
│ ipc-channels.ts IPC 通道名常量 │
|
||||
│ types.ts 共享类型定义 │
|
||||
│ constants.ts 共享常量 (版本号、文件大小限制等) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 安全模型
|
||||
|
||||
| 层级 | 措施 | 说明 |
|
||||
|------|------|------|
|
||||
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
||||
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
||||
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
||||
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
||||
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
||||
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
||||
| 路径 | `validatePath()` | 防止路径遍历攻击 |
|
||||
|
||||
## 3. 状态管理架构
|
||||
|
||||
### 3.1 Zustand Stores
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App.tsx (根组件) │
|
||||
├─────────┬──────────┬──────────┬──────────┬──────────────┤
|
||||
│Toolbar │ TabBar │ Sidebar │ Editor │ Preview │
|
||||
│ │ │ │ (CM6) │ │
|
||||
├─────────┴──────────┴──────────┴──────────┴──────────────┤
|
||||
│ Zustand Stores │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │statusBarStore│ │
|
||||
│ │ - tabs │ │- viewMode│ │- tree │ │- items │ │
|
||||
│ │- activeId│ │- darkMode│ │- expanded │ │ │ │
|
||||
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
||||
│ │ ┌──────────────┐ │ │
|
||||
│ │ │autoSaveStore │ │ │
|
||||
│ │ │- isAutoSaving│ │ │
|
||||
│ │ └──────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
||||
│ │ IndexedDB (Dexie.js) │ │
|
||||
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 tabStore — 标签页状态
|
||||
|
||||
```typescript
|
||||
interface TabState {
|
||||
tabs: Tab[] // 所有标签页
|
||||
activeTabId: string | null // 当前活动标签 ID
|
||||
mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换)
|
||||
|
||||
createTab(filePath?, content?) // 创建标签(同文件不重复打开)
|
||||
closeTab(tabId) // 关闭标签(自动切换到相邻标签)
|
||||
switchToTab(tabId) // 切换标签
|
||||
updateTabContent(tabId, content) // 更新内容(标记修改)
|
||||
setModified(tabId, modified) // 设置修改状态
|
||||
getActiveTab() // 获取当前标签
|
||||
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
||||
saveToDB() // 保存到 IndexedDB
|
||||
loadFromDB() // 从 IndexedDB 加载
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 editorStore — 编辑器状态
|
||||
|
||||
```typescript
|
||||
interface EditorState {
|
||||
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
||||
darkMode: boolean // 暗色主题
|
||||
externallyModified: { filePath: string } | null // 外部修改检测状态
|
||||
loadingStates: Record<string, boolean> // 全局加载状态
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 sidebarStore — 侧边栏状态
|
||||
|
||||
```typescript
|
||||
interface SidebarState {
|
||||
isVisible: boolean // 是否显示
|
||||
rootPath: string | null // 当前打开的文件夹路径
|
||||
tree: FileNode[] // 目录树数据
|
||||
expandedDirs: string[] // 已展开的目录集合
|
||||
sidebarWidth: number // 侧边栏宽度 (180~500)
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 数据持久化 — IndexedDB
|
||||
|
||||
通过 Dexie.js 封装 IndexedDB:
|
||||
|
||||
### 4.1 数据库 Schema
|
||||
|
||||
```typescript
|
||||
const db = new Dexie('MarkLite')
|
||||
|
||||
db.version(1).stores({
|
||||
tabSnapshots: 'id, filePath, updatedAt', // 标签页快照
|
||||
settings: 'id', // 用户设置
|
||||
recentFiles: '++id, filePath, lastOpened', // 最近打开文件
|
||||
activeTab: 'id' // 当前活动标签
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 数据模型
|
||||
|
||||
| Store | 字段 | 说明 |
|
||||
|-------|------|------|
|
||||
| `tabSnapshots` | id, filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt | 标签页状态快照 |
|
||||
| `settings` | id, darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
||||
| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 |
|
||||
| `activeTab` | id, activeTabId | 当前活动标签 ID |
|
||||
|
||||
## 5. IPC 通信设计
|
||||
|
||||
### 5.1 渲染进程 → 主进程(invoke)
|
||||
|
||||
| 通道 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 |
|
||||
| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 |
|
||||
| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 |
|
||||
| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 |
|
||||
| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 |
|
||||
| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 |
|
||||
| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 |
|
||||
| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 |
|
||||
| `window:forceClose` | 无 | `void` | 强制关闭窗口 |
|
||||
| `window:cancelClose` | 无 | `void` | 取消关闭 |
|
||||
| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 |
|
||||
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
|
||||
| `dir:watch` | `dirPath` | `void` | 监听目录变化 |
|
||||
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
|
||||
|
||||
### 5.2 主进程 → 渲染进程(send)
|
||||
|
||||
| 通道 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 |
|
||||
| `file:externallyModified` | `filePath` | 文件被外部修改 |
|
||||
| `window:confirmClose` | 无 | 请求确认关闭 |
|
||||
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
||||
|
||||
## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea)
|
||||
|
||||
### 6.1 双编辑模式
|
||||
|
||||
- **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo
|
||||
- **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键
|
||||
|
||||
两种模式共享同一 tabStore 数据源,可随时切换。
|
||||
|
||||
### 6.1.1 主题切换
|
||||
|
||||
Milkdown 编辑器通过 CSS class(`milkdown-dark`)切换暗色主题,无需销毁重建 EditorView,
|
||||
切换标签时保持光标位置和选区。
|
||||
|
||||
### 6.2 插件体系
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| commonmark | 基础 Markdown 语法 |
|
||||
| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) |
|
||||
| history | undo/redo |
|
||||
| listener | 内容变更监听 |
|
||||
| indent | Tab 缩进 |
|
||||
| trailing | 尾随换行 |
|
||||
| clipboard | 剪贴板增强 |
|
||||
| searchPlugin (自研) | 搜索高亮装饰 |
|
||||
| autoPairPlugin (自研) | 自动配对括号/引号 |
|
||||
|
||||
### 6.3 滚动与选区持久化
|
||||
|
||||
切换标签时自动保存/恢复:
|
||||
- 滚动位置 (`scrollTop`)
|
||||
- 光标选区 (`selectionStart`, `selectionEnd`)
|
||||
|
||||
## 7. Markdown 渲染管线
|
||||
|
||||
```
|
||||
Markdown 文本
|
||||
│
|
||||
▼
|
||||
remark-parse 解析为 MDAST
|
||||
│
|
||||
▼
|
||||
remark-gfm 扩展 GFM 语法
|
||||
│
|
||||
▼
|
||||
remark-rehype 转换为 HAST
|
||||
│
|
||||
▼
|
||||
rehype-raw 解析内联 HTML
|
||||
│
|
||||
▼
|
||||
rehype-sanitize 安全过滤
|
||||
│
|
||||
▼
|
||||
rehype-fixImages 相对路径图片转 file:// URL
|
||||
│
|
||||
▼
|
||||
rehype-highlight 代码语法高亮
|
||||
│
|
||||
▼
|
||||
rehype-stringify 序列化为 HTML
|
||||
│
|
||||
▼
|
||||
dangerouslySetInnerHTML 渲染到 DOM
|
||||
```
|
||||
|
||||
## 8. UI 设计
|
||||
|
||||
### 8.1 色彩方案
|
||||
|
||||
#### 亮色主题
|
||||
|
||||
| 角色 | CSS 变量 | 色值 |
|
||||
|------|----------|------|
|
||||
| 主色调 | `--primary` | `#1a73e8` |
|
||||
| 背景色 | `--bg` | `#ffffff` |
|
||||
| 次级背景 | `--bg-secondary` | `#f8f9fa` |
|
||||
| 文字色 | `--text` | `#333333` |
|
||||
| 边框色 | `--border` | `#e1e4e8` |
|
||||
|
||||
#### 暗色主题
|
||||
|
||||
| 角色 | CSS 变量 | 色值 |
|
||||
|------|----------|------|
|
||||
| 主色调 | `--primary` | `#8ab4f8` |
|
||||
| 背景色 | `--bg` | `#1e1e1e` |
|
||||
| 次级背景 | `--bg-secondary` | `#252526` |
|
||||
| 文字色 | `--text` | `#d4d4d4` |
|
||||
| 边框色 | `--border` | `#3e3e3e` |
|
||||
|
||||
### 8.2 布局
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ MarkLite - filename.md ─ □ ✕ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 🔗 ℹ️ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [file1.md] [file2.md] [未命名] [+] │
|
||||
├──────────┬─────────────────────────────────────────────────────────────┤
|
||||
│ 资源管理器 │ │
|
||||
│ ▼ project │ 1 # Title │ Title │
|
||||
│ 📁 src │ 2 │ ─────── │
|
||||
│ 📄 file1│ 3 content... │ content... │
|
||||
│ 📄 file2│ │ │
|
||||
├──────────┴─────────────────────────────────────────────────────────────┤
|
||||
│ filename.md │ UTF-8 │ Markdown │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 9. 构建与发布
|
||||
|
||||
### 9.1 开发模式
|
||||
|
||||
```bash
|
||||
npm run dev # electron-vite dev(HMR 热更新)
|
||||
```
|
||||
|
||||
### 9.2 生产构建
|
||||
|
||||
```bash
|
||||
npm run build # electron-vite build + electron-builder --win
|
||||
npm run build:portable # 便携版(免安装)
|
||||
```
|
||||
|
||||
### 9.3 打包配置
|
||||
|
||||
- 输出格式:NSIS 安装包(.exe)
|
||||
- 目标平台:Windows x64
|
||||
- 应用图标:assets/icon.ico
|
||||
- 文件关联:`.md` / `.markdown` / `.txt`
|
||||
- 支持自定义安装目录、桌面/开始菜单快捷方式
|
||||
|
||||
## 10. 依赖清单
|
||||
|
||||
### 运行时依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| react / react-dom | ^18.3 | UI 框架 |
|
||||
| zustand | ^5.0 | 状态管理 |
|
||||
| dexie | ^4.0 | IndexedDB 封装 |
|
||||
| nanoid | ^5.0 | 唯一 ID 生成 |
|
||||
| @milkdown/* | 7.21.1 | WYSIWYG Markdown 编辑器(基于 ProseMirror) |
|
||||
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
||||
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
||||
|
||||
### 开发依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| electron | ^28.0 | 桌面框架 |
|
||||
| electron-builder | ^25.0 | 打包工具 |
|
||||
| electron-vite | ^3.0 | 构建工具 |
|
||||
| typescript | ^5.6 | 类型系统 |
|
||||
| eslint | ^9.0 | 代码检查 |
|
||||
# MarkLite v0.4.3 — 架构设计文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
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 + 内置解析器 XSS 防护
|
||||
4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦
|
||||
|
||||
## 2. 技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 组件 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
||||
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
||||
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
||||
| 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
||||
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
||||
| 持久化 | MetonaSqlark | v0.7.4 | 标签页状态 / 用户设置 / 最近文件(AriaEngine + KVStore,OPFS 落盘;v0.6.2 起不再使用 IndexedDB,旧库启动时自动删除) |
|
||||
| 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 安装包 |
|
||||
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
||||
|
||||
### 2.2 进程架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Main Process (src/main/) 5 文件 │
|
||||
│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │
|
||||
│ ipc-handlers.ts 所有 ipcMain.handle 注册 │
|
||||
│ file-system.ts 文件读写、目录树构建、BOM 剥离 │
|
||||
│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │
|
||||
│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │
|
||||
└───────────────────────┬──────────────────────────────────────┘
|
||||
│ contextBridge (安全隔离)
|
||||
┌───────────────────────▼──────────────────────────────────────┐
|
||||
│ Preload Script (src/preload/) 1 文件 │
|
||||
│ index.ts contextBridge 类型安全暴露 │
|
||||
│ electronAPI 22 个方法/事件的类型安全接口 │
|
||||
└───────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────▼──────────────────────────────────────┐
|
||||
│ Renderer Process (src/renderer/) React 18 │
|
||||
│ │
|
||||
│ components/ Toolbar · TabBar · Editor · Sidebar │
|
||||
│ FileTree · OutlinePanel · WelcomeScreen │
|
||||
│ ConfirmDialog · ModifiedBanner │
|
||||
│ DropOverlay · ErrorBoundary · AboutDialog│
|
||||
│ LoadingSpinner · Icons │
|
||||
│ │
|
||||
│ stores/ (4) tabStore · editorStore · sidebarStore │
|
||||
│ autoSaveStore │
|
||||
│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │
|
||||
│ useDragDrop · useFileWatch · useAutoSave │
|
||||
│ useIpcListeners · useFileOperations ... │
|
||||
│ lib/ (4) markdown · fileUtils · errorHandler │
|
||||
│ toast · constants │
|
||||
│ db/ (4) schema · tabRepository · settingsRepo │
|
||||
│ recentFilesRepository │
|
||||
│ types/ (5) tab · file · settings · ipc · index │
|
||||
│ styles/ (3) variables · global · markdown-body │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Shared (src/shared/) 3 文件 │
|
||||
│ ipc-channels.ts IPC 通道名常量 │
|
||||
│ types.ts 共享类型定义 │
|
||||
│ constants.ts 共享常量 (版本号、文件大小限制等) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 安全模型
|
||||
|
||||
| 层级 | 措施 | 说明 |
|
||||
|------|------|------|
|
||||
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
||||
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
||||
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
||||
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
||||
| HTML | `MetonaEditor 内置解析器` | 渲染 Markdown(escapeHTML + safeUrl XSS 防护) |
|
||||
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
||||
| 路径 | `validatePath()` | 防止路径遍历攻击 |
|
||||
|
||||
## 3. 状态管理架构
|
||||
|
||||
### 3.1 Zustand Stores
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App.tsx (根组件) │
|
||||
├─────────┬──────────┬──────────┬──────────────────────────┤
|
||||
│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │
|
||||
├─────────┴──────────┴──────────┴──────────────────────────┤
|
||||
│ Zustand Stores │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │
|
||||
│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │
|
||||
│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │
|
||||
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
||||
│ │ IndexedDB (MetonaSqlark) │ │
|
||||
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 tabStore — 标签页状态
|
||||
|
||||
```typescript
|
||||
interface TabState {
|
||||
tabs: Tab[] // 所有标签页
|
||||
activeTabId: string | null // 当前活动标签 ID
|
||||
mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换)
|
||||
|
||||
createTab(filePath?, content?) // 创建标签(同文件不重复打开)
|
||||
closeTab(tabId) // 关闭标签(自动切换到相邻标签)
|
||||
switchToTab(tabId) // 切换标签
|
||||
updateTabContent(tabId, content) // 更新内容(标记修改)
|
||||
setModified(tabId, modified) // 设置修改状态
|
||||
getActiveTab() // 获取当前标签
|
||||
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
||||
saveToDB() // 保存到 IndexedDB(防抖 500ms)
|
||||
loadFromDB() // 从 IndexedDB 加载
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 editorStore — 编辑器状态
|
||||
|
||||
```typescript
|
||||
interface EditorState {
|
||||
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
||||
darkMode: boolean // 暗色主题
|
||||
externallyModified: { filePath: string } | null // 外部修改检测状态
|
||||
loadingStates: Record<string, boolean> // 全局加载状态
|
||||
}
|
||||
|
||||
// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例
|
||||
function getMetonaEditor(): MarkdownEditor | null
|
||||
function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void
|
||||
```
|
||||
|
||||
### 3.4 sidebarStore — 侧边栏状态
|
||||
|
||||
```typescript
|
||||
interface SidebarState {
|
||||
isVisible: boolean // 是否显示
|
||||
rootPath: string | null // 当前打开的文件夹路径
|
||||
tree: FileNode[] // 目录树数据
|
||||
expandedDirs: string[] // 已展开的目录集合
|
||||
sidebarWidth: number // 侧边栏宽度 (180~500)
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 数据持久化 — MetonaSqlark (KVStore)
|
||||
|
||||
v0.5.0: 由 Dexie.js 迁移至 MetonaSqlark(AriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
|
||||
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
|
||||
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
|
||||
v0.6.2: sqlark 升级 0.7.4,存储后端改用自研 KVStore(内存索引 + 快照/日志,OPFS 落盘);
|
||||
旧 IndexedDB 库在启动时自动删除,不做向下兼容。
|
||||
|
||||
### 4.1 数据库 Schema
|
||||
|
||||
```typescript
|
||||
// schema.ts — 懒加载单例
|
||||
let dbPromise: Promise<MetonaSqlark> | null = null
|
||||
export function getDb(): Promise<MetonaSqlark> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = create({
|
||||
name: 'MarkLiteV2',
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||
diskEngine: 'kv', // v0.6.2: 自研 KVStore 后端(OPFS 落盘;jsdom 测试回退 memory)
|
||||
version: 0, // AriaEngine 忽略版本号
|
||||
}).then(async (db) => {
|
||||
if (!(await db.getTableNames()).includes('tabSnapshots')) {
|
||||
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
|
||||
}
|
||||
// settings / recentFiles / activeTab 同理
|
||||
return db
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 数据模型
|
||||
|
||||
| Store | 字段 | 说明 |
|
||||
|-------|------|------|
|
||||
| `tabSnapshots` | id(PK), filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt(index) | 标签页状态快照 |
|
||||
| `settings` | id(PK), themeMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
||||
| `recentFiles` | filePath(PK), lastOpened(index) | 最近打开文件列表(v0.5.0 改用 filePath 主键,sqlark 无自增) |
|
||||
| `activeTab` | id(PK), activeTabId | 当前活动标签 ID |
|
||||
|
||||
## 5. IPC 通信设计
|
||||
|
||||
### 5.1 渲染进程 → 主进程(invoke)
|
||||
|
||||
| 通道 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 |
|
||||
| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 |
|
||||
| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 |
|
||||
| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 |
|
||||
| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 |
|
||||
| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 |
|
||||
| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 |
|
||||
| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 |
|
||||
| `window:forceClose` | 无 | `void` | 强制关闭窗口 |
|
||||
| `window:cancelClose` | 无 | `void` | 取消关闭 |
|
||||
| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 |
|
||||
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
|
||||
| `dir:watch` | `dirPath` | `void` | 监听目录变化 |
|
||||
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
|
||||
| `dir:search` | `{ dirPath, query, caseSensitive?, useRegex? }` | `SearchInDirResult` | v0.6.2: 文件夹内多文件内容搜索 |
|
||||
|
||||
### 5.2 主进程 → 渲染进程(send)
|
||||
|
||||
| 通道 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 |
|
||||
| `file:externallyModified` | `filePath` | 文件被外部修改 |
|
||||
| `window:confirmClose` | 无 | 请求确认关闭 |
|
||||
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
||||
|
||||
## 6. 编辑器架构 — MetonaEditor v0.1.14
|
||||
|
||||
### 6.1 三模式视图
|
||||
|
||||
- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
|
||||
- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
|
||||
- **预览模式 (preview)**:仅显示渲染后的 HTML
|
||||
|
||||
MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。
|
||||
|
||||
### 6.2 内置功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr |
|
||||
| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 |
|
||||
| 历史栈 | undo / redo,防抖合并,可配置上限 |
|
||||
| 主题 | light / dark / auto / warm,CSS 变量驱动 |
|
||||
| 国际化 | zh-CN / en-US 完整翻译 |
|
||||
| 全屏模式 | 编辑器全屏展示 |
|
||||
|
||||
### 6.3 插件体系
|
||||
|
||||
通过 `plugins` 配置数组安装 MetonaEditor 预设插件:
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
|
||||
| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
|
||||
|
||||
> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
|
||||
|
||||
### 6.4 渲染管线集成
|
||||
|
||||
v0.6.0: 移除 unified/remark/rehype 自研管线,改用 MetonaEditor **内置解析器**(parseMarkdown),
|
||||
通过 `render` 钩子接入,实现:
|
||||
|
||||
- **相对路径图片解析**:内置解析器的 safeUrl 会过滤 `file:` 协议,因此渲染后做 HTML 后处理,将相对路径图片 src 转换为 `file://` 绝对路径(越界路径保持原样)
|
||||
- **XSS 防护**:内置 escapeHTML + safeUrl(过滤 javascript:/vbscript:/file:/data:)+ 属性转义
|
||||
- **代码高亮**:内置零依赖高亮器(`highlight: MeEditor.highlight`,16 种语言)
|
||||
- **Mermaid 图表**:内置解析器原生输出 `.me-mermaid` 容器,`mermaid.run()` 直接渲染
|
||||
|
||||
```
|
||||
Markdown 源码
|
||||
│
|
||||
▼
|
||||
parseMarkdown(MetonaEditor 内置解析器)
|
||||
├── GFM / 任务列表 / 表格 / 删除线
|
||||
├── 脚注 / 数学公式 / 定义列表 / emoji
|
||||
├── 引用链接 / 自动链接 / 上下标
|
||||
├── mermaid → <div class="me-mermaid">…
|
||||
└── XSS 防护(escapeHTML + safeUrl)
|
||||
│
|
||||
▼
|
||||
fixImageSrcs(HTML 后处理:相对路径 → file://)
|
||||
│
|
||||
▼
|
||||
MetonaEditor 预览区渲染
|
||||
```
|
||||
|
||||
### 6.5 主题切换
|
||||
|
||||
MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程:
|
||||
|
||||
1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage
|
||||
2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`)
|
||||
3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题
|
||||
|
||||
### 6.6 内容同步
|
||||
|
||||
- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified`
|
||||
- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange
|
||||
- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置
|
||||
|
||||
## 7. Markdown 渲染管线
|
||||
|
||||
v0.6.0: 渲染完全由 MetonaEditor 内置解析器承担(零依赖),应用侧仅保留图片路径修复后处理:
|
||||
|
||||
```
|
||||
Markdown 文本
|
||||
│
|
||||
▼
|
||||
parseMarkdown(MetonaEditor 内置解析器)
|
||||
├── 块级:标题 / 列表 / 引用 / 代码块 / 表格 / 水平线 / 脚注 / 数学公式 / 定义列表
|
||||
├── 行内:粗体 / 斜体 / 删除线 / 高亮 / 上下标 / 行内代码 / 链接 / 图片 / emoji
|
||||
├── mermaid:<div class="me-mermaid"><pre class="mermaid">…
|
||||
└── 安全:escapeHTML 转义 + safeUrl URL 过滤 + 属性级注入防护
|
||||
│
|
||||
▼
|
||||
fixImageSrcs(markdown.ts 后处理)
|
||||
└── 相对路径图片 src → file:// 绝对路径(越界 ../ 不处理)
|
||||
│
|
||||
▼
|
||||
MetonaEditor 预览区 / getHTML 导出
|
||||
```
|
||||
|
||||
## 8. UI 设计
|
||||
|
||||
### 8.1 色彩方案
|
||||
|
||||
#### 亮色主题
|
||||
|
||||
| 角色 | CSS 变量 | 色值 |
|
||||
|------|----------|------|
|
||||
| 主色调 | `--primary` | `#1a73e8` |
|
||||
| 背景色 | `--bg` | `#ffffff` |
|
||||
| 次级背景 | `--bg-secondary` | `#f8f9fa` |
|
||||
| 文字色 | `--text` | `#333333` |
|
||||
| 边框色 | `--border` | `#e1e4e8` |
|
||||
|
||||
#### 暗色主题
|
||||
|
||||
| 角色 | CSS 变量 | 色值 |
|
||||
|------|----------|------|
|
||||
| 主色调 | `--primary` | `#8ab4f8` |
|
||||
| 背景色 | `--bg` | `#1e1e1e` |
|
||||
| 次级背景 | `--bg-secondary` | `#252526` |
|
||||
| 文字色 | `--text` | `#d4d4d4` |
|
||||
| 边框色 | `--border` | `#3e3e3e` |
|
||||
|
||||
### 8.2 布局
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ MarkLite - filename.md ─ □ ✕ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [file1.md] [file2.md] [未命名] [+] │
|
||||
├──────────┬───────────────────────────────────────────────────────────────┤
|
||||
│ 资源管理器 │ │
|
||||
│ ▼ project │ ┌─────────────────────────────────────────────┐ │
|
||||
│ 📁 src │ │ B I S U </> │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │
|
||||
│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │
|
||||
│ 📄 file2│ ├─────────────────────────────────────────────┤ │
|
||||
│ │ │ # Title │ Title │ │
|
||||
│ 文档大纲 │ │ │ ─────── │ │
|
||||
│ · Title │ │ content... │ content... │ │
|
||||
├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
|
||||
│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 9. 构建与发布
|
||||
|
||||
### 9.1 开发模式
|
||||
|
||||
```bash
|
||||
npm run dev # electron-vite dev(HMR 热更新)
|
||||
```
|
||||
|
||||
### 9.2 生产构建
|
||||
|
||||
```bash
|
||||
npm run build # electron-vite build + electron-builder --win
|
||||
npm run build:portable # 便携版(免安装)
|
||||
```
|
||||
|
||||
### 9.3 打包配置
|
||||
|
||||
- 输出格式:NSIS 安装包(.exe)
|
||||
- 目标平台:Windows x64
|
||||
- 应用图标:assets/icon.ico
|
||||
- 文件关联:`.md` / `.markdown` / `.txt`
|
||||
- 支持自定义安装目录、桌面/开始菜单快捷方式
|
||||
|
||||
## 10. 依赖清单
|
||||
|
||||
### 运行时依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| react / react-dom | ^18.3 | UI 框架 |
|
||||
| zustand | ^5.0 | 状态管理 |
|
||||
| @metona-team/metona-sqlark | 0.7.4 | 前端关系型数据库(AriaEngine + KVStore,OPFS) |
|
||||
| nanoid | ^5.0 | 唯一 ID 生成 |
|
||||
| @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(内置解析器 + 高亮) |
|
||||
| @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
|
||||
| mermaid | ^10.9 | Mermaid 图表渲染 |
|
||||
|
||||
### 开发依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| electron | ^28.0 | 桌面框架 |
|
||||
| electron-builder | ^25.0 | 打包工具 |
|
||||
| electron-vite | ^3.0 | 构建工具 |
|
||||
| typescript | ^5.6 | 类型系统 |
|
||||
| eslint | ^9.0 | 代码检查 |
|
||||
|
||||
+93
-82
@@ -1,82 +1,93 @@
|
||||
# Electron 项目开发环境完整配置指南
|
||||
|
||||
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
||||
|
||||
## 一、Node 版本管理(nvm)
|
||||
|
||||
安装 nvm-windows 后,使用以下命令管理 Node 版本:
|
||||
|
||||
```bash
|
||||
# 安装需要的版本(以 Node 18 为例)
|
||||
nvm install 18
|
||||
|
||||
# 设为当前终端使用的版本
|
||||
nvm use 18
|
||||
|
||||
# 设为默认版本(新终端自动生效)
|
||||
nvm alias default 18
|
||||
```
|
||||
|
||||
## 二、配置镜像源
|
||||
|
||||
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
||||
|
||||
### 推荐方式:项目级 .npmrc(仅对当前项目生效)
|
||||
|
||||
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
||||
|
||||
```
|
||||
registry=https://registry.npmmirror.com
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
```
|
||||
|
||||
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
||||
|
||||
## 三、配置文件说明
|
||||
|
||||
| 配置项 | 作用 |
|
||||
|:-------|:-----|
|
||||
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
||||
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
||||
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
||||
|
||||
## 四、开始打包
|
||||
|
||||
> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||
|
||||
```bash
|
||||
# 1. 清除旧缓存,确保不走之前的错误源
|
||||
npm cache clean --force
|
||||
|
||||
# 2. 安装依赖(如果还没装或 node_modules 不完整)
|
||||
npm install
|
||||
|
||||
# 3. 执行构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功后,安装包会生成在 `dist/` 目录下。
|
||||
|
||||
## 五、一次性完整操作步骤总结
|
||||
|
||||
```bash
|
||||
# ① 确保 Node 版本正确
|
||||
nvm use 18
|
||||
|
||||
# ② 在项目根目录创建 .npmrc,写入:
|
||||
# registry=https://registry.npmmirror.com
|
||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
# ③ 关闭当前终端,新开一个终端,然后执行:
|
||||
npm cache clean --force
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 六、常见问题排查
|
||||
|
||||
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
||||
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
||||
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
||||
# MarkLite v0.5.0 — 开发环境配置指南
|
||||
|
||||
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
||||
|
||||
## 一、Node 版本管理(nvm)
|
||||
|
||||
安装 nvm-windows 后,使用以下命令管理 Node 版本:
|
||||
|
||||
```bash
|
||||
# 安装需要的版本(以 Node 18 为例)
|
||||
nvm install 18
|
||||
|
||||
# 设为当前终端使用的版本
|
||||
nvm use 18
|
||||
|
||||
# 设为默认版本(新终端自动生效)
|
||||
nvm alias default 18
|
||||
```
|
||||
|
||||
## 二、配置镜像源
|
||||
|
||||
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
||||
|
||||
### 项目级 .npmrc(推荐)
|
||||
|
||||
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
||||
|
||||
```
|
||||
registry=https://registry.npmmirror.com
|
||||
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||
//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<your_token>
|
||||
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
||||
|
||||
# Electron 二进制下载镜像
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
```
|
||||
|
||||
> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。
|
||||
|
||||
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
||||
|
||||
## 三、配置文件说明
|
||||
|
||||
| 配置项 | 作用 |
|
||||
|:-------|:-----|
|
||||
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
||||
| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
|
||||
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
||||
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
||||
|
||||
## 四、开始打包
|
||||
|
||||
> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||
|
||||
```bash
|
||||
# 1. 清除旧缓存,确保不走之前的错误源
|
||||
npm cache clean --force
|
||||
|
||||
# 2. 安装依赖(如果还没装或 node_modules 不完整)
|
||||
npm install
|
||||
|
||||
# 3. 执行构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功后,安装包会生成在 `dist/` 目录下。
|
||||
|
||||
## 五、一次性完整操作步骤总结
|
||||
|
||||
```bash
|
||||
# ① 确保 Node 版本正确
|
||||
nvm use 18
|
||||
|
||||
# ② 在项目根目录创建 .npmrc,写入:
|
||||
# registry=https://registry.npmmirror.com
|
||||
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||
# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<token>
|
||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
# ③ 关闭当前终端,新开一个终端,然后执行:
|
||||
npm cache clean --force
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 六、常见问题排查
|
||||
|
||||
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
||||
- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。
|
||||
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
||||
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
{
|
||||
"review_date": "2026-07-06",
|
||||
"reviewer": "CatPaw (苏浅玥) — Follow-up Audit & Fix",
|
||||
"project": "MarkLite",
|
||||
"overall_score": 96,
|
||||
"verdict": "pass",
|
||||
"previous_review": {
|
||||
"review_date": "2026-06-03",
|
||||
"overall_score": 82,
|
||||
"verdict": "needs-fix"
|
||||
},
|
||||
"score": {
|
||||
"code_quality": {
|
||||
"score": 95,
|
||||
"weight": 0.25,
|
||||
"details": {
|
||||
"typescript": {
|
||||
"status": "pass",
|
||||
"errors": 0,
|
||||
"fixes_applied": [
|
||||
"useMilkdown.ts:73 — Replaced state.selection.constructor.create with TextSelection.create from @milkdown/prose/state",
|
||||
"useMilkdown.ts:42-56 — Fixed dead-code bug: Backspace handler was unreachable because PAIRS['Backspace'] check returned false first",
|
||||
"package.json — Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom dependency plugins as direct deps, eliminating @milkdown/ctx 7.21.1/7.21.2 version duplication",
|
||||
"useFolderOperations.ts — openFolderDialog() return type fixed (previously resolved by prior commit)",
|
||||
"types/ipc.ts — Duplicate electronAPI declaration removed (previously resolved by prior commit)"
|
||||
]
|
||||
},
|
||||
"eslint": {
|
||||
"status": "pass",
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"fixes_applied": [
|
||||
"OutlinePanel.tsx — Extracted parseHeadings() and Heading interface to outlineUtils.ts to satisfy react-refresh/only-export-components",
|
||||
"useStatusBarItems.tsx — Extracted 5 StatusBar component definitions to StatusBarItems.tsx to satisfy react-refresh/only-export-components",
|
||||
"tabStore.test.ts:55 — Unused switchToTab variable removed (previously resolved by prior commit)",
|
||||
"Editor.tsx / Preview.tsx — react-hooks/exhaustive-deps handled with eslint-disable comments (previously resolved by prior commit)"
|
||||
]
|
||||
},
|
||||
"npm_warnings": {
|
||||
"status": "pass",
|
||||
"fixes_applied": [
|
||||
"package.json — Added allowScripts config for electron and esbuild postinstall scripts via npm approve-scripts"
|
||||
]
|
||||
},
|
||||
"tests": {
|
||||
"status": "pass",
|
||||
"total_files": 6,
|
||||
"total_tests": 96,
|
||||
"all_passed": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"performance_optimization": {
|
||||
"score": 95,
|
||||
"weight": 0.2,
|
||||
"details": {
|
||||
"markdown_cache": {
|
||||
"status": "pass",
|
||||
"implementation": "LRU cache with MAX_CACHE_SIZE=10",
|
||||
"features": ["filePath-based cache key", "LRU eviction", "null key handling"]
|
||||
},
|
||||
"zustand_selectors": {
|
||||
"status": "pass",
|
||||
"implementation": "Fine-grained selectors throughout",
|
||||
"example": "useTabStore(s => s.tabs) instead of full store"
|
||||
},
|
||||
"codemirror_compartment": {
|
||||
"status": "pass",
|
||||
"implementation": "Compartment.reconfigure() for theme switching",
|
||||
"benefit": "No EditorView reconstruction on theme change"
|
||||
},
|
||||
"debounce": {
|
||||
"status": "pass",
|
||||
"implementations": [
|
||||
"Preview: 150ms debounce on markdown rendering",
|
||||
"TabStore: 500ms debounce on DB persistence"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"architecture_refactoring": {
|
||||
"score": 92,
|
||||
"weight": 0.2,
|
||||
"details": {
|
||||
"component_split": {
|
||||
"status": "pass",
|
||||
"extractions": [
|
||||
"FileTree extracted from Sidebar",
|
||||
"useFolderOperations hook from Sidebar",
|
||||
"EditorToolbar from Editor",
|
||||
"Toast with SingleToast sub-component",
|
||||
"parseHeadings + Heading extracted to outlineUtils.ts",
|
||||
"StatusBar item components extracted to StatusBarItems.tsx"
|
||||
]
|
||||
},
|
||||
"type_safety": {
|
||||
"status": "pass",
|
||||
"positives": [
|
||||
"Shared types properly defined",
|
||||
"IpcInvokeMap type mapping implemented",
|
||||
"Generic tryAsync pattern with proper typing",
|
||||
"ElectronAPI centralized in types/ipc.ts, preload.d.ts imports from it",
|
||||
"All @milkdown/* packages pinned to exact versions to prevent type duplication"
|
||||
]
|
||||
},
|
||||
"error_handling": {
|
||||
"status": "pass",
|
||||
"implementation": [
|
||||
"AppError class with code and cause",
|
||||
"logError() for logging",
|
||||
"getErrorMessage() for user-facing messages",
|
||||
"tryAsync() wrapper returning [result, error] tuple",
|
||||
"ErrorBoundary for React component errors"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_experience": {
|
||||
"score": 90,
|
||||
"weight": 0.2,
|
||||
"details": {
|
||||
"confirm_dialog": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"Promise-based API via useConfirm hook",
|
||||
"Focus management (save/restore)",
|
||||
"ESC key handling",
|
||||
"Background scroll lock",
|
||||
"ARIA: role=alertdialog, aria-modal, aria-labelledby, aria-describedby",
|
||||
"Variant support (danger/warning/info)"
|
||||
]
|
||||
},
|
||||
"loading_states": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"LoadingSpinner with size variants (small/medium/large)",
|
||||
"Overlay mode support",
|
||||
"Global loading state via editorStore.loadingStates",
|
||||
"StatusBar integration with contextual labels",
|
||||
"ARIA: role=status, aria-label, aria-busy"
|
||||
]
|
||||
},
|
||||
"toast": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"Multi-toast stacking (max 5)",
|
||||
"Type differentiation with icons",
|
||||
"Auto-dismiss with configurable duration",
|
||||
"Enter/exit animations",
|
||||
"Close button",
|
||||
"ARIA: role=alert, aria-live=assertive"
|
||||
]
|
||||
},
|
||||
"accessibility": {
|
||||
"status": "pass",
|
||||
"features": [
|
||||
"File tree: role=tree, role=treeitem, aria-expanded, aria-selected",
|
||||
"Keyboard navigation: Enter/Space on tree items",
|
||||
"Sidebar: aria-label on sections",
|
||||
"StatusBar: role=status, aria-live=polite",
|
||||
"Preview: aria-live=polite, aria-busy during render",
|
||||
"Resize handle: role=separator, aria-orientation, tabIndex"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"developer_experience": {
|
||||
"score": 92,
|
||||
"weight": 0.15,
|
||||
"details": {
|
||||
"test_framework": {
|
||||
"status": "pass",
|
||||
"setup": {
|
||||
"framework": "Vitest 4.1.8",
|
||||
"environment": "jsdom",
|
||||
"setup_file": "@testing-library/jest-dom/vitest",
|
||||
"coverage_provider": "v8",
|
||||
"coverage_targets": ["stores", "lib", "hooks"]
|
||||
},
|
||||
"test_files": [
|
||||
"tabStore.test.ts (22 tests)",
|
||||
"editorStore.test.ts (12 tests)",
|
||||
"fileUtils.test.ts (22 tests)",
|
||||
"markdown.test.ts (16 tests)",
|
||||
"errorHandler.test.ts (12 tests)",
|
||||
"useDocStats.test.ts (12 tests)"
|
||||
]
|
||||
},
|
||||
"git_hooks": {
|
||||
"status": "pass",
|
||||
"setup": {
|
||||
"tool": "Husky 9.1.7",
|
||||
"pre_commit": "lint-staged",
|
||||
"lint_staged": {
|
||||
"ts_tsx": ["eslint --fix", "tsc --noEmit --pretty"],
|
||||
"json_css_md": ["prettier --write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentation": {
|
||||
"status": "pass",
|
||||
"files": ["CONTRIBUTING.md", "README.md", "DESIGN.md", "DEVSETUP.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues_resolved": [
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/components/Editor/useMilkdown.ts",
|
||||
"lines": [73],
|
||||
"description": "state.selection.constructor.create — Property 'create' does not exist on type 'Function'",
|
||||
"fix": "Imported TextSelection from @milkdown/prose/state, replaced with TextSelection.create(tr.doc, from + 1)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/components/Editor/useMilkdown.ts",
|
||||
"lines": [173, 190, 193, 194],
|
||||
"description": "Milkdown plugin type incompatibility: @milkdown/ctx 7.21.2 (top-level) vs 7.21.1 (nested in plugin packages) caused Ctx #private field mismatch",
|
||||
"fix": "Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom-dependency plugins as direct deps, reinstalled to eliminate nested @milkdown/ctx copy",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/hooks/useFolderOperations.ts",
|
||||
"lines": [20, 21, 24, 27],
|
||||
"description": "openFolderDialog() return type mismatch",
|
||||
"fix": "IPC handler changed to return string | null, type definitions aligned (resolved in prior commit)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "critical",
|
||||
"category": "typescript",
|
||||
"file": "src/renderer/types/ipc.ts",
|
||||
"lines": [54, 58],
|
||||
"description": "Duplicate Window.electronAPI declaration",
|
||||
"fix": "Removed duplicate from ipc.ts, preload.d.ts is source of truth (resolved in prior commit)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"category": "eslint",
|
||||
"file": "src/renderer/stores/__tests__/tabStore.test.ts",
|
||||
"lines": [55],
|
||||
"description": "'switchToTab' is destructured but never used in test",
|
||||
"fix": "Test updated (resolved in prior commit)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "eslint/react-refresh",
|
||||
"file": "src/renderer/components/OutlinePanel/OutlinePanel.tsx",
|
||||
"lines": [19],
|
||||
"description": "Fast refresh only works when a file only exports components",
|
||||
"fix": "Extracted parseHeadings() and Heading interface to outlineUtils.ts",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "eslint/react-refresh",
|
||||
"file": "src/renderer/hooks/useStatusBarItems.tsx",
|
||||
"lines": [10, 20, 42, 59, 64],
|
||||
"description": "5 react-refresh/only-export-components warnings — component definitions mixed with hook export",
|
||||
"fix": "Extracted 5 StatusBar item components to StatusBarItems.tsx",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "eslint/react-hooks",
|
||||
"file": "src/renderer/components/Editor/Editor.tsx",
|
||||
"lines": [43, 55],
|
||||
"description": "useEffect hooks have missing dependencies",
|
||||
"fix": "Added eslint-disable comments with explanation (resolved in prior commit)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "eslint/react-hooks",
|
||||
"file": "src/renderer/components/Preview/Preview.tsx",
|
||||
"lines": [53],
|
||||
"description": "useEffect missing 'activeTab' dependency",
|
||||
"fix": "Correctly using optional chaining in deps array (resolved in prior commit)",
|
||||
"status": "fixed"
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "npm",
|
||||
"file": "package.json",
|
||||
"lines": [],
|
||||
"description": "npm allow-scripts warning: electron and esbuild postinstall scripts not covered",
|
||||
"fix": "Added allowScripts config via npm approve-scripts electron esbuild",
|
||||
"status": "fixed"
|
||||
}
|
||||
],
|
||||
"strengths": [
|
||||
"Excellent LRU cache implementation for Markdown processor with proper eviction strategy",
|
||||
"CodeMirror Compartment pattern for efficient theme switching without EditorView reconstruction",
|
||||
"Comprehensive Zustand selector usage preventing unnecessary re-renders",
|
||||
"Well-designed ConfirmDialog with full accessibility support (focus trap, ESC, ARIA)",
|
||||
"LoadingSpinner component with multiple sizes, overlay mode, and proper ARIA attributes",
|
||||
"Toast system supports stacking, types, animations, and accessibility",
|
||||
"Unified error handling with AppError, logError, getErrorMessage, tryAsync patterns",
|
||||
"Good component extraction (FileTree, EditorToolbar, useFolderOperations, outlineUtils, StatusBarItems)",
|
||||
"96 unit tests covering core modules with proper mocking",
|
||||
"Debounce implementation for both preview rendering and DB persistence",
|
||||
"Git hooks with lint-staged for automated code quality checks",
|
||||
"Comprehensive documentation (CONTRIBUTING.md, DESIGN.md, README.md)",
|
||||
"All @milkdown/* packages pinned to exact versions preventing type duplication",
|
||||
"Type-safe IPC architecture with centralized ElectronAPI definition",
|
||||
"npm allow-scripts configured for secure postinstall script management"
|
||||
],
|
||||
"summary": "MarkLite has been fully audited and all issues resolved. TypeScript compilation passes with 0 errors, ESLint passes with 0 errors and 0 warnings, and all 96 tests pass. Key fixes include: (1) @milkdown/ctx version deduplication by pinning all Milkdown packages to exact 7.21.1, (2) TextSelection.create type fix in useMilkdown.ts, (3) Backspace auto-pair dead-code bug fix, (4) react-refresh warnings resolved by extracting utilities and components to separate files, (5) npm allow-scripts configuration for electron and esbuild. The project is production-ready."
|
||||
}
|
||||
@@ -1,286 +1,291 @@
|
||||
<p align="center">
|
||||
<img src="assets/icon.ico" alt="MarkLite" width="128" height="128">
|
||||
</p>
|
||||
|
||||
<h1 align="center">MarkLite</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>轻量级 Windows 本地 Markdown 编辑器</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/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.3.13-orange?style=flat-square" alt="Version">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
|
||||
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
||||
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
||||
| ✏️ **双编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)|
|
||||
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
|
||||
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
||||
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
|
||||
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
|
||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
||||
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 |
|
||||
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
||||
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
|
||||
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
||||
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Node.js** >= 18.x
|
||||
- **npm** >= 9.x
|
||||
- **Windows** 10/11 x64
|
||||
|
||||
### 安装与运行
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||
cd MarkLite
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式(带 HMR 热更新)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 打包为 exe 安装包
|
||||
|
||||
```bash
|
||||
# 打包 Windows x64 NSIS 安装包
|
||||
npm run build
|
||||
|
||||
# 打包为便携版(免安装)
|
||||
npm run build:portable
|
||||
```
|
||||
|
||||
打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。
|
||||
|
||||
### 其他命令
|
||||
|
||||
```bash
|
||||
# TypeScript 类型检查
|
||||
npm run typecheck
|
||||
|
||||
# ESLint 代码检查
|
||||
npm run lint
|
||||
|
||||
# 运行单元测试
|
||||
npm run test
|
||||
|
||||
# 监听模式运行测试
|
||||
npm run test:watch
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
项目使用 **Vitest** + **React Testing Library** 作为测试框架。
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
npm run test
|
||||
|
||||
# 监听模式(开发时持续运行)
|
||||
npm run test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
测试覆盖以下核心模块:
|
||||
- `stores/` — Zustand 状态管理(tabStore, editorStore)
|
||||
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
|
||||
- `hooks/` — 自定义 Hooks
|
||||
|
||||
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|:-------|:-----|
|
||||
| `Ctrl + T` | 新建标签页 |
|
||||
| `Ctrl + W` | 关闭当前标签页 |
|
||||
| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) |
|
||||
| `Ctrl + Shift + Tab` | 切换到上一个标签页 |
|
||||
| `Ctrl + O` | 打开文件 |
|
||||
| `Ctrl + S` | 保存文件 |
|
||||
| `Ctrl + Shift + S` | 另存为 |
|
||||
| `Ctrl + 1` | 编辑模式 |
|
||||
| `Ctrl + 2` | 预览模式 |
|
||||
| `Ctrl + 3` | 源码模式 |
|
||||
| `Ctrl + F` | 搜索 |
|
||||
| `Ctrl + H` | 搜索并替换 |
|
||||
| `Ctrl + B` | 粗体 |
|
||||
| `Ctrl + I` | 斜体 |
|
||||
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
|
||||
| `Esc` | 关闭搜索栏 |
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 组件 | 技术 | 说明 |
|
||||
|:-----|:-----|:-----|
|
||||
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
||||
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
||||
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
||||
| 编辑器 | [Milkdown](https://milkdown.dev/) v7 | WYSIWYG 编辑器 + 源码模式 |
|
||||
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
||||
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
|
||||
| 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 的语法高亮 |
|
||||
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
|
||||
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
|
||||
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
MarkLite/
|
||||
├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tsconfig.node.json # Node 端 TypeScript 配置
|
||||
├── electron.vite.config.ts # electron-vite 构建配置
|
||||
├── vitest.config.ts # Vitest 测试框架配置
|
||||
├── .eslintrc.cjs # ESLint + TypeScript 规则
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
│
|
||||
├── src/
|
||||
│ ├── main/ # 主进程 (Node.js)
|
||||
│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
|
||||
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册
|
||||
│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离
|
||||
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听)
|
||||
│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁
|
||||
│ │
|
||||
│ ├── preload/ # 预加载脚本
|
||||
│ │ └── index.ts # contextBridge 类型安全暴露
|
||||
│ │
|
||||
│ ├── renderer/ # 渲染进程 (React 18)
|
||||
│ │ ├── index.html # 入口 HTML(含 CSP 策略)
|
||||
│ │ ├── main.tsx # React 入口
|
||||
│ │ ├── App.tsx # 根组件:布局编排、全局事件
|
||||
│ │ │
|
||||
│ │ ├── components/ # UI 组件 (23个)
|
||||
│ │ │ ├── Toolbar/ # 工具栏
|
||||
│ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序)
|
||||
│ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏
|
||||
│ │ │ ├── Preview/ # Markdown 预览面板
|
||||
│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea)
|
||||
│ │ │ ├── Sidebar/ # 侧边栏文件树
|
||||
│ │ │ ├── FileTree/ # 递归文件树
|
||||
│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮)
|
||||
│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关)
|
||||
│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则)
|
||||
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
|
||||
│ │ │ ├── Toast/ # Toast 通知
|
||||
│ │ │ ├── ConfirmDialog/ # 确认对话框
|
||||
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
|
||||
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
|
||||
│ │ │ ├── ErrorBoundary/ # 错误边界
|
||||
│ │ │ ├── AboutDialog/ # 关于对话框
|
||||
│ │ │ ├── LoadingSpinner/ # 加载指示器
|
||||
│ │ │ └── Icons.tsx # SVG 图标库
|
||||
│ │ │
|
||||
│ │ ├── stores/ # Zustand 状态管理
|
||||
│ │ │ ├── tabStore.ts # 标签页状态
|
||||
│ │ │ ├── editorStore.ts # 编辑器状态
|
||||
│ │ │ └── sidebarStore.ts # 侧边栏状态
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 自定义 Hooks (19个)
|
||||
│ │ │ ├── useTheme.ts # 暗色/亮色主题
|
||||
│ │ │ ├── useSettings.ts # 视图模式
|
||||
│ │ │ ├── useSettingsInit.ts # 设置初始加载
|
||||
│ │ │ ├── useKeyboard.ts # 全局快捷键
|
||||
│ │ │ ├── useDragDrop.ts # 拖拽打开
|
||||
│ │ │ ├── useFileWatch.ts # 外部修改监听
|
||||
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
|
||||
│ │ │ ├── useAutoSave.ts # 自动保存
|
||||
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
|
||||
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
|
||||
│ │ │ └── ... # 文件操作、IPC监听等
|
||||
│ │ │
|
||||
│ │ ├── lib/ # 工具库
|
||||
│ │ │ ├── markdown.ts # Markdown 渲染管线
|
||||
│ │ │ ├── fileUtils.ts # 文件工具函数
|
||||
│ │ │ ├── errorHandler.ts # 错误处理
|
||||
│ │ │ └── constants.ts # 常量定义
|
||||
│ │ │
|
||||
│ │ ├── db/ # IndexedDB 持久化层
|
||||
│ │ │ ├── schema.ts # Dexie 数据库定义
|
||||
│ │ │ ├── tabRepository.ts # 标签页 CRUD
|
||||
│ │ │ ├── settingsRepository.ts # 设置 CRUD
|
||||
│ │ │ └── recentFilesRepository.ts # 最近文件
|
||||
│ │ │
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── styles/ # 全局样式
|
||||
│ │
|
||||
│ └── shared/ # 主进程/渲染进程共享
|
||||
│ ├── ipc-channels.ts # IPC 通道名常量
|
||||
│ └── types.ts # 共享类型定义
|
||||
│
|
||||
├── assets/
|
||||
│ └── icon.ico # 应用图标
|
||||
├── DESIGN.md # 架构设计文档
|
||||
├── DEVSETUP.md # 开发环境配置指南
|
||||
├── LICENSE # MIT 许可证
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 📝 支持的 Markdown 语法
|
||||
|
||||
- ✅ 标题(h1 ~ h6)
|
||||
- ✅ **粗体** / *斜体* / ~~删除线~~
|
||||
- ✅ 有序列表 / 无序列表
|
||||
- ✅ 任务列表 `- [x]`
|
||||
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
|
||||
- ✅ 行内代码
|
||||
- ✅ 链接 / 图片(支持相对路径)
|
||||
- ✅ 表格
|
||||
- ✅ 引用块
|
||||
- ✅ 水平线
|
||||
- ✅ HTML 内联元素
|
||||
- ✅ GFM(GitHub Flavored Markdown)
|
||||
|
||||
## 🔧 Git Hooks
|
||||
|
||||
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
|
||||
|
||||
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
|
||||
|
||||
```bash
|
||||
# 初始化 git hooks(npm install 时自动执行)
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
如果觉得有用,请点个 ⭐ Star 支持一下!
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="assets/icon.ico" alt="MarkLite" width="128" height="128">
|
||||
</p>
|
||||
|
||||
<h1 align="center">MarkLite</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>轻量级 Windows 本地 Markdown 编辑器</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/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.6.2-orange?style=flat-square" alt="Version">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
|
||||
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
||||
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
||||
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置解析器渲染、格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、Zen 专注模式、粘贴图片转 base64 |
|
||||
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,MetonaEditor 内置解析器渲染 |
|
||||
| 🔤 **代码高亮** | MetonaEditor 内置零依赖高亮器(js / ts / python / bash / css / html / json 等 16 种语言) |
|
||||
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
|
||||
| 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
|
||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
||||
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
|
||||
| 🔎 **多文件搜索** | Ctrl+Shift+F 在已打开文件夹内递归搜索内容,结果定位到行 |
|
||||
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
||||
| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
|
||||
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlark(AriaEngine)持久化,关闭后可恢复 |
|
||||
| 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable) |
|
||||
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
||||
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
||||
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
|
||||
| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 |
|
||||
| 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Node.js** >= 18.x
|
||||
- **npm** >= 9.x
|
||||
- **Windows** 10/11 x64
|
||||
|
||||
### 安装与运行
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||
cd MarkLite
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式(带 HMR 热更新)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 打包为 exe 安装包
|
||||
|
||||
```bash
|
||||
# 打包 Windows x64 NSIS 安装包
|
||||
npm run build
|
||||
|
||||
# 打包为便携版(免安装)
|
||||
npm run build:portable
|
||||
```
|
||||
|
||||
打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。
|
||||
|
||||
### 其他命令
|
||||
|
||||
```bash
|
||||
# TypeScript 类型检查
|
||||
npm run typecheck
|
||||
|
||||
# ESLint 代码检查
|
||||
npm run lint
|
||||
|
||||
# 运行单元测试
|
||||
npm run test
|
||||
|
||||
# 监听模式运行测试
|
||||
npm run test:watch
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
项目使用 **Vitest** + **React Testing Library** 作为测试框架。
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
npm run test
|
||||
|
||||
# 监听模式(开发时持续运行)
|
||||
npm run test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
测试覆盖以下核心模块:
|
||||
- `stores/` — Zustand 状态管理(tabStore, editorStore)
|
||||
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
|
||||
- `hooks/` — 自定义 Hooks
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|:-------|:-----|
|
||||
| `Ctrl + T` | 新建标签页 |
|
||||
| `Ctrl + W` | 关闭当前标签页 |
|
||||
| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) |
|
||||
| `Ctrl + Shift + Tab` | 切换到上一个标签页 |
|
||||
| `Ctrl + O` | 打开文件 |
|
||||
| `Ctrl + S` | 保存文件 |
|
||||
| `Ctrl + Shift + S` | 另存为 |
|
||||
| `Ctrl + F` | 搜索 |
|
||||
| `Ctrl + H` | 搜索并替换 |
|
||||
| `Ctrl + Shift + F` | 多文件搜索(当前文件夹内) |
|
||||
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
|
||||
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
|
||||
| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) |
|
||||
| `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
|
||||
| `?` | 快捷键帮助面板(编辑器内置) |
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 组件 | 技术 | 说明 |
|
||||
|:-----|:-----|:-----|
|
||||
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
||||
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
||||
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
||||
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
|
||||
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
||||
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.7.4 (AriaEngine + KVStore/OPFS) | 标签页状态 & 用户设置持久化 |
|
||||
| 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 安装包 |
|
||||
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
MarkLite/
|
||||
├── package.json # 项目配置 & 依赖 & electron-builder 打包配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tsconfig.node.json # Node 端 TypeScript 配置
|
||||
├── electron.vite.config.ts # electron-vite 构建配置
|
||||
├── vitest.config.ts # Vitest 测试框架配置
|
||||
├── eslint.config.mjs # ESLint + TypeScript 规则
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
│
|
||||
├── src/
|
||||
│ ├── main/ # 主进程 (Node.js)
|
||||
│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁
|
||||
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册
|
||||
│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离
|
||||
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听)
|
||||
│ │ └── window-manager.ts # 窗口创建、关闭拦截、单实例锁
|
||||
│ │
|
||||
│ ├── preload/ # 预加载脚本
|
||||
│ │ └── index.ts # contextBridge 类型安全暴露
|
||||
│ │
|
||||
│ ├── renderer/ # 渲染进程 (React 18)
|
||||
│ │ ├── index.html # 入口 HTML(含 CSP 策略)
|
||||
│ │ ├── main.tsx # React 入口
|
||||
│ │ ├── App.tsx # 根组件:布局编排、全局事件
|
||||
│ │ │
|
||||
│ │ ├── components/ # UI 组件
|
||||
│ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于)
|
||||
│ │ │ ├── TabBar/ # 标签页栏(含拖拽排序、右键菜单)
|
||||
│ │ │ ├── Editor/ # MetonaEditor 编辑器集成
|
||||
│ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲
|
||||
│ │ │ ├── FileTree/ # 递归文件树
|
||||
│ │ │ ├── OutlinePanel/ # 文档大纲(活跃标题高亮)
|
||||
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件)
|
||||
│ │ │ ├── ConfirmDialog/ # 确认对话框
|
||||
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
|
||||
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
|
||||
│ │ │ ├── ErrorBoundary/ # 错误边界
|
||||
│ │ │ ├── AboutDialog/ # 关于对话框
|
||||
│ │ │ ├── LoadingSpinner/ # 加载指示器
|
||||
│ │ │ └── Icons.tsx # SVG 图标库
|
||||
│ │ │
|
||||
│ │ ├── stores/ # Zustand 状态管理
|
||||
│ │ │ ├── tabStore.ts # 标签页状态
|
||||
│ │ │ ├── editorStore.ts # 编辑器状态
|
||||
│ │ │ ├── sidebarStore.ts # 侧边栏状态
|
||||
│ │ │ └── autoSaveStore.ts # 自动保存状态
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # 自定义 Hooks
|
||||
│ │ │ ├── useTheme.ts # 暗色/亮色主题
|
||||
│ │ │ ├── useSettingsInit.ts # 设置初始加载
|
||||
│ │ │ ├── useKeyboard.ts # 全局快捷键
|
||||
│ │ │ ├── useDragDrop.ts # 拖拽打开
|
||||
│ │ │ ├── useFileWatch.ts # 外部修改监听
|
||||
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
|
||||
│ │ │ ├── useAutoSave.ts # 自动保存(Electron IPC)
|
||||
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
|
||||
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
|
||||
│ │ │ ├── useDocStats.ts # 文档统计
|
||||
│ │ │ └── ... # 文件操作、IPC监听等
|
||||
│ │ │
|
||||
│ │ ├── lib/ # 工具库
|
||||
│ │ │ ├── markdown.ts # Markdown 渲染管线(unified/rehype)
|
||||
│ │ │ ├── fileUtils.ts # 文件工具函数
|
||||
│ │ │ ├── errorHandler.ts # 错误处理
|
||||
│ │ │ ├── toast.ts # Toast 通知(MetonaToast)
|
||||
│ │ │ └── constants.ts # 常量定义
|
||||
│ │ │
|
||||
│ │ ├── db/ # MetonaSqlark 持久化层
|
||||
│ │ │ ├── schema.ts # 数据库定义(懒初始化单例)
|
||||
│ │ │ ├── tabRepository.ts # 标签页 CRUD
|
||||
│ │ │ ├── settingsRepository.ts # 设置 CRUD
|
||||
│ │ │ └── recentFilesRepository.ts # 最近文件
|
||||
│ │ │
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── styles/ # 全局样式
|
||||
│ │
|
||||
│ └── shared/ # 主进程/渲染进程共享
|
||||
│ ├── ipc-channels.ts # IPC 通道名常量
|
||||
│ ├── types.ts # 共享类型定义
|
||||
│ └── constants.ts # 共享常量(版本号等)
|
||||
│
|
||||
├── assets/
|
||||
│ └── icon.ico # 应用图标
|
||||
├── DESIGN.md # 架构设计文档
|
||||
├── DEVSETUP.md # 开发环境配置指南
|
||||
├── LICENSE # MIT 许可证
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 📝 支持的 Markdown 语法
|
||||
|
||||
- ✅ 标题(h1 ~ h6)
|
||||
- ✅ **粗体** / *斜体* / ~~删除线~~
|
||||
- ✅ 高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
|
||||
- ✅ 有序列表 / 无序列表
|
||||
- ✅ 任务列表 `- [x]`
|
||||
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
|
||||
- ✅ 行内代码
|
||||
- ✅ 链接 / 图片(支持相对路径、粘贴图片)
|
||||
- ✅ 表格(含列对齐)
|
||||
- ✅ 引用块
|
||||
- ✅ 水平线
|
||||
- ✅ Emoji 短码 `:smile:` `:rocket:`
|
||||
- ✅ Mermaid 图表 ` ```mermaid `
|
||||
- ✅ HTML 内联元素
|
||||
- ✅ GFM(GitHub Flavored Markdown)
|
||||
|
||||
## 🔧 Git Hooks
|
||||
|
||||
项目使用 **Husky** + **lint-staged** 自动执行代码检查:
|
||||
|
||||
- **pre-commit**: 对暂存的 `.ts/.tsx` 文件运行 ESLint 和 TypeScript 类型检查;对 `.json/.css/.md` 文件运行 Prettier 格式化
|
||||
|
||||
```bash
|
||||
# 初始化 git hooks(npm install 时自动执行)
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
如果觉得有用,请点个 ⭐ Star 支持一下!
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-md-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||
<title>MetonaEditor v0.4.1 — 全功能演示</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
||||
[data-md-theme=dark]{--app-bg:#0f1117;--app-text:#e2e8f0;--app-card:#1a1d27;--app-border:#2d3140;--header-bg:linear-gradient(135deg,#0a0d15 0%,#161923 100%)}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--app-bg);color:var(--app-text);min-height:100vh;line-height:1.6;transition:background .3s,color .3s}
|
||||
|
||||
.header{background:var(--header-bg);color:var(--header-text);padding:28px 24px 20px;text-align:center;position:relative;overflow:hidden}
|
||||
.header::before{content:'';position:absolute;top:-60px;right:-60px;width:300px;height:300px;background:radial-gradient(circle,rgba(139,92,246,.3) 0%,transparent 70%);border-radius:50%}
|
||||
.header h1{font-size:1.85rem;font-weight:800;letter-spacing:-.5px;position:relative}
|
||||
.header h1 span.grad{background:linear-gradient(135deg,var(--app-accent),var(--app-accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.header .badges{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:12px;position:relative}
|
||||
.header .badge{padding:4px 12px;border-radius:20px;font-size:13px;font-weight:500;background:var(--badge-bg);color:var(--badge-text);border:1px solid rgba(255,255,255,.1)}
|
||||
|
||||
.toolbar-strip{display:flex;align-items:center;gap:8px;padding:8px 20px;background:var(--app-card);border-bottom:1px solid var(--app-border);flex-wrap:wrap;position:sticky;top:0;z-index:50;transition:background .3s}
|
||||
.toolbar-strip label{font-size:12px;font-weight:500;color:var(--app-text);opacity:.7;white-space:nowrap}
|
||||
.toolbar-strip select{padding:4px 8px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;background:var(--app-bg);color:var(--app-text);cursor:pointer}
|
||||
.toolbar-strip button{padding:5px 12px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;font-weight:500;background:var(--app-card);color:var(--app-text);cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||
.toolbar-strip button:hover{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
|
||||
.toolbar-strip button.on{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
|
||||
.toolbar-strip .spacer{flex:1}
|
||||
|
||||
.editor-section{max-width:1280px;margin:20px auto;padding:0 20px}
|
||||
.editor-card{border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.06);background:var(--app-card);border:1px solid var(--app-border);transition:.3s}
|
||||
.editor-card:hover{box-shadow:0 8px 36px rgba(0,0,0,.1)}
|
||||
|
||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;max-width:1280px;margin:20px auto;padding:0 20px}
|
||||
.info-card{background:var(--app-card);border:1px solid var(--app-border);border-radius:10px;padding:16px 18px;transition:.3s}
|
||||
.info-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08)}
|
||||
.info-card .icon{font-size:22px;margin-bottom:6px}
|
||||
.info-card h4{font-size:13px;font-weight:650;margin-bottom:3px}
|
||||
.info-card p{font-size:12px;color:var(--app-text);opacity:.65;margin:0}
|
||||
|
||||
.footer{text-align:center;padding:28px 24px;font-size:13px;color:var(--app-text);opacity:.5}
|
||||
.footer a{color:var(--app-accent);text-decoration:none}
|
||||
@media(max-width:768px){.header h1{font-size:1.4rem}.toolbar-strip{padding:6px 10px;gap:4px}.toolbar-strip button{padding:4px 8px;font-size:11px}.info-grid{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<h1><span class="grad">Metona</span>Editor v0.4.1</h1>
|
||||
<div class="badges">
|
||||
<span class="badge">TypeScript</span>
|
||||
<span class="badge">零运行时依赖</span>
|
||||
<span class="badge">841 tests</span>
|
||||
<span class="badge">6 个插件</span>
|
||||
<span class="badge">桌面端优先</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar-strip" id="bar">
|
||||
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:4px" title="首页">🏠</a>
|
||||
<label>主题</label>
|
||||
<select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
|
||||
<label>语言</label>
|
||||
<select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option><option value="fr">Français</option><option value="de">Deutsch</option></select>
|
||||
<label>模式</label>
|
||||
<button data-cmd="mode" data-arg="edit">编辑</button>
|
||||
<button data-cmd="mode" data-arg="split" class="on">分屏</button>
|
||||
<button data-cmd="mode" data-arg="preview">预览</button>
|
||||
<div class="spacer"></div>
|
||||
<button data-cmd="zen" title="Zen 专注模式">🧘 Zen</button>
|
||||
<button data-cmd="outline" title="大纲面板">📋 大纲</button>
|
||||
<button data-cmd="wrap" title="切换换行">↩ 换行</button>
|
||||
<button data-cmd="float" title="浮动工具栏" class="on">💬 浮动栏</button>
|
||||
<button data-cmd="toast" title="Toast 通知">💬 Toast</button>
|
||||
<button data-cmd="openFile" title="打开文件">📂 打开</button>
|
||||
<button data-cmd="saveFile" title="保存文件">💾 保存</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<div class="editor-card"><div id="editor"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>17 个 .ts 源文件,严格模式,完整类型导出</p></div>
|
||||
<div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
|
||||
<div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 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>工具栏自动隐藏,宽度可配置(zenMaxWidth,默认 960px)</p></div>
|
||||
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
||||
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
||||
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
||||
<div class="info-card"><div class="icon">⚡</div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
MetonaEditor v0.4.1 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<script src="../dist/metona-editor.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
var demoMd=[
|
||||
'# 🚀 MetonaEditor v0.4.1 全功能演示',
|
||||
'',
|
||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||
'> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。',
|
||||
'',
|
||||
'## ✨ v0.4.1 新特性',
|
||||
'',
|
||||
'- **Zen 专注模式宽度可配置**:`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽',
|
||||
'- **修复 5 处问题**:`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新',
|
||||
'- **Playwright 浏览器冒烟测试**:22 项断言真实 Chromium 验证',
|
||||
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
|
||||
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
|
||||
'',
|
||||
'## 📐 文本样式',
|
||||
'',
|
||||
'- **粗体** / __另一种粗体__',
|
||||
'- ***粗斜体*** / ___另一种粗斜体___',
|
||||
'- *斜体* / _另一种斜体_',
|
||||
'- ~~删除线~~ / ==高亮标记==',
|
||||
'- <u>下划线</u>(Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
|
||||
'- 化学:H~2~O / 数学:x^2^',
|
||||
'- 代码:`const x = 42;`',
|
||||
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
||||
'',
|
||||
'## 📊 数学公式 & 图表',
|
||||
'',
|
||||
'行内:$E = mc^2$',
|
||||
'块级:$$\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[TypeScript] --> B[Rollup]',
|
||||
' B --> C[UMD/ESM/CJS]',
|
||||
' C --> D[Browser]',
|
||||
'```',
|
||||
'',
|
||||
'## 📋 表格与对齐',
|
||||
'',
|
||||
'| 版本 | 日期 | 测试 | 主题 |',
|
||||
'| :--- | :---: | ---: | --- |',
|
||||
'| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
|
||||
'| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
|
||||
'| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
|
||||
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
|
||||
'| v0.2.5 | 2026-08 | 782 | 内置高亮+安全修复 |',
|
||||
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
|
||||
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
||||
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
||||
'| v0.1.15 | 2026-07 | 583 | 解析器增强 |',
|
||||
'| v0.1.14 | 2026-07 | 535 | 语法高亮+Mermaid |',
|
||||
'',
|
||||
'## ✅ 任务列表',
|
||||
'',
|
||||
'- [x] TypeScript 全模块重构',
|
||||
'- [x] 解析器 99% 行覆盖率',
|
||||
'- [x] parseTokens / renderTokens 分离',
|
||||
'- [x] registerBlockHandler 扩展',
|
||||
'- [x] 6 个预设插件完整测试',
|
||||
'- [ ] VS Code 扩展',
|
||||
'',
|
||||
'## 🎯 嵌套列表',
|
||||
'',
|
||||
'1. 一级有序',
|
||||
' 1. 二级有序(自动递增)',
|
||||
' 2. 第二项',
|
||||
'2. 回到一级',
|
||||
' - 混杂无序列表',
|
||||
' - 第二项',
|
||||
' - [x] 三级嵌套 + 任务',
|
||||
'',
|
||||
'## 💻 代码块',
|
||||
'',
|
||||
'```ts',
|
||||
'import MeEditor from "@metona-team/metona-editor";',
|
||||
'',
|
||||
'const editor = MeEditor.create("#editor", {',
|
||||
' mode: "split",',
|
||||
' theme: "dark",',
|
||||
' lineNumbers: true,',
|
||||
' autoBrackets: true,',
|
||||
' plugins: ["autoSave", "fileSystem", "shortcutHelp"],',
|
||||
' onChange: (value, editor) => {',
|
||||
' console.log("changed:", value.length, "chars");',
|
||||
' },',
|
||||
'});',
|
||||
'',
|
||||
'// 链式 API',
|
||||
'editor.exec("bold").exec("h1").focus();',
|
||||
'```',
|
||||
'',
|
||||
'```python',
|
||||
'def fib(n):',
|
||||
' """返回斐波那契数列第 n 项"""',
|
||||
' a, b = 0, 1',
|
||||
' for _ in range(n):',
|
||||
' a, b = b, a + b',
|
||||
' return a',
|
||||
'',
|
||||
'print(fib(10)) # 55',
|
||||
'```',
|
||||
'',
|
||||
'```bash',
|
||||
'# 安装(Gitea 私有源)',
|
||||
'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
|
||||
'npm run dev',
|
||||
'```',
|
||||
'',
|
||||
'## 📌 脚注',
|
||||
'',
|
||||
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
|
||||
'',
|
||||
'[^1]: 脚注可以包含 **格式化** 和 `代码`。',
|
||||
'[^note]: 第二个脚注的说明。',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'> **提示:** 按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 查找 · Ctrl+H 替换',
|
||||
'> 在列表行尾 Enter 体验智能延续 · 选中文字后按 `(` 体验自动包裹',
|
||||
'> 点击「📂 打开」加载磁盘 .md 文件 · 点击「💾 保存」写入磁盘',
|
||||
].join('\n');
|
||||
|
||||
var editor = MeEditor.create('#editor',{
|
||||
value: demoMd,
|
||||
mode: 'split', height: 620,
|
||||
lineNumbers: true, autoBrackets: true, wordCount: true,
|
||||
theme: 'auto', locale: 'zh-CN', outline: false,
|
||||
highlight: MeEditor.highlight,
|
||||
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
|
||||
onLinkClick: function(uri, text, ed){
|
||||
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
|
||||
}
|
||||
});
|
||||
|
||||
// Control bar
|
||||
var bar = document.getElementById('bar');
|
||||
|
||||
document.getElementById('selTheme').addEventListener('change', function(){
|
||||
editor.setTheme(this.value);
|
||||
});
|
||||
document.getElementById('selLocale').addEventListener('change', function(){
|
||||
editor.setLocale(this.value);
|
||||
});
|
||||
|
||||
bar.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('button[data-cmd]');
|
||||
if (!btn) return;
|
||||
var cmd = btn.dataset.cmd;
|
||||
if (cmd === 'mode') { editor.setMode(btn.dataset.arg); updateModeBtns(btn.dataset.arg); }
|
||||
else if (cmd === 'zen') { editor.toggleZen(); btn.classList.toggle('on', editor.isZen()); }
|
||||
else if (cmd === 'outline') { toggleOutline(btn); }
|
||||
else if (cmd === 'wrap') { editor.toggleWordWrap(); btn.classList.toggle('on', !editor.isWordWrap()); }
|
||||
else if (cmd === 'float') {
|
||||
editor.toggleFloatingToolbar();
|
||||
btn.classList.toggle('on', editor.isFloatingToolbar());
|
||||
}
|
||||
else if (cmd === 'toast') {
|
||||
var types = ['success','error','warning','info'];
|
||||
var msgs = { success: '✅ 操作成功', error: '❌ 操作失败', warning: '⚠️ 注意', info: 'ℹ️ 提示信息' };
|
||||
editor.toast(msgs[types[Math.floor(Math.random()*4)]], { type: types[Math.floor(Math.random()*4)], duration: 2000 });
|
||||
}
|
||||
else if (cmd === 'openFile') { editor.openFile?.(); }
|
||||
else if (cmd === 'saveFile') { editor.saveFile?.(); }
|
||||
});
|
||||
|
||||
function updateModeBtns(mode){
|
||||
bar.querySelectorAll('[data-cmd=mode]').forEach(function(b){ b.classList.toggle('on', b.dataset.arg === mode); });
|
||||
}
|
||||
|
||||
function toggleOutline(btn){
|
||||
editor.config.outline = !editor.config.outline;
|
||||
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); }
|
||||
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); }
|
||||
}
|
||||
|
||||
// Custom shortcut
|
||||
editor.registerShortcut('Ctrl+Shift+T', function(){
|
||||
editor.toast('自定义快捷键已触发!', { type: 'info' });
|
||||
}, 'Toast 通知演示');
|
||||
|
||||
// Events
|
||||
editor.on('themeChange', function(d){ document.getElementById('selTheme').value = d.theme; });
|
||||
editor.on('modeChange', function(m){ updateModeBtns(m); });
|
||||
editor.on('fileOpened', function(d){ editor.toast('已打开: ' + d.name, { type: 'success' }); });
|
||||
editor.on('fileSaved', function(){ editor.toast('文件已保存', { type: 'success' }); });
|
||||
|
||||
// Mermaid
|
||||
function renderMermaid(){
|
||||
if (typeof mermaid === 'undefined') return;
|
||||
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }); } catch(e) {}
|
||||
}
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
editor.on('afterRender', renderMermaid);
|
||||
setTimeout(renderMermaid, 300);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,511 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
||||
<title>MetonaEditor v0.4.1 · 文档</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
||||
html{scroll-behavior:smooth}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}
|
||||
.container{max-width:1000px;margin:0 auto;padding:0 24px}
|
||||
nav{position:sticky;top:0;z-index:100;background:rgba(15,17,23,.85);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.nav-inner{max-width:1000px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;justify-content:space-between}
|
||||
.nav-brand{display:inline-flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:15px}
|
||||
.nav-brand .logo-mark{width:26px;height:26px;border-radius:6px;background:var(--gradient);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.logo-mark svg{display:block}
|
||||
.nav-links{display:flex;gap:22px;align-items:center}
|
||||
.nav-links a{color:var(--muted);text-decoration:none;font-size:13.5px;transition:color .15s}
|
||||
.nav-links a:hover,.nav-links a.active{color:var(--text)}
|
||||
.nav-links a.active{color:var(--accent);font-weight:600}
|
||||
|
||||
header.docs-header{text-align:center;padding:64px 24px 40px;background:radial-gradient(ellipse 60% 50% at 50% 0%,var(--accent-soft),transparent 70%),var(--bg)}
|
||||
header h1{font-size:clamp(28px,4vw,44px);font-weight:800;letter-spacing:-.02em;margin-bottom:10px}
|
||||
header h1 .grad{background:var(--gradient);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
header .sub{color:var(--muted);font-size:15px}
|
||||
|
||||
.docs-layout{display:flex;gap:32px;padding:40px 0 80px}
|
||||
.sidebar{position:sticky;top:72px;width:200px;flex-shrink:0;max-height:calc(100vh - 100px);overflow-y:auto;padding-right:16px;border-right:1px solid var(--border)}
|
||||
.sidebar a{display:block;padding:6px 12px;border-radius:6px;color:var(--muted);text-decoration:none;font-size:13.5px;transition:background .15s,color .15s;margin-bottom:2px}
|
||||
.sidebar a:hover,.sidebar a.active{background:var(--card);color:var(--text)}
|
||||
.sidebar .toc-group{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;padding:16px 12px 6px;font-weight:600}
|
||||
.content{flex:1;min-width:0}
|
||||
.content section{margin-bottom:48px}
|
||||
.content h2{font-size:24px;font-weight:700;letter-spacing:-.02em;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid var(--border)}
|
||||
.content h3{font-size:18px;font-weight:600;margin:28px 0 14px}
|
||||
.content h4{font-size:15px;font-weight:600;margin:20px 0 10px;color:var(--accent)}
|
||||
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:13.5px}
|
||||
th,td{padding:10px 14px;text-align:left;border:1px solid var(--border)}
|
||||
th{background:var(--card);font-weight:600;color:var(--text)}
|
||||
td{color:var(--muted)}
|
||||
tr:nth-child(even) td{background:var(--bg-soft)}
|
||||
|
||||
code{font-family:"SF Mono","Cascadia Code",Consolas,monospace;font-size:13px;padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;color:var(--accent)}
|
||||
pre{background:var(--bg-soft);border:1px solid var(--border);border-radius:10px;padding:18px 20px;overflow-x:auto;font-family:"SF Mono","Cascadia Code",Consolas,monospace;font-size:13px;line-height:1.8;color:#e6e8eb;margin:14px 0}
|
||||
.c-key{color:#c792ea}.c-str{color:#c3e88d}.c-fn{color:#82aaff}.c-com{color:#5c6370;font-style:italic}.c-kw{color:#89ddff}.c-num{color:#f78c6c}.c-punc{color:#89ddff;opacity:.7}
|
||||
.badge{display:inline-block;padding:3px 10px;background:var(--accent-soft);color:var(--accent);border-radius:999px;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:4px}
|
||||
|
||||
footer{border-top:1px solid var(--border);padding:36px 24px;text-align:center;color:var(--muted);font-size:13px;background:var(--bg-soft)}
|
||||
footer a{color:var(--accent);text-decoration:none}
|
||||
|
||||
@media(max-width:768px){.docs-layout{flex-direction:column}.sidebar{position:static;width:100%;max-height:none;border-right:0;border-bottom:1px solid var(--border);padding-bottom:12px;display:flex;flex-wrap:wrap;gap:4px}.sidebar .toc-group{width:100%}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="index.html"><span class="logo-mark"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V6l7 8 7-8v13"/></svg></span>MetonaEditor</a>
|
||||
<div class="nav-links"><a href="index.html">首页</a><a href="demo.html">演示</a><a href="docs.html" class="active">文档</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="docs-header">
|
||||
<h1><span class="grad">API 文档</span></h1>
|
||||
<p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、API 与使用指南</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="docs-layout">
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="toc-group">快速入门</div>
|
||||
<a href="#quickstart">安装与引入</a>
|
||||
<div class="toc-group">配置</div>
|
||||
<a href="#config">全部配置项</a>
|
||||
<a href="#toolbar">工具栏</a>
|
||||
<div class="toc-group">实例 API</div>
|
||||
<a href="#api-content">内容操作</a>
|
||||
<a href="#api-cursor">光标与选区</a>
|
||||
<a href="#api-exec">命令执行</a>
|
||||
<a href="#api-history">历史栈</a>
|
||||
<a href="#api-mode">模式 / 全屏 / Zen</a>
|
||||
<a href="#api-stats">统计与状态</a>
|
||||
<a href="#api-readonly">启用 / 禁用 / 只读</a>
|
||||
<a href="#api-events">事件系统</a>
|
||||
<a href="#api-plugin">插件管理</a>
|
||||
<a href="#api-shortcuts">快捷键管理</a>
|
||||
<a href="#api-destroy">生命周期</a>
|
||||
<div class="toc-group">静态 API</div>
|
||||
<a href="#static-api">全局函数</a>
|
||||
<div class="toc-group">语法高亮</div>
|
||||
<a href="#highlight">内置高亮器</a>
|
||||
<div class="toc-group">解析器</div>
|
||||
<a href="#parser-syntax">语法参考</a>
|
||||
<a href="#parser-api">解析器 API</a>
|
||||
<div class="toc-group">插件</div>
|
||||
<a href="#plugins-convention">插件约定</a>
|
||||
<a href="#preset-plugins">预设插件</a>
|
||||
<div class="toc-group">主题</div>
|
||||
<a href="#themes">主题系统</a>
|
||||
<div class="toc-group">国际化</div>
|
||||
<a href="#i18n">i18n API</a>
|
||||
</div>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<!-- 快速入门 -->
|
||||
<section id="quickstart">
|
||||
<h2>安装与引入</h2>
|
||||
<pre><span class="c-com"># npm 安装(Gitea 私有源)</span>
|
||||
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
|
||||
|
||||
<span class="c-com">// 或项目 .npmrc 配置 scope 后直接安装</span>
|
||||
<span class="c-com">// @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/</span>
|
||||
|
||||
<span class="c-com">// TypeScript / ES Module</span>
|
||||
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||
<span class="c-fn">value</span><span class="c-punc">:</span> <span class="c-str">'# Hello World'</span><span class="c-punc">,</span>
|
||||
<span class="c-fn">mode</span><span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span>
|
||||
<span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 浏览器 UMD</span>
|
||||
<span class="c-com"><script src="dist/metona-editor.js"></script></span>
|
||||
<span class="c-com"><script></span>
|
||||
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">);</span>
|
||||
<span class="c-com"></script></span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 配置 -->
|
||||
<section id="config">
|
||||
<h2>全部配置项</h2>
|
||||
<p style="color:var(--muted);margin-bottom:14px">所有配置均为可选。</p>
|
||||
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||
<span class="c-com">// 内容</span>
|
||||
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span>
|
||||
id<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 实例 id(默认自动生成,隔离存储键)</span>
|
||||
|
||||
<span class="c-com">// 视图</span>
|
||||
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||
toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> wordCount<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||
lineNumbers<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> outline<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
|
||||
|
||||
<span class="c-com">// 行为</span>
|
||||
autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
|
||||
readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span>
|
||||
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||
<span class="c-com">// Zen 内容区最大宽度(数字为 px / 字符串为 CSS 值 / false 不限宽)</span>
|
||||
zenMaxWidth<span class="c-punc">:</span> <span class="c-num">960</span><span class="c-punc">,</span>
|
||||
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
|
||||
floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
|
||||
|
||||
<span class="c-com">// 主题 / 语言</span>
|
||||
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
|
||||
|
||||
<span class="c-com">// 钩子</span>
|
||||
render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
|
||||
<span class="c-com">// 外观</span>
|
||||
className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> style<span class="c-punc">:</span> <span class="c-punc">{},</span>
|
||||
|
||||
<span class="c-com">// 插件 / 回调</span>
|
||||
plugins<span class="c-punc">:</span> <span class="c-punc">[],</span>
|
||||
onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onLinkClick<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
|
||||
<span class="c-punc">});</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<section id="toolbar">
|
||||
<h2>工具栏</h2>
|
||||
<pre><span class="c-com">// 默认完整工具栏</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">true</span> <span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 隐藏</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">false</span> <span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 自定义 | 为分隔符</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">,</span> <span class="c-str">'redo'</span><span class="c-punc">]</span> <span class="c-punc">});</span></pre>
|
||||
<p>可用动作:</p>
|
||||
<p>
|
||||
<span class="badge">bold</span><span class="badge">italic</span><span class="badge">strikethrough</span><span class="badge">underline</span><span class="badge">code</span>
|
||||
<span class="badge">h1</span><span class="badge">h2</span><span class="badge">h3</span><span class="badge">quote</span><span class="badge">ul</span><span class="badge">ol</span>
|
||||
<span class="badge">indent</span><span class="badge">outdent</span><span class="badge">hr</span>
|
||||
<span class="badge">link</span><span class="badge">image</span><span class="badge">table</span>
|
||||
<span class="badge">undo</span><span class="badge">redo</span>
|
||||
<span class="badge">edit</span><span class="badge">split</span><span class="badge">preview</span><span class="badge">fullscreen</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 实例 API -->
|
||||
<section id="api-content"><h2>实例 API</h2>
|
||||
<h3>内容操作</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">getValue</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setValue</span><span class="c-punc">(</span>md<span class="c-punc">,</span> <span class="c-punc">{</span> silent <span class="c-punc">});</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制重渲染</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">replaceAll</span><span class="c-punc">(</span>search<span class="c-punc">,</span> replace<span class="c-punc">,</span> caseSensitive<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">replaceAllRegex</span><span class="c-punc">(</span>pattern<span class="c-punc">,</span> replace<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">lineCount</span><span class="c-punc">();</span> <span class="c-fn">getLine</span><span class="c-punc">(</span>n<span class="c-punc">);</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">copyAsMarkdown</span><span class="c-punc">();</span> <span class="c-fn">copyAsHTML</span><span class="c-punc">();</span> <span class="c-com">// 剪贴板</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-cursor"><h3>光标与选区</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">getSelectedText</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getCursorPosition</span><span class="c-punc">();</span> <span class="c-com">// => { line, column }</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setCursorPosition</span><span class="c-punc">(</span>line<span class="c-punc">,</span> column<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">scrollToLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">selectLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span class="c-punc">();</span> <span class="c-com">// => this</span></pre>
|
||||
<p>伴随事件 <code>selectionChange</code>(<code>{ start, end, text }</code>)与 <code>cursorMove</code>(<code>{ line, column }</code>)。</p>
|
||||
</section>
|
||||
|
||||
<section id="api-exec"><h3>命令执行</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre>
|
||||
<table>
|
||||
<tr><th>action</th><th>效果</th><th>快捷键</th></tr>
|
||||
<tr><td><code>bold</code></td><td>**text**</td><td>Ctrl+B</td></tr>
|
||||
<tr><td><code>italic</code></td><td>*text*</td><td>Ctrl+I</td></tr>
|
||||
<tr><td><code>strikethrough</code></td><td>~~text~~</td><td>—</td></tr>
|
||||
<tr><td><code>underline</code></td><td><u>text</u></td><td>Ctrl+U</td></tr>
|
||||
<tr><td><code>code</code></td><td>`text`</td><td>Ctrl+E</td></tr>
|
||||
<tr><td><code>h1</code>/<code>h2</code>/<code>h3</code></td><td># / ## / ###</td><td>Ctrl+1/2/3</td></tr>
|
||||
<tr><td><code>quote</code></td><td>> text</td><td>Ctrl+Q</td></tr>
|
||||
<tr><td><code>ul</code>/<code>ol</code></td><td>- / 1. 列表</td><td>—</td></tr>
|
||||
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
|
||||
<tr><td><code>image</code></td><td></td><td>—</td></tr>
|
||||
<tr><td><code>table</code></td><td>插入表格</td><td>—</td></tr>
|
||||
<tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td>—</td></tr>
|
||||
<tr><td><code>hr</code></td><td>---水平线</td><td>—</td></tr>
|
||||
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
|
||||
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr>
|
||||
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td>—</td></tr>
|
||||
<tr><td><code>fullscreen</code></td><td>全屏</td><td>—</td></tr>
|
||||
<tr><td><code>zen</code></td><td>Zen 模式</td><td>—</td></tr>
|
||||
<tr><td><code>wordwrap</code></td><td>自动换行</td><td>—</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="api-history"><h3>历史栈</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">undo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-mode"><h3>模式 / 全屏 / Zen / 浮动工具栏</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span> <span class="c-com">// 或配置 zenMode: true 启动即进入</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false)</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleWordWrap</span><span class="c-punc">();</span> <span class="c-fn">setWordWrap</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isWordWrap</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toggleFloatingToolbar</span><span class="c-punc">();</span> <span class="c-fn">isFloatingToolbar</span><span class="c-punc">();</span> <span class="c-com">// 选中文本格式栏</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-stats"><h3>统计与状态</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">getStats</span><span class="c-punc">();</span>
|
||||
<span class="c-com">// => { characters, words, chineseChars, englishWords, lines, readingTime }</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
|
||||
<span class="c-com">// => { id, mode, theme, locale, fullscreen, readOnly, disabled, destroyed, plugins }</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-readonly"><h3>启用 / 禁用 / 只读</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">enable</span><span class="c-punc">();</span> <span class="c-fn">disable</span><span class="c-punc">();</span> <span class="c-fn">isDisabled</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isReadOnly</span><span class="c-punc">();</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-events"><h3>事件系统</h3>
|
||||
<table>
|
||||
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
|
||||
<tr><td><code>input</code></td><td>textarea 输入</td><td><code>(value, editor)</code></td></tr>
|
||||
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
|
||||
<tr><td><code>beforeChange</code> / <code>afterChange</code></td><td>内容变更前后</td><td><code>(oldValue, newValue, editor)</code></td></tr>
|
||||
<tr><td><code>focus</code> / <code>blur</code></td><td>聚焦/失焦</td><td><code>(editor)</code></td></tr>
|
||||
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(editor)</code></td></tr>
|
||||
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
|
||||
<tr><td><code>fullscreen</code></td><td>全屏切换</td><td><code>(fullscreen, editor)</code></td></tr>
|
||||
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
|
||||
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
|
||||
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
|
||||
<tr><td><code>selectionChange</code></td><td>选区变化</td><td><code>({ start, end, text })</code></td></tr>
|
||||
<tr><td><code>cursorMove</code></td><td>光标移动</td><td><code>({ line, column })</code></td></tr>
|
||||
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
|
||||
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>渲染前后</td><td><code>(editor)</code></td></tr>
|
||||
<tr><td><code>autosave</code></td><td>autoSave 触发</td><td><code>({ key, value })</code></td></tr>
|
||||
<tr><td><code>linkClick</code></td><td>预览链接点击</td><td><code>({ href, text })</code></td></tr>
|
||||
<tr><td><code>copy</code></td><td>复制到剪贴板</td><td><code>({ type })</code></td></tr>
|
||||
<tr><td><code>fileOpened</code> / <code>fileSaved</code></td><td>fileSystem 读写</td><td><code>({ name, handle })</code></td></tr>
|
||||
</table>
|
||||
<h4>全局钩子</h4>
|
||||
<table>
|
||||
<tr><th>钩子</th><th>触发时机</th></tr>
|
||||
<tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
|
||||
<tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
|
||||
<tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="api-plugin"><h3>插件管理</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-shortcuts"><h3>快捷键管理</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">registerShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">,</span> handler<span class="c-punc">,</span> <span class="c-str">'desc'</span><span class="c-punc">);</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">unregisterShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">);</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span class="c-punc">();</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-fn">isDestroyed</span><span class="c-punc">();</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
|
||||
<p>toast 动画支持 <code>fade</code> / <code>slide</code> / <code>scale</code> / <code>bounce</code> / <code>flip</code> / <code>rotate</code> / <code>zoom</code> 七种,<code>duration: 0</code> 不自动消失。</p>
|
||||
</section>
|
||||
|
||||
<!-- 静态 API -->
|
||||
<section id="static-api"><h2>静态 API</h2>
|
||||
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
|
||||
<span class="c-com">// 工厂函数</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> options<span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 全局默认插件</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">,</span> <span class="c-punc">{</span> delay<span class="c-punc">:</span> <span class="c-num">2000</span> <span class="c-punc">});</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>customPlugin<span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 全局钩子</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> <span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> handler<span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 全局主题 / 语言</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 解析器独立使用</span>
|
||||
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
|
||||
<span class="c-com">// 内置语法高亮</span>
|
||||
<span class="c-kw">import</span> <span class="c-punc">{</span> highlight<span class="c-punc">,</span> registerLanguage <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 语法高亮 -->
|
||||
<section id="highlight"><h2>内置语法高亮</h2>
|
||||
<p>v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。</p>
|
||||
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
|
||||
<span class="c-com">// 方式一:作为 highlight 钩子(代码块自动高亮)</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span> highlight<span class="c-punc">:</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span> <span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 方式二:独立调用</span>
|
||||
<span class="c-kw">const</span> html <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span><span class="c-punc">(</span>code<span class="c-punc">,</span> <span class="c-str">'typescript'</span><span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 注册自定义语言</span>
|
||||
MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><span class="c-punc">(</span><span class="c-str">'myLang'</span><span class="c-punc">,</span> <span class="c-punc">{</span> keywords<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'kw'</span><span class="c-punc">]</span><span class="c-punc">,</span> builtins<span class="c-punc">:</span> <span class="c-punc">[]</span> <span class="c-punc">});</span></pre>
|
||||
<p>高亮输出使用 <code>me-hl-keyword</code> / <code>me-hl-string</code> / <code>me-hl-comment</code> / <code>me-hl-number</code> / <code>me-hl-builtin</code> / <code>me-hl-function</code> 类。内置浅色 / 深色自适应配色,可通过 CSS 覆盖定制:</p>
|
||||
<pre><span class="c-com">/* 自定义配色(覆盖内置默认) */</span>
|
||||
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-keyword</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#a855f7</span> <span class="c-punc">}</span>
|
||||
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-string</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#22c55e</span> <span class="c-punc">}</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 解析器语法 -->
|
||||
<section id="parser-syntax"><h2>解析器语法参考</h2>
|
||||
<table>
|
||||
<tr><th>语法</th><th>示例</th><th>输出</th></tr>
|
||||
<tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td><h1> ~ <h6></td></tr>
|
||||
<tr><td>Setext 标题</td><td><code>H1\n===</code></td><td><h1> / <h2></td></tr>
|
||||
<tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td><strong></td></tr>
|
||||
<tr><td>粗斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td><em><strong></td></tr>
|
||||
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><em></td></tr>
|
||||
<tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td><del> / <mark></td></tr>
|
||||
<tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code><u>text</u></code>(裸标签透传)</td><td><u></td></tr>
|
||||
<tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td><sup> / <sub></td></tr>
|
||||
<tr><td>行内代码</td><td><code>`code`</code></td><td><code></td></tr>
|
||||
<tr><td>代码块</td><td><code>```lang</code></td><td><pre><code></td></tr>
|
||||
<tr><td>缩进代码块</td><td><code> code</code></td><td><pre><code></td></tr>
|
||||
<tr><td>引用</td><td><code>> quote</code></td><td><blockquote></td></tr>
|
||||
<tr><td>无序列表</td><td><code>- * + item</code></td><td><ul><li></td></tr>
|
||||
<tr><td>有序列表</td><td><code>1. item</code></td><td><ol><li></td></tr>
|
||||
<tr><td>嵌套列表</td><td>多级缩进</td><td>多级 <ul>/<ol></td></tr>
|
||||
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></td></tr>
|
||||
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><hr></td></tr>
|
||||
<tr><td>表格</td><td><code>| a | b |</code></td><td><table> + 列对齐</td></tr>
|
||||
<tr><td>表格格式化 <span class="badge">v0.2.2</span></td><td><code>exec('formatTable')</code></td><td>列宽对齐</td></tr>
|
||||
<tr><td>链接 <span class="badge">v0.1.15</span></td><td><code>[**t**](url)</code></td><td>文本内支持行内格式</td></tr>
|
||||
<tr><td>图片</td><td><code></code></td><td><img></td></tr>
|
||||
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
||||
<tr><td>引用图片 <span class="badge">v0.2.2</span></td><td><code>![alt][ref]</code> + <code>[ref]: url</code></td><td><img> 引用式</td></tr>
|
||||
<tr><td>自动链接</td><td><code><https://...></code></td><td><a></td></tr>
|
||||
<tr><td>数学公式</td><td><code>$E=mc^2$</code></td><td><span> / <div></td></tr>
|
||||
<tr><td>脚注</td><td><code>text[^1]</code></td><td><sup> + 底部</td></tr>
|
||||
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td><dl><dt><dd></td></tr>
|
||||
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+)</td></tr>
|
||||
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
||||
</table>
|
||||
<h4>XSS 防护</h4>
|
||||
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,属性级注入防护(<code>href</code> / <code>src</code> / <code>alt</code> / <code>title</code> / 脚注 id 引号转义),白名单裸标签 <code><u></code> 透传,提供 <code>sanitize</code> 钩子。</p>
|
||||
</section>
|
||||
|
||||
<section id="parser-api"><h2>解析器 API</h2>
|
||||
<h3>parseTokens / renderTokens</h3>
|
||||
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
|
||||
<span class="c-kw">const</span> <span class="c-punc">{</span> tokens<span class="c-punc">,</span> footnotes <span class="c-punc">}</span> <span class="c-punc">=</span> <span class="c-fn">parseTokens</span><span class="c-punc">(</span><span class="c-str">'# Hello\n\n**world**'</span><span class="c-punc">);</span>
|
||||
tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class="c-punc">({</span> type<span class="c-punc">:</span> <span class="c-str">'hr'</span> <span class="c-punc">});</span> <span class="c-com">// 渲染前变换</span>
|
||||
<span class="c-kw">const</span> html <span class="c-punc">=</span> <span class="c-fn">renderTokens</span><span class="c-punc">(</span>tokens<span class="c-punc">,</span> env<span class="c-punc">,</span> footnotes<span class="c-punc">);</span></pre>
|
||||
|
||||
<h3>registerBlockHandler</h3>
|
||||
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> registerBlockHandler <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
<span class="c-fn">registerBlockHandler</span><span class="c-punc">({</span>
|
||||
name<span class="c-punc">:</span> <span class="c-str">'custom'</span><span class="c-punc">,</span> priority<span class="c-punc">:</span> <span class="c-num">8.5</span><span class="c-punc">,</span>
|
||||
test<span class="c-punc">:</span> <span class="c-punc">(</span>line<span class="c-punc">)</span> <span class="c-punc">=></span> line<span class="c-punc">.</span><span class="c-fn">match</span><span class="c-punc">(</span><span class="c-re">/^:::/</span><span class="c-punc">),</span>
|
||||
parse<span class="c-punc">:</span> <span class="c-punc">(</span>lines<span class="c-punc">,</span> i<span class="c-punc">,</span> match<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">({</span> token<span class="c-punc">:</span> <span class="c-punc">{</span> type<span class="c-punc">:</span> <span class="c-str">'alert'</span> <span class="c-punc">},</span> newIndex<span class="c-punc">:</span> j <span class="c-punc">})</span>
|
||||
<span class="c-punc">});</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 插件 -->
|
||||
<section id="plugins-convention"><h2>插件约定</h2>
|
||||
<pre><span class="c-kw">const</span> myPlugin <span class="c-punc">=</span> <span class="c-punc">{</span>
|
||||
name<span class="c-punc">:</span> <span class="c-str">'myPlugin'</span><span class="c-punc">,</span> description<span class="c-punc">:</span> <span class="c-str">'我的插件'</span><span class="c-punc">,</span>
|
||||
<span class="c-fn">install</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span> editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span> <span class="c-punc">},</span>
|
||||
<span class="c-fn">destroy</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span> editor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span> <span class="c-punc">},</span>
|
||||
<span class="c-punc">};</span></pre>
|
||||
</section>
|
||||
|
||||
<section id="preset-plugins"><h3>预设插件</h3>
|
||||
<h4>autoSave</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">,</span> <span class="c-punc">{</span> key<span class="c-punc">:</span> <span class="c-str">'draft-key'</span><span class="c-punc">,</span> delay<span class="c-punc">:</span> <span class="c-num">1000</span> <span class="c-punc">});</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span></pre>
|
||||
<h4>exportTool</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'exportTool'</span><span class="c-punc">);</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportPDF</span><span class="c-punc">();</span></pre>
|
||||
<h4>searchReplace</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
|
||||
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
|
||||
<h4>imagePaste</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">,</span> <span class="c-punc">{</span> maxSizeKB<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 粘贴图片转 base64,超限警告</span></pre>
|
||||
<h4>shortcutHelp</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
|
||||
<h4>fileSystem</h4>
|
||||
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'fileSystem'</span><span class="c-punc">);</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 主题 -->
|
||||
<section id="themes"><h2>主题系统</h2>
|
||||
<pre><span class="c-com">// 实例级</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span>
|
||||
|
||||
<span class="c-com">// 注册自定义</span>
|
||||
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 外部跟随</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">adopt</span><span class="c-punc">();</span> <span class="c-com">// 从父容器继承</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">syncWithElement</span><span class="c-punc">(</span>document<span class="c-punc">.</span><span class="c-fn">body</span><span class="c-punc">());</span> <span class="c-com">// 跟随外部元素</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">getThemeContext</span><span class="c-punc">().</span><span class="c-fn">dispose</span><span class="c-punc">();</span> <span class="c-com">// 手动断开跟随(destroy 时自动断开)</span>
|
||||
|
||||
<span class="c-com">// CSS 变量导出</span>
|
||||
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- 国际化 -->
|
||||
<section id="i18n"><h2>国际化</h2>
|
||||
<pre><span class="c-com">// 切换</span>
|
||||
editor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'zh-CN'</span><span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 添加翻译</span>
|
||||
<span class="c-kw">import</span> <span class="c-punc">{</span> i18nUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||
i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><span class="c-punc">(</span><span class="c-str">'ja'</span><span class="c-punc">,</span> <span class="c-punc">{</span> bold<span class="c-punc">:</span> <span class="c-str">'太字'</span> <span class="c-punc">});</span>
|
||||
|
||||
<span class="c-com">// 远程加载</span>
|
||||
<span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
|
||||
|
||||
<span class="c-com">// 格式化</span>
|
||||
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
|
||||
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const sections = document.querySelectorAll('section[id]');
|
||||
const links = document.querySelectorAll('.sidebar a');
|
||||
window.addEventListener('scroll', () => {
|
||||
let current = '';
|
||||
sections.forEach(s => { if (window.scrollY >= s.offsetTop - 100) current = s.id; });
|
||||
links.forEach(a => { a.classList.toggle('active', a.getAttribute('href') === '#' + current); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,836 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧪 在线演示 — MetonaSqlark v0.7.4</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0a0f; --surface:#0d1117; --surface2:#161b22; --border:#30363d;
|
||||
--primary:#6366f1; --accent:#06b6d4; --accent2:#ec4899; --green:#22c55e;
|
||||
--text:#e2e8f0; --text2:#8b949e; --radius:12px;
|
||||
--gradient:linear-gradient(135deg,#6366f1,#06b6d4,#ec4899);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:'Inter',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; overflow:hidden; }
|
||||
header {
|
||||
height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px;
|
||||
background:var(--surface); border-bottom:1px solid var(--border); z-index:10; position:relative;
|
||||
}
|
||||
.logo { font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:10px; }
|
||||
.logo .icon { width:28px; height:28px; border-radius:7px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:900; color:#fff; }
|
||||
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
nav { display:flex; gap:20px; }
|
||||
nav a { color:var(--text2); text-decoration:none; font-size:0.88rem; transition:.2s; }
|
||||
nav a:hover,.nav-active { color:var(--text); }
|
||||
.status { display:flex; align-items:center; gap:8px; font-size:0.82rem; color:var(--text2); }
|
||||
.status .dot { width:7px; height:7px; border-radius:50%; background:var(--green); animation:pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
.main { display:flex; height:calc(100vh - 56px); }
|
||||
.editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); min-width:400px; }
|
||||
.result-panel { flex:1; display:flex; flex-direction:column; min-width:400px; }
|
||||
.panel-header {
|
||||
padding:12px 20px; font-size:0.78rem; font-weight:600; text-transform:uppercase;
|
||||
letter-spacing:1px; color:var(--text2); border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
}
|
||||
.panel-header .badge {
|
||||
font-size:0.7rem; padding:3px 10px; border-radius:999px; background:var(--surface2);
|
||||
border:1px solid var(--border); text-transform:none; letter-spacing:0;
|
||||
}
|
||||
.editor-area { flex:1; display:flex; flex-direction:column; }
|
||||
#sql-input {
|
||||
flex:1; background:var(--surface); color:var(--text); border:none; padding:20px;
|
||||
font-family:'JetBrains Mono','Fira Code',monospace; font-size:0.9rem; line-height:1.8;
|
||||
resize:none; outline:none; caret-color:var(--primary);
|
||||
}
|
||||
#sql-input::placeholder { color:var(--text2); opacity:0.5; }
|
||||
.toolbar { display:flex; gap:8px; padding:12px 20px; border-top:1px solid var(--border); background:var(--surface2); flex-wrap:wrap; }
|
||||
.btn {
|
||||
display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px;
|
||||
font-weight:600; font-size:0.85rem; cursor:pointer; border:none; transition:.25s; font-family:inherit;
|
||||
}
|
||||
.btn-run { background:var(--gradient); color:#fff; box-shadow:0 4px 15px rgba(99,102,241,0.35); }
|
||||
.btn-run:hover { transform:translateY(-1px); box-shadow:0 6px 25px rgba(99,102,241,0.5); }
|
||||
.btn-run:active { transform:scale(0.97); }
|
||||
.btn-clear { background:var(--surface2); color:var(--text2); border:1px solid var(--border); }
|
||||
.btn-clear:hover { color:var(--text); border-color:var(--text2); }
|
||||
.btn-preset { background:transparent; color:var(--accent); border:1px solid var(--accent); font-size:0.78rem; padding:6px 14px; }
|
||||
.btn-preset:hover { background:rgba(6,182,212,0.12); }
|
||||
.result-content { flex:1; overflow-y:auto; padding:20px; }
|
||||
.result-table { width:100%; border-collapse:collapse; font-size:0.85rem; }
|
||||
.result-table th { text-align:left; padding:10px 14px; background:var(--surface2); color:var(--accent); font-weight:600; border-bottom:2px solid var(--border); position:sticky; top:0; z-index:1; }
|
||||
.result-table td { padding:8px 14px; border-bottom:1px solid var(--border); color:var(--text2); max-width:250px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.result-table tr:hover td { background:rgba(99,102,241,0.04); }
|
||||
.empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text2); gap:12px; }
|
||||
.empty-state .icon { font-size:3rem; opacity:0.3; }
|
||||
.error-box { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:10px; padding:16px; color:#f87171; font-family:'JetBrains Mono',monospace; font-size:0.82rem; margin-bottom:16px; }
|
||||
.success-box { background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3); border-radius:10px; padding:12px 16px; color:#4ade80; font-size:0.85rem; margin-bottom:16px; }
|
||||
.sql-tag { display:inline-block; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:4px 12px; font-family:'JetBrains Mono',monospace; font-size:0.75rem; color:var(--accent); margin-bottom:8px; }
|
||||
::-webkit-scrollbar { width:6px; height:6px; }
|
||||
::-webkit-scrollbar-track { background:transparent; }
|
||||
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
||||
@media(max-width:900px){ .main { flex-direction:column; } .editor-panel,.result-panel { min-width:auto; } .editor-panel { height:50%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo"><div class="icon">◈</div><span>MetonaSqlark</span></div>
|
||||
<nav>
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html">文档</a>
|
||||
<a href="demo.html" class="nav-active">演示</a>
|
||||
<a href="benchmark.html">基准</a>
|
||||
</nav>
|
||||
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.7.4</div>
|
||||
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
|
||||
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<div class="editor-panel">
|
||||
<div class="editor-area">
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... 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.7.4 在线演示
|
||||
-- 已预置 users / orders / products 表数据
|
||||
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
|
||||
|
||||
-- 查看所有数据
|
||||
SELECT * FROM users;
|
||||
|
||||
-- 条件查询 + 排序
|
||||
SELECT name, age FROM users WHERE age > 25 ORDER BY age DESC;
|
||||
|
||||
-- JOIN 多表关联
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id;
|
||||
|
||||
-- 聚合统计
|
||||
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;</textarea>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-run" onclick="runQuery()">▶ 执行 (Ctrl+Enter)</button>
|
||||
<button class="btn btn-clear" onclick="clearResults()">🗑 清空</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-preset" onclick="loadPreset('all')">📋 全数据</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('join')">🔗 JOIN</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('agg')">📊 聚合</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('ddl')">🏗 DDL</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('crud')">✏️ CRUD</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('subquery')">🔍 子查询</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('tx')">🔒 事务</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('scalar')">🎯 标量子查询</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('cascade')">🔗 级联</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('adv')">🧪 高级</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('alter')">🏗 ALTER</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('truncate')">🗑 TRUNCATE</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('casewhen')">🎯 CASE WHEN</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('union')">🔀 UNION</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('insertselect')">📥 INSERT SELECT</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('exists')">🔍 EXISTS</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('v040')" style="color:#22c55e;border-color:#22c55e;">🚰 v0.4.0 新特性</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('onupdate')" style="color:#fbbf24;border-color:#fbbf24;">🔄 ON UPDATE</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('repair')" style="color:#22c55e;border-color:#22c55e;">🛡 自愈</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('maint')" style="color:#22c55e;border-color:#22c55e;">🛠 v0.5.1 维护语句</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-panel">
|
||||
<div class="panel-header">
|
||||
<span>📊 查询结果</span>
|
||||
<span class="badge" id="row-count">0 行</span>
|
||||
</div>
|
||||
<div class="result-content" id="result-area">
|
||||
<div class="empty-state">
|
||||
<div class="icon">⚡</div>
|
||||
<div>执行 SQL 查询以查看结果</div>
|
||||
<div style="font-size:0.8rem;opacity:0.6">Ctrl + Enter 快捷执行 · 支持多语句</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../dist/metona-sqlark.js"></script>
|
||||
<script>
|
||||
// Resolve global: try both MetonaSqlark and MeSqlark
|
||||
const globalNs = window.MetonaSqlark || window.MeSqlark || {};
|
||||
const { MetonaSqlark, MeSqlark, create } = globalNs;
|
||||
const DBClass = MetonaSqlark || MeSqlark;
|
||||
|
||||
let db = null;
|
||||
const resultArea = document.getElementById('result-area');
|
||||
const rowCount = document.getElementById('row-count');
|
||||
const sqlInput = document.getElementById('sql-input');
|
||||
|
||||
// Init database
|
||||
let currentEngine = 'memory';
|
||||
|
||||
async function initDB() {
|
||||
try {
|
||||
const engine = currentEngine;
|
||||
// 关闭旧实例(预设重置/引擎切换时数据回到初始状态)
|
||||
if (db) { try { await db.close(); } catch { /* ignore */ } }
|
||||
db = new DBClass({ name: 'demo', mode: engine });
|
||||
await db.init();
|
||||
|
||||
// v0.4.2: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
|
||||
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
|
||||
await db.getEngine().clearAll();
|
||||
}
|
||||
|
||||
await seedDemoData(db);
|
||||
renderInfo(`✅ ${engine === 'aria' ? '🌲 AriaEngine(LSM-Tree + WAL + MVCC)' : '⚡ Memory'} 引擎已就绪`);
|
||||
} catch (e) {
|
||||
renderError('数据库初始化失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 建表 + 种子数据(每次预设重置/引擎切换后重建,保证演示可预期)
|
||||
async function seedDemoData(db) {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
email: { type: 'string' },
|
||||
age: { type: 'number', default: 0 },
|
||||
});
|
||||
await db.defineTable('orders', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
user_id: { type: 'string' },
|
||||
product: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
quantity: { type: 'number', default: 1 },
|
||||
});
|
||||
await db.defineTable('products', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
price: { type: 'number', default: 0 },
|
||||
category: { type: 'string' },
|
||||
});
|
||||
|
||||
// Seed data
|
||||
await db.table('users').insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
|
||||
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
|
||||
{ id: '4', name: 'Diana', email: 'diana@demo.com', age: 28 },
|
||||
{ id: '5', name: 'Eve', email: 'eve@demo.com', age: 22 },
|
||||
]);
|
||||
await db.table('orders').insertMany([
|
||||
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200, quantity: 1 },
|
||||
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50, quantity: 2 },
|
||||
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150, quantity: 1 },
|
||||
{ id: 'o4', user_id: '3', product: 'Monitor', amount: 400, quantity: 1 },
|
||||
{ id: 'o5', user_id: '3', product: 'Cable', amount: 20, quantity: 3 },
|
||||
]);
|
||||
await db.table('products').insertMany([
|
||||
{ id: 'p1', name: 'Laptop', price: 1200, category: 'Electronics' },
|
||||
{ id: 'p2', name: 'Mouse', price: 50, category: 'Accessories' },
|
||||
{ id: 'p3', name: 'Keyboard', price: 150, category: 'Accessories' },
|
||||
]);
|
||||
}
|
||||
|
||||
// v0.4.2: 切换存储引擎(重建数据库实例)
|
||||
async function switchEngine(engine) {
|
||||
if (engine === currentEngine) return;
|
||||
currentEngine = engine;
|
||||
document.getElementById('btn-memory').style.opacity = engine === 'memory' ? '1' : '0.6';
|
||||
document.getElementById('btn-aria').style.opacity = engine === 'aria' ? '1' : '0.6';
|
||||
document.getElementById('engine-status').textContent = engine === 'aria' ? '🌲 Aria' : '⚡ Memory';
|
||||
document.getElementById('engine-dot').style.background = engine === 'aria' ? '#ec4899' : '';
|
||||
renderInfo(`⏳ 正在初始化 ${engine === 'aria' ? 'Aria' : 'Memory'} 引擎…`);
|
||||
await initDB();
|
||||
}
|
||||
|
||||
// Run SQL
|
||||
async function runQuery() {
|
||||
const sql = sqlInput.value.trim();
|
||||
if (!sql || !db) return;
|
||||
|
||||
const statements = sql.split(';').map(s => s.trim()).filter(s => {
|
||||
// 过滤空语句和纯注释语句
|
||||
if (s.length === 0) return false;
|
||||
const nonComment = s.split('\n').filter(l => !l.trimStart().startsWith('--')).join('\n').trim();
|
||||
return nonComment.length > 0;
|
||||
});
|
||||
|
||||
try {
|
||||
const allResults = [];
|
||||
for (const stmt of statements) {
|
||||
const result = await db.query(stmt);
|
||||
// 深拷贝查询结果,防止后续语句通过引用修改已保存的结果
|
||||
const resultCopy = Array.isArray(result) ? result.map(r => ({ ...r })) : result;
|
||||
allResults.push({ sql: stmt, result: resultCopy });
|
||||
}
|
||||
|
||||
if (allResults.length === 1) {
|
||||
renderResult(allResults[0]);
|
||||
} else {
|
||||
renderAllResults(allResults);
|
||||
}
|
||||
} catch (e) {
|
||||
renderError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult({ sql, result }) {
|
||||
if (Array.isArray(result)) {
|
||||
renderTable(result);
|
||||
} else {
|
||||
renderSuccess(`✅ 执行成功 (影响 ${result ?? 0} 行)`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllResults(allResults) {
|
||||
let html = '';
|
||||
for (const { sql, result } of allResults) {
|
||||
html += `<div class="sql-tag">> ${escapeHTML(sql)}</div>`;
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
html += '<div class="success-box">✅ 查询返回 0 行</div>';
|
||||
} else {
|
||||
html += buildTableHTML(result);
|
||||
}
|
||||
} else {
|
||||
html += `<div class="success-box">✅ 执行成功 (影响 ${result ?? 0} 行)</div>`;
|
||||
}
|
||||
html += '<div style="height:12px"></div>';
|
||||
}
|
||||
resultArea.innerHTML = html;
|
||||
updateRowCount(allResults.reduce((sum, r) => sum + (Array.isArray(r.result) ? r.result.length : 0), 0));
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
if (!rows || rows.length === 0) {
|
||||
resultArea.innerHTML = '<div class="success-box">✅ 查询返回 0 行</div>';
|
||||
updateRowCount(0);
|
||||
return;
|
||||
}
|
||||
resultArea.innerHTML = buildTableHTML(rows);
|
||||
updateRowCount(rows.length);
|
||||
}
|
||||
|
||||
function buildTableHTML(rows) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
let html = '<table class="result-table"><thead><tr>';
|
||||
for (const col of columns) {
|
||||
html += `<th>${escapeHTML(col)}</th>`;
|
||||
}
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const row of rows) {
|
||||
html += '<tr>';
|
||||
for (const col of columns) {
|
||||
const val = row[col];
|
||||
html += `<td>${formatCell(val)}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatCell(val) {
|
||||
if (val === null) return '<span style="color:#64748b;font-style:italic">NULL</span>';
|
||||
if (val === undefined) return '<span style="color:#64748b">-</span>';
|
||||
if (typeof val === 'boolean') return `<span style="color:#fbbf24">${val}</span>`;
|
||||
if (typeof val === 'number') return `<span style="color:#fbbf24">${val}</span>`;
|
||||
return escapeHTML(String(val));
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
resultArea.innerHTML = `<div class="error-box">❌ ${escapeHTML(msg)}</div>`;
|
||||
updateRowCount(0);
|
||||
}
|
||||
|
||||
function renderSuccess(msg) {
|
||||
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
|
||||
}
|
||||
|
||||
function renderInfo(msg) {
|
||||
resultArea.innerHTML = `<div class="success-box" style="color:#22c55e;border-color:#22c55e;">${msg}</div>`;
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
|
||||
updateRowCount(0);
|
||||
}
|
||||
|
||||
function updateRowCount(n) { rowCount.textContent = `${n} 行`; }
|
||||
function escapeHTML(str) { return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
|
||||
// Presets
|
||||
const presets = {
|
||||
all: `-- 查看所有数据
|
||||
SELECT * FROM users;
|
||||
|
||||
SELECT * FROM orders;
|
||||
|
||||
SELECT * FROM products;`,
|
||||
join: `-- INNER JOIN:用户 + 订单
|
||||
SELECT u.name, o.product, o.amount, o.quantity
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
ORDER BY o.amount DESC;
|
||||
|
||||
-- LEFT JOIN:包含没有订单的用户
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u
|
||||
LEFT JOIN orders o ON u.id = o.user_id;`,
|
||||
agg: `-- 聚合统计:每个用户的消费
|
||||
SELECT u.name, COUNT(*) as orders, SUM(o.amount) as total_spent
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
GROUP BY u.name
|
||||
ORDER BY total_spent DESC;
|
||||
|
||||
-- 全局聚合
|
||||
SELECT COUNT(*) as total_users, AVG(age) as avg_age,
|
||||
MIN(age) as min_age, MAX(age) as max_age
|
||||
FROM users;
|
||||
|
||||
-- 分类统计
|
||||
SELECT category, COUNT(*) as product_count, AVG(price) as avg_price
|
||||
FROM products
|
||||
GROUP BY category;`,
|
||||
ddl: `-- 查看所有表
|
||||
-- (使用 db.getTableNames() 替代)
|
||||
|
||||
-- 创建新表
|
||||
CREATE TABLE tasks (
|
||||
id STRING PRIMARY KEY,
|
||||
title STRING NOT NULL,
|
||||
done BOOLEAN DEFAULT false,
|
||||
priority NUMBER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 插入数据
|
||||
INSERT INTO tasks (id, title, done, priority) VALUES ('t1', 'Learn SQLark', false, 1);
|
||||
INSERT INTO tasks VALUES ('t2', 'Write docs', true, 2);
|
||||
|
||||
-- 查询
|
||||
SELECT * FROM tasks ORDER BY priority DESC;
|
||||
|
||||
-- 删除表
|
||||
DROP TABLE tasks;`,
|
||||
crud: `-- 增
|
||||
INSERT INTO users VALUES ('9', 'Frank', 'frank@demo.com', 40);
|
||||
|
||||
-- 查
|
||||
SELECT * FROM users WHERE name = 'Frank';
|
||||
|
||||
-- 改
|
||||
UPDATE users SET age = 41, email = 'frank2@demo.com' WHERE id = '9';
|
||||
|
||||
-- 查(确认)
|
||||
SELECT * FROM users WHERE id = '9';
|
||||
|
||||
-- 删
|
||||
DELETE FROM users WHERE id = '9';
|
||||
|
||||
-- 确认
|
||||
SELECT COUNT(*) as user_count FROM users;`,
|
||||
subquery: `-- IN 子查询:有订单的用户
|
||||
SELECT name, email FROM users
|
||||
WHERE id IN (SELECT user_id FROM orders);
|
||||
|
||||
-- NOT IN 子查询:没有订单的用户
|
||||
SELECT name, email FROM users
|
||||
WHERE id NOT IN (SELECT user_id FROM orders);
|
||||
|
||||
-- 标量子查询:消费最高的用户
|
||||
SELECT name, age FROM users
|
||||
WHERE id IN (
|
||||
SELECT user_id FROM orders
|
||||
WHERE amount = (SELECT MAX(amount) FROM orders)
|
||||
);`,
|
||||
tx: `-- 事务演示:使用 db.transaction() API
|
||||
-- 查询用户 Alice
|
||||
SELECT * FROM users WHERE id = '1';
|
||||
|
||||
-- 为 Alice 插入新订单
|
||||
INSERT INTO orders VALUES ('o99', '1', 'Headphones', 199, 1);
|
||||
|
||||
-- 查看 Alice 的订单汇总
|
||||
SELECT u.name, COUNT(*) as order_count, SUM(o.amount) as total
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id
|
||||
WHERE u.id = '1'
|
||||
GROUP BY u.name;
|
||||
|
||||
-- 生产环境用 db.transaction() 包裹保证原子性`,
|
||||
scalar: `-- 聚合无 GROUP BY 返回单行
|
||||
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;
|
||||
|
||||
-- 年龄大于平均值的用户
|
||||
SELECT name, age FROM users
|
||||
WHERE age > (SELECT AVG(age) FROM users);
|
||||
|
||||
-- 消费超过平均值的用户
|
||||
SELECT u.name, SUM(o.amount) as spent
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id
|
||||
GROUP BY u.name
|
||||
HAVING SUM(o.amount) > (SELECT AVG(amount) FROM orders);`,
|
||||
cascade: `-- 外键级联:重建 orders 表带 CASCADE
|
||||
DROP TABLE IF EXISTS orders;
|
||||
|
||||
CREATE TABLE orders (
|
||||
id STRING PRIMARY KEY,
|
||||
user_id STRING REFERENCES users(id) ON DELETE CASCADE,
|
||||
product STRING,
|
||||
amount NUMBER,
|
||||
quantity NUMBER DEFAULT 1
|
||||
);
|
||||
|
||||
-- 重新插入数据
|
||||
INSERT INTO orders VALUES ('o1', '1', 'Laptop', 1200, 1);
|
||||
INSERT INTO orders VALUES ('o2', '1', 'Mouse', 50, 2);
|
||||
INSERT INTO orders VALUES ('o3', '2', 'Keyboard', 150, 1);
|
||||
|
||||
-- 查看关联数据
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id;
|
||||
|
||||
-- 删除 Alice (id=1) 订单自动级联删除
|
||||
DELETE FROM users WHERE id = '1';
|
||||
|
||||
-- 确认级联结果
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u LEFT JOIN orders o ON u.id = o.user_id;`,
|
||||
adv: `-- BETWEEN 范围查询
|
||||
SELECT * FROM users
|
||||
WHERE age BETWEEN 20 AND 30 AND name != 'Bob'
|
||||
ORDER BY age DESC;
|
||||
|
||||
-- DISTINCT 去重
|
||||
SELECT DISTINCT age FROM users ORDER BY age;
|
||||
|
||||
-- 多表 JOIN + 过滤 + 排序 + 分页
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.amount >= 50
|
||||
ORDER BY o.amount DESC
|
||||
LIMIT 5 OFFSET 0;
|
||||
|
||||
-- NOT LIKE 模糊排除
|
||||
SELECT * FROM users
|
||||
WHERE name NOT LIKE 'A%' AND age > 20;`,
|
||||
alter: `-- 🏗 ALTER TABLE 动态修改表结构 (v0.2.5)
|
||||
|
||||
-- 添加新列
|
||||
ALTER TABLE users ADD COLUMN phone STRING;
|
||||
|
||||
-- 插入数据(含新列)
|
||||
INSERT INTO users (id, name, age, phone) VALUES ('6', 'Frank', 33, '123-4567');
|
||||
|
||||
-- 查询确认
|
||||
SELECT * FROM users WHERE id = '6';
|
||||
|
||||
-- 删除列
|
||||
ALTER TABLE users DROP COLUMN phone;
|
||||
|
||||
-- 查询确认(phone 列已删除)
|
||||
SELECT * FROM users WHERE id = '6';`,
|
||||
truncate: `-- 🗑 TRUNCATE TABLE 快速清空 (v0.2.5)
|
||||
|
||||
-- 创建临时表并插入数据
|
||||
CREATE TABLE IF NOT EXISTS temp_logs (
|
||||
id STRING PRIMARY KEY,
|
||||
message STRING NOT NULL
|
||||
);
|
||||
INSERT INTO temp_logs VALUES ('1', 'log A');
|
||||
INSERT INTO temp_logs VALUES ('2', 'log B');
|
||||
INSERT INTO temp_logs VALUES ('3', 'log C');
|
||||
|
||||
-- 确认数据
|
||||
SELECT COUNT(*) as total FROM temp_logs;
|
||||
|
||||
-- TRUNCATE 快速清空
|
||||
TRUNCATE TABLE temp_logs;
|
||||
|
||||
-- 确认清空
|
||||
SELECT COUNT(*) as total FROM temp_logs;
|
||||
|
||||
-- 清理
|
||||
DROP TABLE temp_logs;`,
|
||||
aria: `-- 🌲 AriaEngine 演示 (v0.7.4)
|
||||
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
|
||||
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter)
|
||||
-- 基础 CRUD 与 Memory 引擎完全兼容
|
||||
|
||||
-- 当前引擎确认
|
||||
-- (Aria 引擎实例,数据经 WAL + SSTable 持久化到 OPFS)
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id STRING PRIMARY KEY,
|
||||
title STRING NOT NULL,
|
||||
done BOOLEAN DEFAULT false
|
||||
);
|
||||
|
||||
-- 插入数据
|
||||
INSERT INTO tasks VALUES ('t1', 'Implement AriaEngine', false);
|
||||
INSERT INTO tasks VALUES ('t2', 'Write tests', false);
|
||||
INSERT INTO tasks VALUES ('t3', 'Update docs', true);
|
||||
|
||||
-- 查询
|
||||
SELECT * FROM tasks ORDER BY title;
|
||||
|
||||
-- 聚合统计
|
||||
SELECT done, COUNT(*) as cnt FROM tasks GROUP BY done;
|
||||
|
||||
-- CASE WHEN 表达式
|
||||
SELECT title,
|
||||
CASE WHEN done THEN '✅ done' ELSE '⏳ pending' END AS status
|
||||
FROM tasks;
|
||||
|
||||
-- EXISTS 关联子查询
|
||||
SELECT u.name FROM users u
|
||||
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
|
||||
|
||||
-- AriaEngine 特性:
|
||||
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
|
||||
-- • WAL: 分片文件 + 标准 CRC32 + 空洞检测 + 崩溃恢复(残缺 SSTable 自动跳过)
|
||||
-- • MVCC: 版本链 + 快照隔离
|
||||
-- • 页面化物理存储: SSTable 4KB 页面 + BufferPool LRU 缓存 (v0.5.0)
|
||||
-- • 完整性: 整文件 CRC-32 校验(数据腐坏自动清理自愈)
|
||||
-- • Bloom Filter: FNV-1a + Murmur 双哈希
|
||||
-- • 二级索引: 每列独立 LSM + 跨重启自动恢复
|
||||
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT
|
||||
-- • 自愈: db.repair() 无需删库重建(含孤儿页面/残留清理)
|
||||
-- • 维护语句: EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT (v0.5.1)
|
||||
|
||||
-- 生产环境 API(与演示页右上角切换等价)
|
||||
-- const db = await MetonaSqlark.create({
|
||||
-- name: 'my-app', mode: 'aria'
|
||||
-- });`,
|
||||
maint: `-- 🛠 维护语句 (v0.5.1)
|
||||
-- EXPLAIN: 输出查询计划(任何引擎可用)
|
||||
EXPLAIN SELECT * FROM users WHERE age > 18;
|
||||
|
||||
-- ANALYZE: 收集表统计信息(仅 Aria 引擎)
|
||||
ANALYZE TABLE users;
|
||||
|
||||
-- REINDEX: 重建二级索引(仅 Aria 引擎)
|
||||
REINDEX TABLE users;
|
||||
|
||||
-- VACUUM: 压缩 LSM + 清理碎片(仅 Aria 引擎)
|
||||
VACUUM;
|
||||
|
||||
-- SAVEPOINT: 嵌套事务保存点(仅 Aria 引擎)
|
||||
BEGIN;
|
||||
UPDATE users SET age = age + 1 WHERE id = '1';
|
||||
SAVEPOINT sp1;
|
||||
UPDATE users SET age = 0 WHERE id = '1';
|
||||
ROLLBACK TO SAVEPOINT sp1;
|
||||
RELEASE SAVEPOINT sp1;
|
||||
COMMIT;
|
||||
SELECT id, age FROM users WHERE id = '1';`,
|
||||
casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2)
|
||||
|
||||
-- SELECT 列:多 WHEN + ELSE
|
||||
SELECT name, age,
|
||||
CASE WHEN age >= 30 THEN 'senior'
|
||||
WHEN age >= 25 THEN 'mid'
|
||||
ELSE 'junior' END AS age_group
|
||||
FROM users ORDER BY age;
|
||||
|
||||
-- WHERE 条件中的 CASE WHEN
|
||||
SELECT name FROM users
|
||||
WHERE CASE WHEN age >= 25 THEN 'adult' ELSE 'young' END = 'adult';
|
||||
|
||||
-- 聚合中的 CASE WHEN
|
||||
SELECT
|
||||
SUM(CASE WHEN age >= 30 THEN 1 ELSE 0 END) AS seniors,
|
||||
SUM(CASE WHEN age < 30 THEN 1 ELSE 0 END) AS juniors,
|
||||
AVG(CASE WHEN age >= 25 THEN age END) AS avg_adult_age
|
||||
FROM users;`,
|
||||
union: `-- 🔀 UNION / UNION ALL (v0.3.0)
|
||||
|
||||
-- UNION 去重合并两表数据
|
||||
SELECT name FROM users WHERE age >= 30
|
||||
UNION
|
||||
SELECT name FROM users WHERE age < 30;
|
||||
|
||||
-- UNION 对重复行去重(同查询两次 → 结果去重)
|
||||
SELECT category FROM products
|
||||
UNION
|
||||
SELECT category FROM products;
|
||||
|
||||
-- UNION ALL 保留重复
|
||||
SELECT category FROM products WHERE price >= 100
|
||||
UNION ALL
|
||||
SELECT category FROM products WHERE price >= 50;`,
|
||||
insertselect: `-- 📥 INSERT INTO ... SELECT (v0.3.0)
|
||||
|
||||
-- 建备份表并复制全量数据
|
||||
DROP TABLE IF EXISTS users_backup;
|
||||
CREATE TABLE users_backup (
|
||||
id STRING PRIMARY KEY, name STRING, email STRING, age NUMBER
|
||||
);
|
||||
INSERT INTO users_backup SELECT * FROM users;
|
||||
|
||||
-- 确认复制
|
||||
SELECT COUNT(*) as backed_up FROM users_backup;
|
||||
|
||||
-- INSERT SELECT 带 WHERE 过滤
|
||||
DROP TABLE IF EXISTS big_spenders;
|
||||
CREATE TABLE big_spenders (
|
||||
id STRING PRIMARY KEY, product STRING, amount NUMBER
|
||||
);
|
||||
INSERT INTO big_spenders (id, product, amount)
|
||||
SELECT o.id, o.product, o.amount FROM orders o WHERE o.amount >= 150;
|
||||
|
||||
-- 确认过滤结果
|
||||
SELECT * FROM big_spenders;`,
|
||||
exists: `-- 🔍 EXISTS 关联子查询 (v0.3.0 / v0.3.1)
|
||||
|
||||
-- 有订单的用户(关联子查询)
|
||||
SELECT u.name FROM users u
|
||||
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
|
||||
|
||||
-- 无订单的用户(NOT EXISTS)
|
||||
SELECT u.name FROM users u
|
||||
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
|
||||
|
||||
-- EXISTS + JOIN 组合
|
||||
SELECT u.name, o.product, o.amount FROM users u
|
||||
JOIN orders o ON u.id = o.user_id
|
||||
WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 100);`,
|
||||
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
|
||||
|
||||
-- 为 orders.user_id 创建索引(已有数据自动回填)
|
||||
CREATE INDEX idx_orders_user ON orders (user_id);
|
||||
|
||||
-- 索引查找(v0.4.2: JOIN 主表 WHERE 条件下推 + 二级索引跨重启恢复)
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM orders o JOIN users u ON u.id = o.user_id
|
||||
WHERE o.user_id = '1';
|
||||
|
||||
-- 删除索引
|
||||
DROP INDEX idx_orders_user ON orders (user_id);
|
||||
|
||||
-- 删除后回退全表扫描(结果不变)
|
||||
SELECT * FROM orders WHERE user_id = '3';
|
||||
|
||||
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND)
|
||||
-- DROP INDEX idx_nonexist ON orders (user_id);`,
|
||||
onupdate: `-- 🔄 ON UPDATE 外键级联 (v0.4.2)
|
||||
-- 更新父表主键 → 子表外键自动级联(CASCADE / SET NULL / RESTRICT)
|
||||
|
||||
-- 建带 onUpdate 外键的表
|
||||
DROP TABLE IF EXISTS accounts;
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
CREATE TABLE accounts (id STRING PRIMARY KEY, owner STRING);
|
||||
CREATE TABLE audit_log (
|
||||
id STRING PRIMARY KEY,
|
||||
account_id STRING REFERENCES accounts.id ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 种子数据
|
||||
INSERT INTO accounts VALUES ('a1', 'Alice');
|
||||
INSERT INTO audit_log VALUES ('l1', 'a1');
|
||||
INSERT INTO audit_log VALUES ('l2', 'a1');
|
||||
|
||||
-- 更新父表主键 a1 → a2
|
||||
UPDATE accounts SET id = 'a2' WHERE id = 'a1';
|
||||
|
||||
-- 子表外键已级联为 a2
|
||||
SELECT * FROM audit_log ORDER BY id;
|
||||
|
||||
-- 再按新主键反查
|
||||
SELECT * FROM accounts WHERE id = 'a2';`,
|
||||
repair: `-- 🛡 崩溃恢复自愈 (v0.4.2)
|
||||
-- db.repair(): 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL
|
||||
-- db.clearAll(): 清空全部数据与表结构(保留库本身)
|
||||
-- 打开数据库时自动跳过残缺 SSTable,无需删库重建
|
||||
|
||||
-- 数据写入
|
||||
CREATE TABLE IF NOT EXISTS notes (id STRING PRIMARY KEY, body STRING);
|
||||
INSERT INTO notes VALUES ('n1', 'Hello');
|
||||
INSERT INTO notes VALUES ('n2', 'World');
|
||||
|
||||
-- 模拟数据(正常数据)
|
||||
SELECT * FROM notes ORDER BY id;
|
||||
|
||||
-- API 自愈(控制台执行):
|
||||
-- await db.repair(); → 校验+清理损坏文件,重建索引
|
||||
-- await db.clearAll(); → 清空全部表与数据
|
||||
|
||||
-- 迁移版本持久化(重启后不重跑已执行迁移)
|
||||
-- db.addMigration(1, async () => { ... });
|
||||
-- await db.migrateTo(1);`,
|
||||
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
|
||||
|
||||
-- 分号分隔的多语句一次执行
|
||||
DROP TABLE IF EXISTS audit;
|
||||
CREATE TABLE audit (id STRING PRIMARY KEY, msg STRING);
|
||||
INSERT INTO audit VALUES ('a1', 'first');
|
||||
INSERT INTO audit VALUES ('a2', 'second');
|
||||
SELECT * FROM audit ORDER BY id;
|
||||
|
||||
-- 事务语句:BEGIN → ROLLBACK(a3 不会生效)
|
||||
BEGIN;
|
||||
INSERT INTO audit VALUES ('a3', 'will be rolled back');
|
||||
ROLLBACK;
|
||||
SELECT * FROM audit ORDER BY id;
|
||||
|
||||
-- 事务语句:BEGIN → COMMIT(a4 生效)
|
||||
BEGIN;
|
||||
INSERT INTO audit VALUES ('a4', 'will be committed');
|
||||
COMMIT;
|
||||
SELECT COUNT(*) as total FROM audit;`,
|
||||
v040: `-- 🚰 v0.4.0 新特性
|
||||
|
||||
-- 派生表:FROM (SELECT ...) 子查询作为行源
|
||||
SELECT category, total FROM (
|
||||
SELECT category, SUM(price) AS total FROM products GROUP BY category
|
||||
) AS t WHERE total > 100 ORDER BY total DESC;
|
||||
|
||||
-- COUNT(DISTINCT col):去重计数
|
||||
SELECT COUNT(DISTINCT category) AS categories FROM products;
|
||||
|
||||
-- NULLS FIRST / LAST:NULL 排序位置控制
|
||||
SELECT name FROM users ORDER BY age ASC NULLS FIRST LIMIT 3;
|
||||
|
||||
-- 普通列别名 + ORDER BY 别名
|
||||
SELECT name AS n FROM users ORDER BY n DESC;
|
||||
|
||||
-- SQL 标准 '' 字符串转义
|
||||
SELECT 'it''s a test' AS escaped;
|
||||
|
||||
-- 哈希连接(等值 ON 走 O(N+M);多列等值 ON 同样支持)
|
||||
SELECT u.name, o.product FROM users u
|
||||
INNER JOIN orders o ON o.user_id = u.id;`,
|
||||
};
|
||||
|
||||
// v0.4.1: 点击预设前自动重置数据(前序预设可能修改/删除种子数据,重置保证每个预设可预期演示)
|
||||
async function loadPreset(name) {
|
||||
if (presets[name]) {
|
||||
sqlInput.value = presets[name];
|
||||
await initDB();
|
||||
runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runQuery();
|
||||
}
|
||||
});
|
||||
|
||||
// Boot
|
||||
document.getElementById('btn-memory').style.opacity = '1';
|
||||
document.getElementById('btn-aria').style.opacity = '0.6';
|
||||
document.getElementById('engine-status').textContent = '⚡ Memory';
|
||||
initDB().then(() => {
|
||||
console.log('✅ MetonaSqlark v0.4.2 demo ready');
|
||||
setTimeout(runQuery, 300);
|
||||
}).catch(err => {
|
||||
renderError('初始化失败: ' + err.message);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,937 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📖 API 文档 — MetonaSqlark v0.7.4</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0a0f; --surface:#14141f; --surface2:#1a1a2e; --border:#2a2a45;
|
||||
--primary:#6366f1; --primary-glow:#818cf8; --accent:#06b6d4; --accent2:#ec4899;
|
||||
--text:#e2e8f0; --text2:#94a3b8; --gradient:linear-gradient(135deg,#6366f1 0%,#06b6d4 50%,#ec4899 100%);
|
||||
--radius:14px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.7; }
|
||||
header {
|
||||
position:fixed; top:0; left:0; right:0; z-index:100; backdrop-filter:blur(20px);
|
||||
background:rgba(10,10,15,0.85); border-bottom:1px solid var(--border); padding:16px 32px;
|
||||
}
|
||||
header .inner { max-width:1400px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; }
|
||||
.logo { font-size:1.3rem; font-weight:800; display:flex; align-items:center; gap:10px; }
|
||||
.logo .icon { width:32px; height:32px; border-radius:8px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:1rem; font-weight:900; color:#fff; }
|
||||
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
nav { display:flex; gap:28px; }
|
||||
nav a { color:var(--text2); text-decoration:none; font-weight:500; font-size:0.92rem; transition:.2s; }
|
||||
nav a:hover,.nav-active { color:var(--text); }
|
||||
.layout { display:flex; max-width:1400px; margin:0 auto; padding-top:80px; min-height:100vh; }
|
||||
.sidebar {
|
||||
width:260px; min-width:260px; padding:32px 24px; position:sticky; top:80px; height:calc(100vh - 80px);
|
||||
overflow-y:auto; border-right:1px solid var(--border); background:var(--bg);
|
||||
}
|
||||
.sidebar h4 { font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--text2); margin:20px 0 8px; }
|
||||
.sidebar h4:first-child { margin-top:0; }
|
||||
.sidebar a { display:block; padding:7px 12px; border-radius:6px; color:var(--text2); text-decoration:none; font-size:0.9rem; transition:.15s; }
|
||||
.sidebar a:hover,.sidebar a.active { color:var(--text); background:var(--surface2); }
|
||||
.sidebar a.active { border-left:3px solid var(--primary); }
|
||||
.content { flex:1; padding:32px 48px 80px; max-width:950px; }
|
||||
.content h2 { font-size:1.8rem; font-weight:800; margin:48px 0 16px; padding-top:24px; border-top:1px solid var(--border); }
|
||||
.content h2:first-of-type { margin-top:0; padding-top:0; border-top:none; }
|
||||
.content h3 { font-size:1.25rem; font-weight:700; margin:32px 0 12px; color:var(--primary-glow); }
|
||||
.content p { color:var(--text2); margin-bottom:16px; }
|
||||
.content code {
|
||||
background:var(--surface2); padding:2px 8px; border-radius:5px; font-family:'JetBrains Mono',monospace;
|
||||
font-size:0.88rem; color:var(--accent);
|
||||
}
|
||||
.content pre {
|
||||
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:20px 24px; overflow-x:auto; font-family:'JetBrains Mono',monospace; font-size:0.85rem;
|
||||
line-height:1.65; margin:16px 0; color:#e2e8f0;
|
||||
}
|
||||
.content pre .k { color:#c084fc; } .content pre .s { color:#34d399; }
|
||||
.content pre .f { color:#60a5fa; } .content pre .c { color:#64748b; }
|
||||
.content pre .n { color:#fbbf24; } .content pre .t { color:#f472b6; }
|
||||
.content table { width:100%; border-collapse:collapse; margin:16px 0; }
|
||||
.content th,.content td { text-align:left; padding:12px 16px; border-bottom:1px solid var(--border); font-size:0.9rem; }
|
||||
.content th { color:var(--text); font-weight:600; background:var(--surface); }
|
||||
.content td { color:var(--text2); }
|
||||
.content td code { font-size:0.82rem; }
|
||||
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="inner">
|
||||
<div class="logo"><div class="icon">◈</div><span>MetonaSqlark</span></div>
|
||||
<nav>
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html" class="nav-active">文档</a>
|
||||
<a href="demo.html">演示</a>
|
||||
<a href="benchmark.html">基准</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h4>快速开始</h4>
|
||||
<a href="#install" class="active">安装</a>
|
||||
<a href="#create">创建数据库</a>
|
||||
<a href="#define-table">定义表</a>
|
||||
<h4>查询 API</h4>
|
||||
<a href="#sql-query">SQL 查询</a>
|
||||
<a href="#query-builder">Query Builder</a>
|
||||
<a href="#join">JOIN 查询</a>
|
||||
<a href="#groupby">GROUP BY & 聚合</a>
|
||||
<a href="#where-ops">WHERE 操作符</a>
|
||||
<h4>高级特性</h4>
|
||||
<a href="#aria-engine">AriaEngine 🆕</a>
|
||||
<a href="#transaction">事务 & 回滚</a>
|
||||
<a href="#subquery">子查询</a>
|
||||
<a href="#alter-table">ALTER TABLE 🆕</a>
|
||||
<a href="#truncate">TRUNCATE TABLE 🆕</a>
|
||||
<a href="#foreign-key">外键级联</a>
|
||||
<a href="#connection-pool">连接池</a>
|
||||
<a href="#migration">数据迁移</a>
|
||||
<a href="#selfheal">崩溃自愈 🆕</a>
|
||||
<a href="#export">导入导出</a>
|
||||
<a href="#plugin">插件 & 钩子</a>
|
||||
<a href="#subscribe">发布订阅</a>
|
||||
<h4>框架集成</h4>
|
||||
<a href="#react">React 集成</a>
|
||||
<a href="#vue">Vue 集成</a>
|
||||
<h4>类型参考</h4>
|
||||
<a href="#types">TypeScript 类型</a>
|
||||
<a href="#config">完整配置项</a>
|
||||
<a href="#engine">存储引擎</a>
|
||||
<a href="#errors">错误处理</a>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<h2 id="install">📦 安装</h2>
|
||||
<p>MetonaSqlark 支持多种引入方式,覆盖 npm、CDN、ESM、CJS 所有常见场景。</p>
|
||||
|
||||
<h3>npm 安装(推荐)</h3>
|
||||
<pre><span class="c"># 配置 Gitea Registry(一次性)</span>
|
||||
npm config set @metona-team:registry https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||
|
||||
<span class="c"># 安装</span>
|
||||
npm install @metona-team/metona-sqlark</pre>
|
||||
|
||||
<h3>CDN / UMD</h3>
|
||||
<pre><span class="c"><!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark --></span>
|
||||
<script src=<span class="s">"https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"</span>></script>
|
||||
<script>
|
||||
<span class="k">const</span> db = <span class="k">await</span> window.<span class="f">MetonaSqlark</span>.create({...});
|
||||
<span class="c">// 或 window.MeSqlark.create(...) — 完全等价</span>
|
||||
</script></pre>
|
||||
|
||||
<h3>ESM</h3>
|
||||
<pre><span class="k">import</span> { MetonaSqlark, MeSqlark } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark'</span>;
|
||||
<span class="c">// MeSqlark 是 MetonaSqlark 的别名,行为完全一致</span></pre>
|
||||
|
||||
<h3>CJS</h3>
|
||||
<pre><span class="k">const</span> { MetonaSqlark } = <span class="f">require</span>(<span class="s">'@metona-team/metona-sqlark'</span>);</pre>
|
||||
|
||||
<p>输出文件说明:</p>
|
||||
<table>
|
||||
<tr><th>文件</th><th>格式</th><th>用途</th></tr>
|
||||
<tr><td><code>metona-sqlark.js</code></td><td>UMD</td><td>浏览器开发版(含 sourcemap)</td></tr>
|
||||
<tr><td><code>metona-sqlark.min.js</code></td><td>UMD (minified)</td><td>生产环境(~105KB / ~27KB gzip)</td></tr>
|
||||
<tr><td><code>metona-sqlark.esm.js</code></td><td>ES Module</td><td>现代打包工具 / 浏览器 ESM</td></tr>
|
||||
<tr><td><code>metona-sqlark.cjs</code></td><td>CommonJS</td><td>Node.js require()</td></tr>
|
||||
<tr><td><code>metona-sqlark.d.ts</code></td><td>TypeScript 声明</td><td>类型提示</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="create">🏗 创建数据库</h2>
|
||||
<p><code>MetonaSqlark.create(config)</code> — 工厂函数,自动创建并初始化数据库实例。</p>
|
||||
|
||||
<pre><span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'hybrid'</span>, <span class="c">// 'memory' | 'disk' | 'hybrid' | 'aria' 🆕</span>
|
||||
<span class="s">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 'opfs' | 'kv' | 'memory'(v0.6.0: IndexedDB 已移除)</span>
|
||||
<span class="s">version</span>: <span class="n">1</span>,
|
||||
<span class="s">plugins</span>: [], <span class="c">// MetonaPlugin[]</span>
|
||||
<span class="s">onReady</span>: (db) => {}, <span class="c">// 就绪回调</span>
|
||||
<span class="s">onError</span>: (err) => {}, <span class="c">// 错误回调</span>
|
||||
});
|
||||
|
||||
<span class="c">// 也可手动实例化</span>
|
||||
<span class="k">const</span> db2 = <span class="k">new</span> <span class="f">MetonaSqlark</span>(config);
|
||||
<span class="k">await</span> db2.<span class="f">init</span>();
|
||||
|
||||
<span class="c">// 检查状态</span>
|
||||
db.<span class="f">isReady</span>(); <span class="c">// true</span>
|
||||
|
||||
<span class="c">// 关闭</span>
|
||||
<span class="k">await</span> db.<span class="f">close</span>();</pre>
|
||||
|
||||
<h2 id="define-table">📋 定义表</h2>
|
||||
<p>使用 <code>db.defineTable(name, columns)</code> 定义表结构。</p>
|
||||
|
||||
<pre><span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'users'</span>, {
|
||||
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
|
||||
<span class="s">name</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">required</span>: <span class="k">true</span> },
|
||||
<span class="s">email</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">unique</span>: <span class="k">true</span>, <span class="s">index</span>: <span class="k">true</span> },
|
||||
<span class="s">age</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">default</span>: <span class="n">0</span>, <span class="s">min</span>: <span class="n">0</span>, <span class="s">max</span>: <span class="n">150</span> },
|
||||
<span class="s">active</span>: { <span class="s">type</span>: <span class="s">'boolean'</span>, <span class="s">default</span>: <span class="k">true</span> },
|
||||
<span class="s">birthday</span>: { <span class="s">type</span>: <span class="s">'date'</span> },
|
||||
<span class="s">meta</span>: { <span class="s">type</span>: <span class="s">'json'</span> },
|
||||
<span class="s">dept_id</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">references</span>: <span class="s">'departments.id'</span> },
|
||||
});
|
||||
|
||||
<span class="c">// 表操作</span>
|
||||
<span class="k">await</span> db.<span class="f">getTableNames</span>(); <span class="c">// ['users']</span>
|
||||
<span class="k">await</span> db.<span class="f">dropTable</span>(<span class="s">'users'</span>); <span class="c">// 删除表</span></pre>
|
||||
|
||||
<table>
|
||||
<tr><th>ColumnDef 属性</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>type</code></td><td><code>'string'|'number'|'boolean'|'date'|'json'</code></td><td>数据类型</td></tr>
|
||||
<tr><td><code>primaryKey</code></td><td><code>boolean</code></td><td>主键(每表至少一个)</td></tr>
|
||||
<tr><td><code>required</code></td><td><code>boolean</code></td><td>是否必填</td></tr>
|
||||
<tr><td><code>unique</code></td><td><code>boolean</code></td><td>唯一约束(自动建索引)</td></tr>
|
||||
<tr><td><code>index</code></td><td><code>boolean</code></td><td>创建哈希索引,O(1) 加速查询</td></tr>
|
||||
<tr><td><code>default</code></td><td><code>unknown</code></td><td>默认值</td></tr>
|
||||
<tr><td><code>maxLength</code></td><td><code>number</code></td><td>字符串最大长度</td></tr>
|
||||
<tr><td><code>min</code>/<code>max</code></td><td><code>number</code></td><td>数值范围</td></tr>
|
||||
<tr><td><code>references</code></td><td><code>string</code></td><td>外键引用 <code>'table.column'</code></td></tr>
|
||||
<tr><td><code>onDelete</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>删除级联 ✅ v0.4.1</td></tr>
|
||||
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联(更新主键时触发)✅ v0.4.2</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="sql-query">🔍 SQL 查询</h2>
|
||||
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
|
||||
<p><code>db.queryStream(sql, onRow)</code> — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退物化。</p>
|
||||
|
||||
<pre><span class="c">// 流式查询 — 大表逐行处理</span>
|
||||
<span class="k">let</span> count = <span class="n">0</span>;
|
||||
<span class="k">await</span> db.<span class="f">queryStream</span>(<span class="s">"SELECT * FROM logs WHERE level = 'error'"</span>, (row) => {
|
||||
count++;
|
||||
processRow(row);
|
||||
});
|
||||
|
||||
<span class="c">// 派生表 / COUNT(DISTINCT) / NULLS 排序(v0.4.0)</span>
|
||||
<span class="k">const</span> top = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, total FROM
|
||||
(SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t
|
||||
WHERE total > 100 ORDER BY total DESC`</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT COUNT(DISTINCT city) AS n FROM users"</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT name FROM users ORDER BY age ASC NULLS FIRST"</span>);</pre>
|
||||
|
||||
<h3>完整 SQL 语法支持</h3>
|
||||
<pre><span class="c">// SELECT — 核心查询</span>
|
||||
<span class="k">const</span> rows = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
|
||||
WHERE age > 18
|
||||
ORDER BY name ASC
|
||||
LIMIT 10 OFFSET 0`</span>);
|
||||
|
||||
<span class="c">// INSERT — 插入数据</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)"</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES ('2', 'Bob', 25)"</span>);
|
||||
<span class="c">// 支持多行插入</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES ('3', 'C'), ('4', 'D')"</span>);
|
||||
|
||||
<span class="c">// UPDATE — 更新数据</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"UPDATE users SET age = 31, active = true WHERE id = '1'"</span>);
|
||||
|
||||
<span class="c">// DELETE — 删除数据</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"DELETE FROM users WHERE id = '1'"</span>);
|
||||
|
||||
<span class="c">// DDL — 表结构操作</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE products (
|
||||
id STRING PRIMARY KEY,
|
||||
name STRING NOT NULL,
|
||||
price NUMBER DEFAULT 0
|
||||
)`</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP TABLE products'</span>);
|
||||
|
||||
<span class="c">-- ALTER TABLE — 动态修改表结构 (v0.2.5)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN phone STRING'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN phone'</span>);
|
||||
|
||||
<span class="c">-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);</pre>
|
||||
|
||||
<h3>参数化查询 (v0.7.0)</h3>
|
||||
<p>位置参数 <code>?</code> 在词法层安全绑定(仅替换字符串字面量与注释之外的占位符),值按 SQL 字面量编码 —— 杜绝 SQL 注入,无需手动转义。</p>
|
||||
<pre><span class="c">// 位置参数绑定 — 字符串 '' 转义自动完成</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users VALUES (?, ?, ?, ?)"</span>, [<span class="s">'2'</span>, <span class="s">"O'Brien"</span>, <span class="n">25</span>, <span class="s">'ob@demo.com'</span>]);
|
||||
<span class="k">const</span> row = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM users WHERE name = ?'</span>, [<span class="s">"O'Brien"</span>]);
|
||||
|
||||
<span class="c">// 参数数量不匹配 → PARAM_ERROR;对象/数组参数显式拒绝;NaN/Infinity → NULL</span></pre>
|
||||
|
||||
<h3>SQL 扩展 (v0.3.0+)</h3>
|
||||
<pre><span class="c">// 多语句 — 分号分隔一次执行(返回最后一条结果)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE t (id STRING PRIMARY KEY);
|
||||
INSERT INTO t VALUES ('1'); INSERT INTO t VALUES ('2')`</span>);
|
||||
|
||||
<span class="c">// 事务语句 — BEGIN / COMMIT / ROLLBACK</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO t VALUES ('3')"</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK'</span>); <span class="c">// 回滚</span>
|
||||
|
||||
<span class="c">// INSERT INTO ... SELECT — 查询结果写入</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'INSERT INTO t SELECT id FROM t2 WHERE x > 1'</span>);
|
||||
|
||||
<span class="c">// UNION / UNION ALL — 合并查询</span>
|
||||
<span class="k">const</span> merged = <span class="k">await</span> db.<span class="f">query</span>(
|
||||
<span class="s">`SELECT name FROM users WHERE city = 'Beijing'
|
||||
UNION SELECT name FROM users WHERE age < 30`</span>);
|
||||
|
||||
<span class="c">// EXISTS / NOT EXISTS — 关联子查询</span>
|
||||
<span class="k">const</span> hasOrders = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users u
|
||||
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`</span>);
|
||||
|
||||
<span class="c">// CASE WHEN — SELECT 列 / WHERE / 聚合 (v0.3.1 / v0.3.2)</span>
|
||||
<span class="k">const</span> labeled = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT name,
|
||||
CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`</span>);
|
||||
<span class="k">const</span> adults = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) FROM users`</span>);
|
||||
|
||||
<span class="c">// CREATE / DROP INDEX — 动态二级索引</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE INDEX idx_users_city ON users (city)'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);
|
||||
|
||||
<span class="c">// v0.7.4: 建表 UNIQUE 约束不可经 DROP INDEX 解除(NOT_SUPPORTED,重建表解除);
|
||||
// 仅 CREATE UNIQUE INDEX 添加的唯一约束可随 DROP INDEX 一并删除(对齐 SQLite)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE UNIQUE INDEX idx_u ON users (email)'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_u ON users (email)'</span>); <span class="c">// unique 随之解除</span></pre>
|
||||
|
||||
<h3>维护语句 (v0.5.1 SQL 入口)</h3>
|
||||
<pre><span class="c">// EXPLAIN — 查询计划(真实索引命中信息 v0.7.0)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"EXPLAIN SELECT * FROM users WHERE email = 'a@x.com'"</span>);
|
||||
<span class="c">// { type, table, usingIndex: 'index:email', estimatedRows, actualTimeMs, ... }</span>
|
||||
|
||||
<span class="c">// ANALYZE / REINDEX / VACUUM(Aria 引擎;v0.7.3 统计含二级索引)</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ANALYZE users'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'REINDEX users'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'VACUUM'</span>);
|
||||
|
||||
<span class="c">// SAVEPOINT — 嵌套事务保存点</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SAVEPOINT sp1'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK TO SAVEPOINT sp1'</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'RELEASE SAVEPOINT sp1'</span>);
|
||||
<span class="c">// 不支持的引擎执行维护语句抛 NOT_SUPPORTED</span></pre>
|
||||
|
||||
<h3>条件表达式</h3>
|
||||
<pre><span class="c">// 比较运算符</span>
|
||||
<span class="s">`WHERE age > 18 AND name LIKE 'A%'`</span>
|
||||
<span class="s">`WHERE salary >= 5000 OR dept = 'Engineering'`</span>
|
||||
|
||||
<span class="c">// IN / NOT IN</span>
|
||||
<span class="s">`WHERE dept IN ('Engineering', 'Sales')`</span>
|
||||
|
||||
<span class="c">// NULL 检查</span>
|
||||
<span class="s">`WHERE email IS NULL`</span>
|
||||
<span class="s">`WHERE email IS NOT NULL`</span>
|
||||
|
||||
<span class="c">// NOT 取反</span>
|
||||
<span class="s">`WHERE NOT (age < 18 OR age > 65)`</span>
|
||||
|
||||
<span class="c">// 嵌套条件</span>
|
||||
<span class="s">`WHERE (age > 18 AND active = true) OR role = 'admin'`</span></pre>
|
||||
|
||||
<h2 id="query-builder">⛓ Query Builder</h2>
|
||||
<p>链式 API,TypeScript 友好,享受 IDE 自动补全。</p>
|
||||
|
||||
<h3>SELECT</h3>
|
||||
<pre><span class="k">const</span> users = db.<span class="f">table</span>(<span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// 全量查询</span>
|
||||
<span class="k">await</span> users.<span class="f">select</span>().<span class="f">execute</span>();
|
||||
|
||||
<span class="c">// 指定列 + 条件 + 排序 + 分页</span>
|
||||
<span class="k">const</span> result = <span class="k">await</span> users
|
||||
.<span class="f">select</span>([<span class="s">'name'</span>, <span class="s">'age'</span>, <span class="s">'email'</span>])
|
||||
.<span class="f">where</span>({
|
||||
<span class="s">age</span>: { <span class="s">$gt</span>: <span class="n">18</span> },
|
||||
<span class="s">name</span>: { <span class="s">$like</span>: <span class="s">'A%'</span> },
|
||||
})
|
||||
.<span class="f">orderBy</span>(<span class="s">'age'</span>, <span class="s">'desc'</span>)
|
||||
.<span class="f">limit</span>(<span class="n">10</span>)
|
||||
.<span class="f">offset</span>(<span class="n">0</span>)
|
||||
.<span class="f">execute</span>();</pre>
|
||||
|
||||
<h3>INSERT</h3>
|
||||
<pre><span class="c">// 单行插入 — 返回主键值</span>
|
||||
<span class="k">const</span> pk = <span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span>, <span class="s">age</span>: <span class="n">30</span> });
|
||||
|
||||
<span class="c">// 批量插入 — 返回主键值数组</span>
|
||||
<span class="k">const</span> pks = <span class="k">await</span> users.<span class="f">insertMany</span>([
|
||||
{ <span class="s">id</span>: <span class="s">'2'</span>, <span class="s">name</span>: <span class="s">'Bob'</span>, <span class="s">age</span>: <span class="n">25</span> },
|
||||
{ <span class="s">id</span>: <span class="s">'3'</span>, <span class="s">name</span>: <span class="s">'Charlie'</span>, <span class="s">age</span>: <span class="n">35</span> },
|
||||
]);</pre>
|
||||
|
||||
<h3>UPDATE / DELETE</h3>
|
||||
<pre><span class="c">// 更新 — 返回影响行数</span>
|
||||
<span class="k">const</span> updated = <span class="k">await</span> users
|
||||
.<span class="f">update</span>({ <span class="s">age</span>: <span class="n">31</span>, <span class="s">active</span>: <span class="k">false</span> })
|
||||
.<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> })
|
||||
.<span class="f">execute</span>(); <span class="c">// 1</span>
|
||||
|
||||
<span class="c">// 删除 — 返回影响行数</span>
|
||||
<span class="k">const</span> deleted = <span class="k">await</span> users
|
||||
.<span class="f">delete</span>()
|
||||
.<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> })
|
||||
.<span class="f">execute</span>(); <span class="c">// 1</span>
|
||||
|
||||
<span class="c">// 全表删除</span>
|
||||
<span class="k">await</span> users.<span class="f">delete</span>().<span class="f">execute</span>();</pre>
|
||||
|
||||
<h2 id="join">🔗 JOIN 查询</h2>
|
||||
<p>支持 INNER / LEFT / RIGHT / CROSS JOIN,SQL 和 Query Builder 两种方式。</p>
|
||||
|
||||
<pre><span class="c">// SQL 方式 — 支持表别名</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT u.name, d.name AS dept_name
|
||||
FROM users u
|
||||
INNER JOIN departments d ON u.dept_id = d.id
|
||||
LEFT JOIN orders o ON u.id = o.user_id
|
||||
WHERE d.name = 'Engineering'
|
||||
ORDER BY u.name`</span>);
|
||||
|
||||
<span class="c">// Query Builder 方式</span>
|
||||
<span class="k">await</span> db.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">select</span>()
|
||||
.<span class="f">as</span>(<span class="s">'u'</span>)
|
||||
.<span class="f">innerJoin</span>(<span class="s">'departments'</span>, { <span class="s">'u.dept_id'</span>: { <span class="s">$col</span>: <span class="s">'d.id'</span> } }, <span class="s">'d'</span>)
|
||||
.<span class="f">leftJoin</span>(<span class="s">'orders'</span>, { <span class="s">'u.id'</span>: { <span class="s">$col</span>: <span class="s">'o.user_id'</span> } }, <span class="s">'o'</span>)
|
||||
.<span class="f">execute</span>();
|
||||
|
||||
<span class="c">// CROSS JOIN — 笛卡尔积</span>
|
||||
.<span class="f">crossJoin</span>(<span class="s">'metadata'</span>, <span class="s">'m'</span>)</pre>
|
||||
|
||||
<h2 id="groupby">📊 GROUP BY & 聚合</h2>
|
||||
<p>五大聚合函数 + GROUP BY + HAVING + DISTINCT,完整的数据分析能力。</p>
|
||||
|
||||
<pre><span class="c">// GROUP BY + 聚合</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, COUNT(*) as cnt, SUM(salary) as total,
|
||||
AVG(salary) as avg_sal, MIN(age), MAX(age)
|
||||
FROM employees
|
||||
GROUP BY dept
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY total DESC
|
||||
LIMIT 5`</span>);
|
||||
|
||||
<span class="c">// DISTINCT 去重</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT DISTINCT dept FROM employees'</span>);
|
||||
|
||||
<span class="c">// 聚合函数别名</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT COUNT(*) AS total_users, AVG(age) AS avg_age FROM users'</span>);</pre>
|
||||
|
||||
<h2 id="where-ops">🎯 WHERE 操作符</h2>
|
||||
<p>Query Builder 使用 <code>$</code> 前缀操作符,支持逻辑组合。</p>
|
||||
|
||||
<table>
|
||||
<tr><th>操作符</th><th>含义</th><th>示例</th></tr>
|
||||
<tr><td>直接值 / <code>$eq</code></td><td>等于</td><td><code>{ age: 30 }</code> / <code>{ age: { $eq: 30 } }</code></td></tr>
|
||||
<tr><td><code>$ne</code></td><td>不等于</td><td><code>{ age: { $ne: 30 } }</code></td></tr>
|
||||
<tr><td><code>$gt</code> / <code>$gte</code></td><td>大于 / 大于等于</td><td><code>{ age: { $gt: 18 } }</code></td></tr>
|
||||
<tr><td><code>$lt</code> / <code>$lte</code></td><td>小于 / 小于等于</td><td><code>{ age: { $lt: 65 } }</code></td></tr>
|
||||
<tr><td><code>$in</code> / <code>$nin</code></td><td>在列表中 / 不在</td><td><code>{ dept: { $in: ['IT','HR'] } }</code></td></tr>
|
||||
<tr><td><code>$like</code></td><td>模糊匹配</td><td><code>{ name: { $like: 'A%' } }</code></td></tr>
|
||||
<tr><td><code>$and</code></td><td>逻辑与</td><td><code>{ $and: [{...}, {...}] }</code></td></tr>
|
||||
<tr><td><code>$or</code></td><td>逻辑或</td><td><code>{ $or: [{...}, {...}] }</code></td></tr>
|
||||
<tr><td><code>$not</code></td><td>逻辑非</td><td><code>{ age: { $not: { $gt: 18 } } }</code></td></tr>
|
||||
<tr><td><code>$col</code></td><td>列引用(JOIN ON)</td><td><code>{ 'a.id': { $col: 'b.a_id' } }</code></td></tr>
|
||||
</table>
|
||||
|
||||
<pre><span class="c">// 复合条件</span>
|
||||
.<span class="f">where</span>({
|
||||
<span class="s">$or</span>: [
|
||||
{ <span class="s">age</span>: { <span class="s">$lt</span>: <span class="n">18</span> } },
|
||||
{ <span class="s">age</span>: { <span class="s">$gt</span>: <span class="n">65</span> } },
|
||||
],
|
||||
<span class="s">active</span>: <span class="k">true</span>,
|
||||
<span class="s">name</span>: { <span class="s">$like</span>: <span class="s">'A%'</span>, <span class="s">$ne</span>: <span class="s">'Admin'</span> },
|
||||
})</pre>
|
||||
|
||||
<h2 id="transaction">🔒 事务 & 回滚</h2>
|
||||
<p>v0.1.13 起支持真正的自动回滚:事务内任何一步失败,所有变更自动撤销。</p>
|
||||
|
||||
<pre><span class="k">await</span> db.<span class="f">transaction</span>(<span class="k">async</span> (trx) => {
|
||||
<span class="c">// trx.table() 获取事务内表操作对象</span>
|
||||
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'3'</span>, <span class="s">name</span>: <span class="s">'Charlie'</span> });
|
||||
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'orders'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'o1'</span>, <span class="s">userId</span>: <span class="s">'3'</span>, <span class="s">amount</span>: <span class="n">99</span> });
|
||||
<span class="c">// ✅ 全部成功 → 自动 commit</span>
|
||||
<span class="c">// ❌ 任何一步失败 → 自动 rollback,数据恢复原状</span>
|
||||
<span class="c">// Memory 引擎:快照回滚 | KVStore 引擎:原子日志 flush</span>
|
||||
});</pre>
|
||||
|
||||
<h2 id="subquery">🔍 子查询</h2>
|
||||
<p>v0.1.13 新增子查询支持,可在 WHERE 条件中嵌套 SELECT。</p>
|
||||
|
||||
<pre><span class="c">// IN 子查询 — 查询有高额订单的用户</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
|
||||
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`</span>);
|
||||
|
||||
<span class="c">// 标量子查询 — 查询年龄等于平均年龄的用户</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
|
||||
WHERE age = (SELECT AVG(age) FROM users)`</span>);
|
||||
|
||||
<span class="c">// NOT IN 子查询</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users
|
||||
WHERE id NOT IN (SELECT user_id FROM orders)`</span>);
|
||||
|
||||
<span class="c">// v0.7.4: UPDATE / DELETE 的 WHERE 同样支持子查询(四引擎)
|
||||
// 此前未解析的 $subquery 在引擎层恒 false → 静默影响 0 行</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`UPDATE users SET role = 'vip'
|
||||
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`</span>);
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`DELETE FROM logs
|
||||
WHERE ts < (SELECT MIN(ts) FROM keep_logs)`</span>);
|
||||
|
||||
<span class="c">// 写语句中关联引用($col / EXISTS 引用外层行)显式 NOT_SUPPORTED(不静默)</span></pre>
|
||||
|
||||
<h2 id="alter-table">🏗 ALTER TABLE (🆕 v0.2.5)</h2>
|
||||
<p>v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。</p>
|
||||
|
||||
<h3>ADD COLUMN</h3>
|
||||
<pre><span class="c">-- 添加新列</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN phone STRING'</span>);
|
||||
|
||||
<span class="c">-- 带约束的添加</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN email STRING UNIQUE'</span>);
|
||||
|
||||
<span class="c">-- 带可选 COLUMN 关键字</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users ADD COLUMN age NUMBER DEFAULT 0'</span>);</pre>
|
||||
|
||||
<h3>DROP COLUMN</h3>
|
||||
<pre><span class="c">-- 删除列</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN phone'</span>);
|
||||
|
||||
<span class="c">-- 带可选 COLUMN 关键字</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ALTER TABLE users DROP COLUMN email'</span>);</pre>
|
||||
|
||||
<table>
|
||||
<tr><th>语法</th><th>说明</th></tr>
|
||||
<tr><td><code>ALTER TABLE name ADD COLUMN col type [constraints]</code></td><td>添加列(COLUMN 可选)</td></tr>
|
||||
<tr><td><code>ALTER TABLE name DROP COLUMN col</code></td><td>删除列(COLUMN 可选)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="truncate">🗑 TRUNCATE TABLE (🆕 v0.2.5)</h2>
|
||||
<p>v0.2.5 新增 TRUNCATE TABLE 语法,快速清空表数据(保留表结构)。</p>
|
||||
|
||||
<pre><span class="c">-- 快速清空表数据</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);
|
||||
|
||||
<span class="c">-- 等价于 DELETE FROM old_logs,但语义更清晰</span></pre>
|
||||
|
||||
<table>
|
||||
<tr><th>语法</th><th>说明</th></tr>
|
||||
<tr><td><code>TRUNCATE TABLE name</code></td><td>清空表数据,保留表结构</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="foreign-key">🔗 外键级联</h2>
|
||||
<p>v0.1.13 支持外键级联操作,定义表时可指定 ON DELETE / ON UPDATE 行为。</p>
|
||||
|
||||
<pre><span class="c">// 定义时指定外键 + 级联策略</span>
|
||||
<span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'orders'</span>, {
|
||||
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
|
||||
<span class="s">user_id</span>: {
|
||||
<span class="s">type</span>: <span class="s">'string'</span>,
|
||||
<span class="s">references</span>: <span class="s">'users.id'</span>,
|
||||
<span class="s">onDelete</span>: <span class="s">'CASCADE'</span>, <span class="c">// 删除用户时级联删除订单</span>
|
||||
<span class="s">onUpdate</span>: <span class="s">'RESTRICT'</span>, <span class="c">// 禁止更新被引用的用户 ID</span>
|
||||
},
|
||||
<span class="s">amount</span>: { <span class="s">type</span>: <span class="s">'number'</span> },
|
||||
});
|
||||
|
||||
<span class="c">// SQL DDL 同样支持</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE orders (
|
||||
id STRING PRIMARY KEY,
|
||||
user_id STRING REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
amount NUMBER
|
||||
)`</span>);
|
||||
|
||||
<span class="c">// 删除用户 → 其所有订单自动删除</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"DELETE FROM users WHERE id = '1'"</span>);</pre>
|
||||
|
||||
<table>
|
||||
<tr><th>级联选项</th><th>行为</th></tr>
|
||||
<tr><td><code>CASCADE</code></td><td>级联删除/更新子表中的匹配行</td></tr>
|
||||
<tr><td><code>SET NULL</code></td><td>将子表中的外键列设为 NULL</td></tr>
|
||||
<tr><td><code>RESTRICT</code></td><td>禁止操作(默认行为)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="connection-pool">🏊 连接池</h2>
|
||||
<p>v0.1.13 新增连接池管理器,避免重复创建同名数据库实例。</p>
|
||||
|
||||
<pre><span class="c">// connect() — 获取或创建实例(单例复用)</span>
|
||||
<span class="k">const</span> db1 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span>, <span class="s">mode</span>: <span class="s">'hybrid'</span> });
|
||||
<span class="k">const</span> db2 = <span class="k">await</span> <span class="f">MetonaSqlark.connect</span>({ <span class="s">name</span>: <span class="s">'my-app'</span> });
|
||||
<span class="c">// db1 === db2 — 复用已有实例,避免重复打开底层存储</span>
|
||||
|
||||
<span class="c">// disconnect() — 释放连接(引用计数 -1)</span>
|
||||
<span class="k">await</span> db2.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 2 → 1</span>
|
||||
<span class="k">await</span> db1.<span class="f">disconnect</span>(); <span class="c">// 引用计数: 1 → 0,自动 close()</span>
|
||||
|
||||
<span class="c">// disconnectAll() — 强制关闭所有连接</span>
|
||||
<span class="k">await</span> <span class="f">MetonaSqlark.disconnectAll</span>();
|
||||
|
||||
<span class="c">// getActiveConnections() — 查看活跃连接</span>
|
||||
<span class="f">MetonaSqlark.getActiveConnections</span>(); <span class="c">// ['my-app']</span></pre>
|
||||
|
||||
<h2 id="migration">🔄 数据迁移</h2>
|
||||
<p>按版本号管理表结构变更。</p>
|
||||
|
||||
<pre><span class="c">// 注册迁移</span>
|
||||
db.<span class="f">addMigration</span>(<span class="n">2</span>, <span class="k">async</span> (db) => {
|
||||
<span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'products'</span>, {
|
||||
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
|
||||
<span class="s">name</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">required</span>: <span class="k">true</span> },
|
||||
});
|
||||
});
|
||||
|
||||
db.<span class="f">addMigration</span>(<span class="n">3</span>, <span class="k">async</span> (db) => {
|
||||
<span class="c">// 添加新列、数据迁移等</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"UPDATE users SET role = 'user' WHERE role IS NULL"</span>);
|
||||
});
|
||||
|
||||
<span class="c">// 执行迁移到目标版本</span>
|
||||
<span class="k">await</span> db.<span class="f">migrateTo</span>(<span class="n">3</span>); <span class="c">// 依次执行 v2, v3 的迁移函数</span></pre>
|
||||
<p>✅ <strong>v0.4.2: 迁移版本持久化到库内</strong> — 重启后从持久化版本继续执行,已执行迁移不重跑(此前 version 每次从 config 重置,可能重跑不幂等的迁移)。</p>
|
||||
|
||||
<h2 id="selfheal">🛡 崩溃恢复自愈(v0.4.2)</h2>
|
||||
<p>异常退出(强杀/断电)后无需删库重建:打开数据库时自动跳过残缺 SSTable,应用层可调用自愈 API 恢复一致性。</p>
|
||||
|
||||
<pre><span class="c">// 自愈 — 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL</span>
|
||||
<span class="k">await</span> db.<span class="f">repair</span>();
|
||||
|
||||
<span class="c">// 清空全部数据与表结构(保留库本身,实例可继续使用)</span>
|
||||
<span class="k">await</span> db.<span class="f">clearAll</span>();
|
||||
|
||||
<span class="c">// 引擎级元数据(迁移版本等)</span>
|
||||
<span class="c">// 引擎接口 IStorageEngine 可选扩展:repair() / clearAll() / getMeta() / setMeta()</span></pre>
|
||||
|
||||
<h2 id="export">📤 导入导出</h2>
|
||||
<pre><span class="c">// 导出单表 — 返回 JSON 数组</span>
|
||||
<span class="k">const</span> userData = <span class="k">await</span> db.<span class="f">exportTable</span>(<span class="s">'users'</span>);
|
||||
<span class="c">// [{ id: '1', name: 'Alice', ... }, ...]</span>
|
||||
|
||||
<span class="c">// 导出全库 — 返回 { tableName: rows[] }</span>
|
||||
<span class="k">const</span> allData = <span class="k">await</span> db.<span class="f">exportAll</span>();
|
||||
<span class="c">// { users: [...], orders: [...], products: [...] }</span>
|
||||
|
||||
<span class="c">// 导入数据 — 返回主键列表</span>
|
||||
<span class="k">const</span> pks = <span class="k">await</span> db.<span class="f">importTable</span>(<span class="s">'users'</span>, userData);</pre>
|
||||
|
||||
<h2 id="plugin">🧩 插件 & 钩子</h2>
|
||||
<p>14 种生命周期钩子,支持插件机制。</p>
|
||||
|
||||
<table>
|
||||
<tr><th>钩子名称</th><th>触发时机</th><th>参数</th></tr>
|
||||
<tr><td><code>beforeCreateTable</code></td><td>创建表前</td><td>schema</td></tr>
|
||||
<tr><td><code>afterCreateTable</code></td><td>创建表后</td><td>schema</td></tr>
|
||||
<tr><td><code>beforeDropTable</code></td><td>删除表前</td><td>tableName</td></tr>
|
||||
<tr><td><code>afterDropTable</code></td><td>删除表后</td><td>tableName</td></tr>
|
||||
<tr><td><code>beforeInsert</code></td><td>插入前</td><td>rows[]</td></tr>
|
||||
<tr><td><code>afterInsert</code></td><td>插入后</td><td>rows[], pks</td></tr>
|
||||
<tr><td><code>beforeUpdate</code></td><td>更新前</td><td>query, updates</td></tr>
|
||||
<tr><td><code>afterUpdate</code></td><td>更新后</td><td>query, updates, count</td></tr>
|
||||
<tr><td><code>beforeDelete</code></td><td>删除前</td><td>query</td></tr>
|
||||
<tr><td><code>afterDelete</code></td><td>删除后</td><td>query, count</td></tr>
|
||||
<tr><td><code>beforeQuery</code></td><td>SQL 查询前</td><td>sql</td></tr>
|
||||
<tr><td><code>afterQuery</code></td><td>SQL 查询后</td><td>sql, result</td></tr>
|
||||
<tr><td><code>beforeTransaction</code></td><td>事务开始前</td><td>-</td></tr>
|
||||
<tr><td><code>afterTransaction</code></td><td>事务完成后</td><td>-</td></tr>
|
||||
</table>
|
||||
|
||||
<pre><span class="c">// 注册钩子</span>
|
||||
db.<span class="f">on</span>(<span class="s">'beforeInsert'</span>, <span class="k">async</span> (row) => {
|
||||
<span class="f">console.log</span>(<span class="s">'即将插入:'</span>, row);
|
||||
<span class="c">// 可在此校验、转换数据</span>
|
||||
});
|
||||
|
||||
db.<span class="f">on</span>(<span class="s">'afterQuery'</span>, <span class="k">async</span> (sql, result) => {
|
||||
<span class="f">console.log</span>(<span class="s">`查询完成 [</span><span class="k">${sql}</span><span class="s">] → </span><span class="k">${(result as any[]).length}</span><span class="s"> 行`</span>);
|
||||
});
|
||||
|
||||
<span class="c">// 注册自定义插件</span>
|
||||
<span class="k">const</span> loggerPlugin = {
|
||||
<span class="s">name</span>: <span class="s">'logger'</span>,
|
||||
<span class="s">version</span>: <span class="s">'1.0.0'</span>,
|
||||
<span class="s">description</span>: <span class="s">'记录所有数据库操作'</span>,
|
||||
<span class="s">priority</span>: <span class="n">100</span>,
|
||||
<span class="f">install</span>(db) {
|
||||
db.<span class="f">on</span>(<span class="s">'beforeQuery'</span>, (sql) => <span class="f">console.log</span>(<span class="s">'SQL:'</span>, sql));
|
||||
},
|
||||
<span class="f">destroy</span>() { <span class="c">/* 清理 */</span> },
|
||||
};
|
||||
|
||||
<span class="c">// 在 create 配置中注册</span>
|
||||
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">plugins</span>: [loggerPlugin],
|
||||
});</pre>
|
||||
|
||||
<h2 id="subscribe">📡 发布订阅</h2>
|
||||
<pre><span class="c">// 订阅表变更</span>
|
||||
<span class="k">const</span> unsubscribe = db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
|
||||
<span class="c">// event.type: 'insert' | 'update' | 'delete'</span>
|
||||
<span class="c">// event.row: 被操作的行数据</span>
|
||||
<span class="f">console.log</span>(<span class="s">`users 表 </span><span class="k">${event.type}</span><span class="s">`</span>, event.row);
|
||||
});
|
||||
|
||||
<span class="c">// 手动触发变更</span>
|
||||
db.<span class="f">emit</span>(<span class="s">'users'</span>, { <span class="s">type</span>: <span class="s">'insert'</span>, <span class="s">row</span>: { <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span> } });
|
||||
|
||||
<span class="c">// 取消订阅</span>
|
||||
<span class="f">unsubscribe</span>();</pre>
|
||||
|
||||
<h3>多标签页同步 (v0.3.2)</h3>
|
||||
<pre><span class="c">// 启用 multiTabSync 后,其他标签页的写操作会广播到此标签页</span>
|
||||
<span class="k">const</span> db = <span class="k">await</span> MetonaSqlark.<span class="f">create</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
|
||||
<span class="s">multiTabSync</span>: <span class="k">true</span>,
|
||||
});
|
||||
|
||||
<span class="c">// 订阅其他标签页的变更(event.type === 'external')</span>
|
||||
db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
|
||||
<span class="k">if</span> (event.<span class="s">type</span> === <span class="s">'external'</span>) {
|
||||
<span class="c">// Hybrid 模式已自动从磁盘重载,此处可刷新 UI</span>
|
||||
<span class="f">refreshList</span>();
|
||||
}
|
||||
});
|
||||
|
||||
<span class="c">// 手动广播(Table API 已自动广播;自定义写入可调用)</span>
|
||||
db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
|
||||
|
||||
<h2 id="react">⚛️ React 集成</h2>
|
||||
<pre><span class="k">import</span> { useQuery, useTable, useDatabase } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark/react'</span>;
|
||||
<span class="k">import</span> { db } <span class="k">from</span> <span class="s">'./db'</span>;
|
||||
|
||||
<span class="k">function</span> <span class="f">UserList</span>() {
|
||||
<span class="c">// 执行 SQL 查询,自动响应 db 变化</span>
|
||||
<span class="k">const</span> { data, loading, error, refresh } = <span class="f">useQuery</span>(
|
||||
db,
|
||||
<span class="s">'SELECT * FROM users WHERE age > 18'</span>,
|
||||
[<span class="c">/* deps */</span>]
|
||||
);
|
||||
|
||||
<span class="k">if</span> (loading) <span class="k">return</span> <div>Loading...</div>;
|
||||
<span class="k">if</span> (error) <span class="k">return</span> <div>Error: {error.message}</div>;
|
||||
|
||||
<span class="k">return</span> (
|
||||
<div>
|
||||
{data.<span class="f">map</span>(u => <div key={u.id}>{u.name} ({u.age})</div>)}
|
||||
<button onClick={refresh}>刷新</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<span class="c">// 便捷 hook — 查询整张表</span>
|
||||
<span class="k">const</span> { data, loading, refresh } = <span class="f">useTable</span>(db, <span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// 管理数据库生命周期</span>
|
||||
<span class="k">function</span> <span class="f">App</span>() {
|
||||
<span class="k">const</span> { db, ready, error } = <span class="f">useDatabase</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
|
||||
});
|
||||
<span class="k">if</span> (!ready) <span class="k">return</span> <div>Initializing...</div>;
|
||||
<span class="k">return</span> <UserList />;
|
||||
}</pre>
|
||||
|
||||
<h2 id="vue">🟢 Vue 集成</h2>
|
||||
<pre><span class="k">import</span> { useSqlarkQuery, useSqlarkTable, useSqlarkDatabase }
|
||||
<span class="k">from</span> <span class="s">'@metona-team/metona-sqlark/vue'</span>;
|
||||
<span class="k">import</span> { db } <span class="k">from</span> <span class="s">'./db'</span>;
|
||||
|
||||
<span class="c">// useSqlarkQuery — 执行 SQL 查询</span>
|
||||
<span class="k">const</span> { data, loading, error, refresh } = <span class="f">useSqlarkQuery</span>(
|
||||
db,
|
||||
<span class="s">'SELECT * FROM users WHERE age > 18'</span>
|
||||
);
|
||||
|
||||
<span class="c">// useSqlarkTable — 获取全表数据</span>
|
||||
<span class="k">const</span> { data, loading, refresh } = <span class="f">useSqlarkTable</span>(db, <span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// useSqlarkDatabase — 管理数据库生命周期</span>
|
||||
<span class="k">const</span> { db, ready, error } = <span class="f">useSqlarkDatabase</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
|
||||
});</pre>
|
||||
|
||||
<h2 id="types">🔷 TypeScript 泛型</h2>
|
||||
<pre><span class="k">interface</span> <span class="t">User</span> {
|
||||
<span class="s">id</span>: <span class="t">string</span>;
|
||||
<span class="s">name</span>: <span class="t">string</span>;
|
||||
<span class="s">age</span>: <span class="t">number</span>;
|
||||
<span class="s">email</span>?: <span class="t">string</span>;
|
||||
}
|
||||
|
||||
<span class="c">// 泛型表操作 — 类型安全的 insert/select</span>
|
||||
<span class="k">const</span> users = db.<span class="f">table</span><<span class="t">User</span>>(<span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// ✅ 类型检查通过</span>
|
||||
<span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span>, <span class="s">age</span>: <span class="n">30</span> });
|
||||
|
||||
<span class="c">// ❌ TypeScript 报错:缺少 name</span>
|
||||
<span class="c">// await users.insert({ id: '2', age: 25 });</span></pre>
|
||||
|
||||
<h2 id="config">⚙️ 完整配置项</h2>
|
||||
|
||||
<table>
|
||||
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||
<tr><td><code>name</code></td><td><code>string</code></td><td><code>'metona-sqlark'</code></td><td>数据库名称(必填)</td></tr>
|
||||
<tr><td><code>mode</code></td><td><code>'memory'|'disk'|'hybrid'|'aria'</code></td><td><code>'hybrid'</code></td><td>存储模式 🆕 aria</td></tr>
|
||||
<tr><td><code>diskEngine</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>磁盘引擎类型('kv' = 自研 KVStore 后端;v0.6.0: IndexedDB 已移除)</td></tr>
|
||||
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</code></td><td>数据库版本号</td></tr>
|
||||
<tr><td><code>plugins</code></td><td><code>MetonaPlugin[]</code></td><td><code>[]</code></td><td>初始插件列表</td></tr>
|
||||
<tr><td><code>onReady</code></td><td><code>(db) => void</code></td><td>-</td><td>初始化完成回调</td></tr>
|
||||
<tr><td><code>onError</code></td><td><code>(err) => void</code></td><td>-</td><td>错误回调(v0.2.5 接入执行路径)</td></tr>
|
||||
<tr><td><code>maxRowsPerQuery</code></td><td><code>number</code></td><td><code>0</code></td><td>查询结果行数上限(0=不限制)✅ v0.2.5 生效</td></tr>
|
||||
<tr><td><code>multiTabSync</code></td><td><code>boolean</code></td><td><code>false</code></td><td>多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2</td></tr>
|
||||
<tr><td><code>aria</code></td><td><code>AriaEngineConfig</code></td><td>-</td><td>AriaEngine 专属配置透传:walSyncMode / checkpointInterval / encryption / pageStorage / compression 等 🆕 v0.5.0</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="engine">💾 存储引擎</h2>
|
||||
|
||||
<table>
|
||||
<tr><th>引擎</th><th>模式</th><th>持久化</th><th>索引</th><th>事务</th><th>适用场景</th></tr>
|
||||
<tr><td><code>MemoryEngine</code></td><td>memory</td><td>❌</td><td>哈希</td><td>快照回滚</td><td>临时数据、缓存、测试</td></tr>
|
||||
<tr><td><code>KVStoreEngine</code> 🆕</td><td>disk</td><td>✅ KVStore(OPFS)</td><td>哈希</td><td>原子日志 flush</td><td>标准持久化(v0.6.0 替代 IndexedDB)</td></tr>
|
||||
<tr><td><code>HybridEngine</code></td><td>hybrid</td><td>✅ Write-Through</td><td>哈希</td><td>双引擎代理</td><td>生产推荐,读写均走内存</td></tr>
|
||||
<tr style="border-top:2px solid var(--primary);"><td><code style="color:#ec4899;font-weight:700;">AriaEngine</code></td><td>aria</td><td>✅ WAL + SSTable</td><td>LSM-Tree</td><td>MVCC 快照隔离</td><td>自研引擎:大表、高并发、需崩溃恢复</td></tr>
|
||||
</table>
|
||||
<p>✅ <strong>v0.6.0: IndexedDB 已完全移除</strong> — disk 模式改用自研 KVStore 引擎(多 key 原子写 + 快照/日志崩溃恢复),旧库可经 <code>migrateFromIndexedDB()</code> 一键迁移。</p>
|
||||
|
||||
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
|
||||
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
|
||||
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。<br>
|
||||
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
|
||||
<strong>v0.4.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
|
||||
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待。<br>
|
||||
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2("SSTC")与 v1("SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持。<br>
|
||||
<strong>v0.5.0 存储后端生产级硬化</strong> — 真实 CRC-32 完整性校验(SSTable 整文件 + WAL 记录,旧文件兼容)· 全库 AES-256-GCM 透明加密(EncryptedBackend + PBKDF2 密钥派生 + 密码验证)· WAL 分片文件重构(真追加 + 空洞检测 + 旧格式迁移)· SSTable 4KB 页面化物理存储(BufferPool/FileManager 真实接入,meta 存 pageIds 兼容旧数据)· OPFS 后端 v2(append 真追加 / 写队列健壮性 / 残留清理)· Web Locks 多标签页独占锁(ARIA_LOCKED)· LZ4 v2 原始大小头 · Playwright 真实 Chromium e2e(7 用例)· DatabaseConfig.aria 配置透传。<br>
|
||||
<strong>v0.5.1 深度审查修复</strong> — 14 个生命周期钩子全部真实接线(此前 6 个 CRUD 钩子从未触发)· EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT SQL 入口补齐(此前仅有引擎方法无法触发)· db.backup() 公共方法 · 删除全部死代码(utils.ts 整文件 / MVCC 读侧 / estimateQueryCost 未接线优化器 / 40+ 统计辅助方法)。<br>
|
||||
<strong>v0.6.0 完全移除 IndexedDB</strong> — 自研 KVStore 事务存储引擎(多 key 原子写 = 单日志记录原子追加 · 快照 checkpoint + 两阶段崩溃恢复 · CRC-32 自愈)· disk 模式切换 KVStoreEngine(替代 IndexedDBEngine + OPFSEngine)· 事务内 DDL / 外键级联 / 二级索引完整持久化 · migrateFromIndexedDB() 旧库一键迁移 · 10 万 key 压力验证 · e2e 崩溃注入 + KVStoreEngine 真实环境(12 用例)。<br>
|
||||
<strong>v0.6.1 生产可用性深度审查</strong> — MemoryEngine 级联环(A→B→A)无限递归修复(visited 保护,与 Aria 对齐)· KVStore 快照损坏水位 bug 修复(metaSeq 误跳日志)· 多表事务 / 级联写入合并单条日志记录真原子(崩溃无部分提交)· 未 open 防护统一 · <strong>AriaEngine 可选自研 KVStore 后端</strong>(storageBackend: 'kv':KVStore APPEND 日志支持 WAL 追加、writeMany 真原子、不再依赖浏览器 OPFS)· 二级索引范围扫描尾块漏读 P0 修复 · 批量插入性能悬崖修复(10 万行 kv 后端 353s → 12.5s)· 页面 id 崩溃回退 / flush 并发写 meta 两个崩溃恢复 P0 修复 · 10 万级 kv/opfs 双后端回归。<br>
|
||||
<strong>v0.6.2 深度审计修复(数据正确性专项)</strong> — KVStoreEngine 数值主键 update 丢行 P0 · update 主键变更撞已有主键静默覆盖 P0 · Aria 二级索引范围查询边界算法错误(小数/字符串静默丢数据)P1 · 索引列 IS NULL 恒空(Aria)P1 · Aria unique 约束未强制(批内互查 + 索引前缀扫描)· 非主键 update 索引旧值残留 · EXPLAIN 写语句产生真实副作用修复。<br>
|
||||
<strong>v0.6.3 原子性 / 一致性 / 资源治理</strong> — KVStore 混合写单记录真原子(writeBatch,主键变更/级联全有或全无)· WAL full 模式写入失败抛错(不再吞错)· Memory SET NULL 级联索引残留 · delete 级联两阶段 RESTRICT 预检(无事务部分级联修复)· BufferPool 驱逐清理 pages Map(内存预算真实生效)· MVCC 已提交版本清理 · LSM.flush 重复入链 · rollbackToSavepoint 重建二级索引。<br>
|
||||
<strong>v0.7.0 参数化查询 / 事务性能 / 语义硬化</strong> — <strong>参数化查询</strong>(<code>db.query(sql, params)</code> 位置参数 `?`,词法层绑定 + SQL 字面量安全编码,注入防护从根上成立)· EXPLAIN 真实索引命中信息(pk / index:col)· KVStoreEngine 事务行级增量 flush(大表事务改 1 行 commit 仅 1 条记录)· 移除每次 commit 强制全量 checkpoint · 复合主键显式拒绝(SCHEMA_ERROR,v0.8 路线图)。<br>
|
||||
<strong>v0.7.1 API 修复与防御统一</strong> — <code>MetonaSqlark.create</code> 静态工厂(README 示例在 ESM/Node 下可用)· close 未初始化崩溃防御 · AriaEngine 未 open 防护统一 · 事务回滚失败不掩盖原始错误 · <code>__proto__</code> 列名原型污染防护 · lint 清零。<br>
|
||||
<strong>v0.7.2 语句级原子性 / 事务 DDL / 约束硬化</strong> — UPDATE 语句级两阶段原子(多行匹配第 N 行失败整句不执行 + 批内唯一互查)· 事务内 DDL 显式拒绝(四引擎对齐)· SET NULL 级联绕过 required 约束整体拒绝 · 参数绑定注释感知(注释中 `?`/引号不参与绑定)· 未闭合字符串显式 PARSE_ERROR · 未知 where 操作符抛 QUERY_ERROR(此前静默全匹配)· update undefined 语义化(保留旧值)· Hybrid write-through 失败补偿(磁盘失败自动重载内存对齐)。<br>
|
||||
<strong>v0.7.3 深度审计第六阶段:INSERT 原子 / 索引一致性 / 边界窗口</strong> — INSERT 语句级两阶段原子(三引擎 + Aria PK 批内重复,此前部分提交)· 索引列 IS NULL 恒空修复(Memory/KVStore/Hybrid,对齐 Aria)· delete RESTRICT 预检不再破坏索引 · queryStream 子查询静默空结果修复($subquery/$exists/$col 回退物化)· ALTER DROP 索引列残留清理 · CREATE UNIQUE INDEX 存量重复数据校验(失败原子回滚)· <code>SELECT *, col AS alias</code> 解析与投影 · WAL BEGIN/ROLLBACK 写失败窗口修复(事务不泄漏/数据不复活)· aria $in 批级预加载(消除逐值 drainChain 性能悬崖)· 多条件 AND 等值下推(索引真正生效,EXPLAIN 同步)· ANALYZE 统计含二级索引 · React/Vue hooks 生命周期修复(config 变更重建 / 卸载关闭)· 迁移无主键旧库兜底 · 1256 测试 75 套件 · 90.0% 行覆盖率。<br>
|
||||
<strong>v0.7.4 深度审计第七阶段:写语句子查询 / 约束硬化 / 真惰性流式</strong> — UPDATE/DELETE WHERE 子查询正确执行(四引擎,此前静默 0 行;关联引用显式 NOT_SUPPORTED;EXPLAIN 估算同步修复)· 主键 NULL/undefined 强制拒绝(SQL 语义 PK 隐含 NOT NULL,此前静默生成 "null"/"undefined" 主键)· DROP INDEX 保留建表 UNIQUE 约束(对齐 SQLite:需重建表解除;仅 CREATE UNIQUE INDEX 添加的可随索引删除)· GROUP BY / DISTINCT / UNION 键类型安全编码(null 与 'null' 字符串不再合并)· UPDATE 未知列显式 COLUMN_NOT_FOUND(此前脏列写入存储行)· queryStream 多语句显式 PARSE_ERROR(此前静默忽略后续语句)· KVStore 后台错误跨 reopen 清理 + Hybrid beginTransaction 失败补偿回滚 · <strong>Aria findStream 真惰性</strong>(MergeIterator 迭代器化 + SSTable/MemTable 生成器扫描,limit 提前终止,大表流式内存 O(1)——此前内部 drain 全量物化)· REINDEX 单次全表扫描重建全部索引列(此前每列一次全扫描)· RB-Tree 删除双黑修复边界 + LSM 死代码清理 · 1304 测试 76 套件 · 90.1% 行覆盖率。</p>
|
||||
|
||||
<h3>存储模式对比</h3>
|
||||
<table>
|
||||
<tr><th>特性</th><th>Memory</th><th>Disk (KVStore)</th><th>Hybrid</th><th>Aria</th></tr>
|
||||
<tr><td>持久化</td><td>❌</td><td>✅ KVStore(OPFS / 内存介质)</td><td>✅ 双写</td><td>✅ 后端决定</td></tr>
|
||||
<tr><td>事务</td><td>✅ 快照回滚</td><td>✅ 单日志记录原子写</td><td>✅ 双引擎(磁盘优先)</td><td>✅ MVCC 快照隔离</td></tr>
|
||||
<tr><td>索引</td><td>Hash</td><td>Hash(重启恢复)</td><td>Hash</td><td>LSM 二级(重启恢复)</td></tr>
|
||||
<tr><td>上限</td><td>内存</td><td>磁盘可用</td><td>磁盘可用</td><td>内存</td></tr>
|
||||
<tr><td>多标签页锁</td><td>—</td><td>—</td><td>—</td><td>✅ Web Locks</td></tr>
|
||||
<tr><td>全库加密</td><td>—</td><td>—</td><td>—</td><td>✅ AES-GCM</td></tr>
|
||||
<tr><td>浏览器</td><td>全部</td><td>Chrome/Edge 102+ · Firefox 111+ · Safari 15.2+</td><td>同上</td><td>同上(后端决定)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>核心特性</h3>
|
||||
<table>
|
||||
<tr><th>特性</th><th>说明</th></tr>
|
||||
<tr><td><strong>LSM-Tree 索引</strong></td><td>MemTable (红黑树) + 多级 SSTable,写优化,支持范围扫描</td></tr>
|
||||
<tr><td><strong>4KB 页面化物理存储</strong></td><td>SSTable 切分为 4KB 页面(FileManager 分配 pageId + BufferPool LRU 缓存 256 页 ≈ 1MB),save 即落盘,`SSTableMeta.pageIds` 持久化(旧整 value 数据兼容)✅ v0.5.0</td></tr>
|
||||
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB),脏页写回 ✅ v0.5.0 真实接入</td></tr>
|
||||
<tr><td><strong>WAL 日志</strong></td><td>分片文件(`__wal_%06d.bin`,4MB 阈值切换 + 真追加)· 标准 CRC32 记录校验 · full/batch/none 三种同步模式(full 真正同步)· 空洞检测截断 · 16MB 阈值自动 checkpoint ✅ v0.5.0</td></tr>
|
||||
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC(引擎事务读取走 txnSnapshot,MVCC 版本链作 undo log)</td></tr>
|
||||
<tr><td><strong>全库 AES-GCM 加密</strong></td><td>EncryptedBackend 透明加解密(WAL/SSTable/Schema/元数据全密文)· PBKDF2 密钥派生 + salt 持久化 + 密码验证 · 明文/加密库开关一致性检测 ✅ v0.5.0</td></tr>
|
||||
<tr><td><strong>多标签页独占锁</strong></td><td>Web Locks API:第二个标签页打开同一库抛 `ARIA_LOCKED`,open 失败自动释放 ✅ v0.5.0</td></tr>
|
||||
<tr><td><strong>Bloom Filter</strong></td><td>快速判定 key 不存在,减少无效磁盘 I/O,SSTableReader 二分查找统一 ✅ v0.2.5</td></tr>
|
||||
<tr><td><strong>LZ4 压缩(v2)</strong></td><td>SSTable 级压缩,压缩流自带原始大小头(高压缩率不截断)✅ v0.5.0</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>使用方式</h3>
|
||||
<pre><span class="c">// 激活 AriaEngine(OPFS 后端默认页面化存储)</span>
|
||||
<span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'aria'</span>, <span class="c">// 🆕 AriaEngine 模式</span>
|
||||
<span class="s">diskEngine</span>: <span class="s">'opfs'</span>, <span class="c">// 底层存储后端(opfs | kv | memory)</span>
|
||||
<span class="s">aria</span>: { <span class="c">// AriaEngine 专属配置透传 🆕 v0.5.0</span>
|
||||
<span class="s">walSyncMode</span>: <span class="s">'full'</span>, <span class="c">// WAL 同步模式</span>
|
||||
<span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库 AES-GCM 加密 🆕 v0.5.0</span>
|
||||
},
|
||||
});
|
||||
|
||||
<span class="c">// 或直接实例化 — 支持细粒度配置</span>
|
||||
<span class="k">import</span> { <span class="t">AriaEngine</span> } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark'</span>;
|
||||
<span class="k">const</span> engine = <span class="k">new</span> <span class="f">AriaEngine</span>({
|
||||
<span class="s">bufferPoolPages</span>: <span class="n">256</span>, <span class="c">// Buffer Pool 页面数量</span>
|
||||
<span class="s">memtableSizeThreshold</span>: <span class="n">4194304</span>, <span class="c">// MemTable 刷盘阈值 4MB</span>
|
||||
<span class="s">walSyncMode</span>: <span class="s">'full'</span>, <span class="c">// 'full' | 'batch' | 'none'(默认 full)</span>
|
||||
<span class="s">storageBackend</span>: <span class="s">'opfs'</span>, <span class="c">// 存储后端(opfs | kv | memory)</span>
|
||||
<span class="s">encryption</span>: { <span class="s">password</span>: <span class="s">'my-password'</span> }, <span class="c">// 全库加密</span>
|
||||
});</pre>
|
||||
|
||||
<h3>AriaEngine 配置项</h3>
|
||||
<table>
|
||||
<tr><th>属性</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||
<tr><td><code>pageSize</code></td><td><code>number</code></td><td><code>4096</code></td><td>页面大小(字节)</td></tr>
|
||||
<tr><td><code>bufferPoolPages</code></td><td><code>number</code></td><td><code>256</code></td><td>Buffer Pool 页面数量</td></tr>
|
||||
<tr><td><code>memtableSizeThreshold</code></td><td><code>number</code></td><td><code>4194304</code></td><td>MemTable 刷盘阈值(字节)</td></tr>
|
||||
<tr><td><code>levelSizeMultiplier</code></td><td><code>number</code></td><td><code>10</code></td><td>LSM 层级容量倍数</td></tr>
|
||||
<tr><td><code>bloomFilterBitsPerKey</code></td><td><code>number</code></td><td><code>10</code></td><td>Bloom Filter 每 key 位数</td></tr>
|
||||
<tr><td><code>walEnabled</code></td><td><code>boolean</code></td><td><code>true</code></td><td>是否启用 WAL</td></tr>
|
||||
<tr><td><code>walSyncMode</code></td><td><code>'full'|'batch'|'none'</code></td><td><code>'full'</code></td><td>WAL 同步策略(full 真正同步 ✅ v0.2.4 起默认 full)</td></tr>
|
||||
<tr><td><code>checkpointInterval</code></td><td><code>number</code></td><td><code>1000</code></td><td>Checkpoint 触发间隔(操作数)</td></tr>
|
||||
<tr><td><code>walSizeThreshold</code></td><td><code>number</code></td><td><code>16777216</code></td><td>WAL 大小阈值(字节),超阈值触发 checkpoint ✅ v0.2.5</td></tr>
|
||||
<tr><td><code>compression</code></td><td><code>boolean</code></td><td><code>false</code></td><td>是否启用页面压缩</td></tr>
|
||||
<tr><td><code>storageBackend</code></td><td><code>'opfs'|'memory'|'kv'</code></td><td><code>'opfs'</code></td><td>存储后端类型('kv' = 自研 KVStore 后端 🆕 v0.6.1)</td></tr>
|
||||
<tr><td><code>encryption</code></td><td><code>{ password: string }</code></td><td>-</td><td>全库 AES-256-GCM 透明加密(PBKDF2 派生 + salt 持久化 + 密码验证)🆕 v0.5.0</td></tr>
|
||||
<tr><td><code>pageStorage</code></td><td><code>boolean</code></td><td>opfs/kv 自动启用</td><td>SSTable 4KB 页面化物理存储(BufferPool 缓存)🆕 v0.5.0</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="errors">⚠️ 错误处理</h2>
|
||||
<p>所有错误抛出 <code>DatabaseError</code> 实例。</p>
|
||||
|
||||
<pre><span class="k">try</span> {
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM nonexistent'</span>);
|
||||
} <span class="k">catch</span> (err) {
|
||||
<span class="k">if</span> (err <span class="k">instanceof</span> <span class="t">DatabaseError</span>) {
|
||||
<span class="f">console.log</span>(err.message); <span class="c">// 'Table "nonexistent" does not exist'</span>
|
||||
<span class="f">console.log</span>(err.code); <span class="c">// 'TABLE_NOT_FOUND'</span>
|
||||
<span class="f">console.log</span>(err.details); <span class="c">// 附加信息</span>
|
||||
}
|
||||
}</pre>
|
||||
|
||||
<table>
|
||||
<tr><th>错误码</th><th>触发场景</th></tr>
|
||||
<tr><td><code>TABLE_NOT_FOUND</code></td><td>表不存在</td></tr>
|
||||
<tr><td><code>TABLE_EXISTS</code></td><td>表已存在</td></tr>
|
||||
<tr><td><code>DUPLICATE_KEY</code></td><td>主键重复</td></tr>
|
||||
<tr><td><code>UNIQUE_VIOLATION</code></td><td>唯一约束冲突</td></tr>
|
||||
<tr><td><code>VALIDATION_ERROR</code></td><td>数据校验失败</td></tr>
|
||||
<tr><td><code>TYPE_ERROR</code></td><td>字段类型错误</td></tr>
|
||||
<tr><td><code>SCHEMA_ERROR</code></td><td>表结构定义错误</td></tr>
|
||||
<tr><td><code>DB_NOT_READY</code></td><td>数据库未初始化</td></tr>
|
||||
<tr><td><code>PARSE_ERROR</code></td><td>SQL 语法错误</td></tr>
|
||||
<tr><td><code>TRANSACTION_ERROR</code></td><td>事务执行失败</td></tr>
|
||||
<tr><td><code>COMPILE_ERROR</code></td><td>编译 AST 到查询计划失败</td></tr>
|
||||
<tr><td><code>CONFIG_ERROR</code></td><td>配置错误</td></tr>
|
||||
<tr><td><code>ARIA_LOCKED</code></td><td>数据库已被其他标签页打开(Web Locks 独占锁)🆕 v0.5.0</td></tr>
|
||||
<tr><td><code>ARIA_DECRYPT_ERROR</code></td><td>解密失败:密码错误 / 密钥元数据损坏 / 数据被篡改 🆕 v0.5.0</td></tr>
|
||||
<tr><td><code>ARIA_ENCRYPT_REQUIRED</code></td><td>库已加密但未提供密码 🆕 v0.5.0</td></tr>
|
||||
<tr><td><code>ARIA_ENCRYPT_CONFIG_ERROR</code></td><td>明文库无法以加密模式打开 / 空密码 🆕 v0.5.0</td></tr>
|
||||
<tr><td><code>ARIA_BACKGROUND_ERROR</code></td><td>后台 flush/compaction 失败(flush/close 时显式报告)</td></tr>
|
||||
<tr><td><code>SAVEPOINT_EXISTS</code> / <code>SAVEPOINT_NOT_FOUND</code></td><td>保存点已存在 / 回滚到不存在的保存点 🆕 v0.5.1 SQL 入口</td></tr>
|
||||
<tr><td><code>NOT_SUPPORTED</code></td><td>引擎不支持的操作(如 Memory 引擎执行 ANALYZE / VACUUM / SAVEPOINT)🆕 v0.5.1</td></tr>
|
||||
<tr><td><code>PARAM_ERROR</code></td><td>参数绑定错误:数量不匹配 / 对象数组参数 🆕 v0.7.0</td></tr>
|
||||
<tr><td><code>QUERY_ERROR</code></td><td>未知 where 操作符(如拼错的 <code>$betwen</code>)🆕 v0.7.2</td></tr>
|
||||
<tr><td><code>COLUMN_NOT_FOUND</code></td><td>列不存在:UPDATE 未知列 / ALTER DROP 不存在列(v0.7.4 UPDATE 未知列显式报错)</td></tr>
|
||||
<tr><td><code>INDEX_NOT_FOUND</code></td><td>DROP 不存在的索引</td></tr>
|
||||
</table>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.sidebar a').forEach(a => {
|
||||
a.addEventListener('click', function() {
|
||||
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,546 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<title>在线演示 — MetonaToast</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070b14; --surface: rgba(255,255,255,0.03); --surface2: rgba(255,255,255,0.05);
|
||||
--border: rgba(255,255,255,0.08); --text: #e8ecf4; --muted: #8b94a7;
|
||||
--accent: #a5b4fc; --grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
||||
--radius: 14px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
|
||||
background:var(--bg); color:var(--text); line-height:1.65; min-height:100vh;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(99,102,241,0.12), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 90% 20%, rgba(168,85,247,0.07), transparent);
|
||||
}
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
code { background:rgba(99,102,241,0.15); color:#a5b4fc; padding:1px 6px; border-radius:5px; font-size:.85em; }
|
||||
.grad-text { background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent; }
|
||||
|
||||
.nav { position:sticky; top:0; z-index:100; background:rgba(7,11,20,0.72); backdrop-filter:blur(16px); border-bottom:1px solid var(--border); }
|
||||
.nav-inner { max-width:1320px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; padding:0 22px; height:58px; }
|
||||
.nav-logo { font-size:18px; font-weight:800; }
|
||||
.nav-links { display:flex; gap:22px; }
|
||||
.nav-links a { color:var(--muted); font-size:13.5px; font-weight:500; transition:.2s; }
|
||||
.nav-links a:hover { color:var(--accent); }
|
||||
|
||||
.hero { text-align:center; padding:38px 20px 28px; }
|
||||
.hero h1 { font-size:34px; font-weight:800; }
|
||||
.hero p { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||
|
||||
.container { max-width:1320px; margin:0 auto; padding:20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:16px; }
|
||||
.card {
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:20px; transition:border-color .2s, transform .2s; position:relative; overflow:hidden;
|
||||
}
|
||||
.card:hover { border-color:rgba(129,140,248,.35); }
|
||||
.card::before {
|
||||
content:''; position:absolute; top:0; left:0; right:0; height:2px;
|
||||
background:var(--grad); opacity:0; transition:opacity .25s;
|
||||
}
|
||||
.card:hover::before { opacity:.8; }
|
||||
.card h2 { font-size:14.5px; margin-bottom:2px; font-weight:700; }
|
||||
.card > p { color:var(--muted); font-size:11.5px; margin-bottom:12px; }
|
||||
.btn-row { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.btn {
|
||||
padding:8px 14px; border:none; border-radius:8px; font-size:12px; font-weight:600;
|
||||
cursor:pointer; transition:transform .15s, filter .15s, box-shadow .15s; color:#fff;
|
||||
}
|
||||
.btn:hover { transform:translateY(-1.5px); filter:brightness(1.15); box-shadow:0 6px 18px -6px rgba(0,0,0,.5); }
|
||||
.btn-sm { padding:5.5px 11px; font-size:11px; border-radius:6px; }
|
||||
.btn-tiny { padding:4px 9px; font-size:10px; border-radius:5px; }
|
||||
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||||
.btn-outline:hover { border-color:rgba(129,140,248,.5); background:rgba(129,140,248,.08); }
|
||||
.c-s { background:#10b981; } .c-e { background:#ef4444; } .c-w { background:#f59e0b; color:#1e293b; }
|
||||
.c-i { background:#3b82f6; } .c-p { background:#6366f1; } .c-pp { background:#8b5cf6; }
|
||||
.c-pk { background:#ec4899; } .c-t { background:#14b8a6; } .c-o { background:#f97316; }
|
||||
.c-sl { background:#475569; } .c-r { background:#f43f5e; } .c-g { background:#22c55e; }
|
||||
.c-grad { background:var(--grad); }
|
||||
.label {
|
||||
font-size:10px; text-transform:uppercase; letter-spacing:1px; color:var(--muted);
|
||||
margin-bottom:10px; font-weight:700;
|
||||
}
|
||||
.demo-section { border-top:1px solid var(--border); padding-top:10px; margin-top:10px; }
|
||||
.status-line { font-size:11.5px; color:#34d399; margin-top:10px; font-family:monospace; min-height:18px; }
|
||||
footer { text-align:center; padding:30px 20px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }
|
||||
|
||||
@media (max-width:820px) { .container { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-inner">
|
||||
<div class="nav-logo"><span class="grad-text">MetonaToast</span></div>
|
||||
<div class="nav-links">
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html">文档</a>
|
||||
<a href="demo.html">演示</a>
|
||||
<a href="https://git.metona.cn/MetonaTeam/MetonaToast" target="_blank">Gitea</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero">
|
||||
<h1><span class="grad-text">在线演示</span></h1>
|
||||
<p>覆盖项目全部功能的交互演示 — 点击即可体验</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
|
||||
<!-- 1. 基础通知 -->
|
||||
<div class="card">
|
||||
<div class="label">📢 基础通知</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-s" onclick="MeToast.success('操作成功!')">✓ 成功</button>
|
||||
<button class="btn c-e" onclick="MeToast.error('操作失败!')">✕ 错误</button>
|
||||
<button class="btn c-w" onclick="MeToast.warning('请注意检查')">⚠ 警告</button>
|
||||
<button class="btn c-i" onclick="MeToast.info('系统维护中')">ℹ 信息</button>
|
||||
<button class="btn c-sl" onclick="MeToast.show('默认消息')">— 默认</button>
|
||||
</div>
|
||||
<div class="demo-section">
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success({title:'保存成功',message:'文件已同步到云端'})">带标题</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.info('持续 10 秒',{duration:10000})">长时 10s</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.warning('手动关闭',{duration:0,closeButton:true})">手动关闭</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.info('',{icon:false})">无图标</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.info('无关闭按钮',{closeButton:false})">无按钮</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoCustomIcon()">自定义图标</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Loading 链式 -->
|
||||
<div class="card">
|
||||
<div class="label">🔄 Loading 链式转换 <span style="color:#34d399">id 稳定</span></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-s" onclick="demoLoading('success')">提交成功</button>
|
||||
<button class="btn c-e" onclick="demoLoading('error')">提交失败</button>
|
||||
<button class="btn c-w" onclick="demoLoading('warning')">转为警告</button>
|
||||
<button class="btn c-i" onclick="demoLoading('update')">原地更新</button>
|
||||
</div>
|
||||
<p class="status-line" id="loading-status">转换后返回的 toast 与 loading 是同一个实例(id 不变)</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. 对话框 -->
|
||||
<div class="card">
|
||||
<div class="label">💬 对话框</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-pp" onclick="demoPromise()">Promise 风格</button>
|
||||
<button class="btn c-pk" onclick="demoConfirm()">确认对话框</button>
|
||||
<button class="btn c-t" onclick="demoPrompt()">输入对话框</button>
|
||||
<button class="btn c-o" onclick="demoPromptPassword()">密码输入</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 进度 & 倒计时 -->
|
||||
<div class="card">
|
||||
<div class="label">📊 进度 & 倒计时</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-o" onclick="demoProgress('horizontal')">进度条(水平)</button>
|
||||
<button class="btn c-g" onclick="demoProgress('vertical')">进度条(垂直)</button>
|
||||
<button class="btn c-pk" onclick="demoCountdown()">倒计时 5s</button>
|
||||
<button class="btn c-r" onclick="demoCountdownPause()">倒计时(可暂停)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 队列 & 堆叠 -->
|
||||
<div class="card">
|
||||
<div class="label">📋 队列 & 堆叠</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-p" onclick="demoQueue()">队列展示</button>
|
||||
<button class="btn c-e" onclick="demoQueueCancel()">队列中途取消</button>
|
||||
<button class="btn c-pp" onclick="demoStack()">堆叠展示</button>
|
||||
<button class="btn c-t" onclick="demoStackMixed()">混合类型堆叠</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Action Toast -->
|
||||
<div class="card">
|
||||
<div class="label">⚡ Action Toast <span style="color:#34d399">close:false 不关闭</span></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
|
||||
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
|
||||
<button class="btn c-s" onclick="demoActionNoClose()">不自动关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. 分组管理 -->
|
||||
<div class="card">
|
||||
<div class="label">📁 分组管理</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
|
||||
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
|
||||
<button class="btn c-i" onclick="MeToast.info('分组数量: '+group.count(),{type:'info'})">查看数量</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoGroupMixed()">混合类型分组</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. 动画效果 -->
|
||||
<div class="card">
|
||||
<div class="label">🎭 11 种动画效果</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('slide')">slide</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('fade')">fade</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('scale')">scale</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('bounce')">bounce</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('flip')">flip</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('rotate')">rotate</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('zoom')">zoom</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('slideUp')">slideUp</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('slideDown')">slideDown</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('slideLeft')">slideLeft</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="anim('slideRight')">slideRight</button>
|
||||
</div>
|
||||
<div class="demo-section">
|
||||
<div class="btn-row">
|
||||
<button class="btn c-grad" onclick="demoCustomAnim()">✨ 注册自定义动画</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. 位置 -->
|
||||
<div class="card">
|
||||
<div class="label">📍 全部位置</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左上角',{position:'top-left'})">↖ top-left</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('顶部居中',{position:'top-center'})">↑ top-center</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右上角',{position:'top-right'})">↗ top-right</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左下角',{position:'bottom-left'})">↙ bottom-left</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('底部居中',{position:'bottom-center'})">↓ bottom-center</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右下角',{position:'bottom-right'})">↘ bottom-right</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. 主题 -->
|
||||
<div class="card">
|
||||
<div class="label">🎨 主题切换 & 自定义</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('light')">☀️ Light</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('dark')">🌙 Dark</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('auto')">🔄 Auto</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('warm')">🔥 Warm</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoRegisterTheme()">注册自定义</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 11. 插件 -->
|
||||
<div class="card">
|
||||
<div class="label">🔌 插件系统 <span style="color:#34d399">4 款预设</span></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.use('keyboard');MeToast.info('已安装 — 按 ESC 关闭所有 Toast')">keyboard</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.use('persistence');MeToast.info('已安装 — 配置持久化')">persistence</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.use('accessibility');MeToast.info('已安装 — 屏幕阅读器')">accessibility</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="MeToast.use('dedupe');MeToast.info('已安装 — 同消息去重')">dedupe</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoCustomPlugin()">自定义插件</button>
|
||||
</div>
|
||||
<div class="demo-section">
|
||||
<div class="btn-row">
|
||||
<button class="btn c-t btn-sm" onclick="demoDedupe()">去重演示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 12. 钩子系统 -->
|
||||
<div class="card">
|
||||
<div class="label">🔗 钩子系统 <span style="color:#34d399">beforeShow 拦截演示</span></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-p" onclick="demoHookBlock()">开启拦截 error</button>
|
||||
<button class="btn c-e" onclick="MeToast.error('这条会被拦截')">触发被拦 error</button>
|
||||
<button class="btn c-s" onclick="MeToast.success('这条不受影响')">正常 success</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoHookUnblock()">关闭拦截</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoHookCount()">钩子统计</button>
|
||||
</div>
|
||||
<p class="status-line" id="hook-status">钩子:未注册</p>
|
||||
</div>
|
||||
|
||||
<!-- 13. 高级特性 -->
|
||||
<div class="card">
|
||||
<div class="label">🔥 高级特性</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
|
||||
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
|
||||
<button class="btn c-pp" onclick="demoCustomRender()">自定义渲染</button>
|
||||
<button class="btn c-w" onclick="demoNotify()">系统通知</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 14. Toast 管理 -->
|
||||
<div class="card">
|
||||
<div class="label">🔧 Toast 管理</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-s" onclick="MeToast.success('消息 '+(MeToast.count()+1))">发一条</button>
|
||||
<button class="btn c-i" onclick="MeToast.info('消息 '+(MeToast.count()+1))">再一条</button>
|
||||
<button class="btn c-w" onclick="MeToast.info('当前 '+MeToast.count()+' 条')">查看数量</button>
|
||||
<button class="btn c-sl" onclick="MeToast.pauseAll();MeToast.info('已暂停全部')">暂停全部</button>
|
||||
<button class="btn c-sl" onclick="MeToast.resumeAll();MeToast.info('已恢复全部')">恢复全部</button>
|
||||
<button class="btn c-e" onclick="MeToast.dismiss()">关闭全部</button>
|
||||
<button class="btn c-r" onclick="MeToast.destroy();MeToast.init({});MeToast.info('已销毁并重建')">销毁重建</button>
|
||||
</div>
|
||||
<div class="demo-section">
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline btn-tiny" onclick="demoFindById()">findById</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="demoGetAll()">getAll</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="demoClearByPos()">clear(位置)</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="demoGetConfig()">查看配置</button>
|
||||
<button class="btn btn-outline btn-tiny" onclick="demoRemove()">removeToast 立即移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 15. onError -->
|
||||
<div class="card">
|
||||
<div class="label">🛡️ onError 错误捕获</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
|
||||
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 16. 交互特性 -->
|
||||
<div class="card">
|
||||
<div class="label">✋ 交互特性</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-i" onclick="demoDraggable()">拖拽关闭</button>
|
||||
<button class="btn c-p" onclick="demoDragThreshold()">拖拽阈值 300px</button>
|
||||
<button class="btn c-w" onclick="demoPauseHover()">悬停暂停</button>
|
||||
<button class="btn c-pp" onclick="demoCloseOnClick()">点击关闭</button>
|
||||
<button class="btn c-t" onclick="demoWidth()">自定义宽度</button>
|
||||
<button class="btn c-sl" onclick="demoClassName()">自定义 className</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 17. 国际化 -->
|
||||
<div class="card">
|
||||
<div class="label">🌍 国际化 & 格式化</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn c-s" onclick="switchLocale('zh-CN')">切换到中文</button>
|
||||
<button class="btn c-i" onclick="switchLocale('en-US')">Switch to English</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="demoFormat()">格式化演示</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 18. 图标网格 -->
|
||||
<div class="card" style="grid-column:1/-1;">
|
||||
<div class="label">🎨 107 种预设图标 — 点击任意图标发送 Toast</div>
|
||||
<div class="btn-row" id="icon-grid" style="max-height:320px;overflow-y:auto;padding:4px 0;"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
MetonaToast v0.5.0 · MIT ·
|
||||
<a href="index.html">首页</a> ·
|
||||
<a href="docs.html">文档</a> ·
|
||||
<a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
|
||||
</footer>
|
||||
|
||||
<script src="../dist/metona-toast.js"></script>
|
||||
<script>
|
||||
MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true });
|
||||
|
||||
// === Loading ===
|
||||
function demoLoading(mode) {
|
||||
const l = MeToast.loading('正在处理...');
|
||||
const showId = () => document.getElementById('loading-status').textContent = 'loading id: ' + l.id;
|
||||
showId();
|
||||
setTimeout(() => {
|
||||
const map = { success:['提交成功!','success'], error:['处理失败','error'], warning:['发现潜在问题','warning'], update:null };
|
||||
if (mode === 'update') {
|
||||
l.update({ message: '进度 50%' });
|
||||
setTimeout(() => l.success('完成!'), 1000);
|
||||
return;
|
||||
}
|
||||
const [msg, type] = map[mode];
|
||||
const t = l[type](msg);
|
||||
if (t) document.getElementById('loading-status').textContent = '转换后 id: ' + t.id + '(' + (t.id === l.id ? '同一实例 ✓' : '不同实例 ✗') + ')';
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
// === Promise / confirm / prompt ===
|
||||
async function demoPromise() {
|
||||
const p = new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000));
|
||||
try{ await MeToast.promise(p,{loading:'处理中...',success:'成功!',error:'失败'}); }catch(_){}
|
||||
}
|
||||
async function demoConfirm() { const ok=await MeToast.confirm('确定删除?',{confirmText:'删除',confirmColor:'#ef4444'}); if(ok)MeToast.success('已删除'); }
|
||||
async function demoPrompt() { const v=await MeToast.prompt('请输入姓名',{placeholder:'请输入...'}); if(v) MeToast.info('你好,'+v+'!'); }
|
||||
async function demoPromptPassword() { const v=await MeToast.prompt('请输入密码',{placeholder:'密码',inputType:'password',submitText:'登录'}); if(v) MeToast.info('密码已输入'); }
|
||||
|
||||
// === Progress & Countdown ===
|
||||
function demoProgress(dir) {
|
||||
const p=MeToast.progress('上传中...',{progressColor: dir==='vertical'?'#10b981':'#3b82f6', progressDirection: dir});
|
||||
let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('上传完成!');}else p.setProgress(v);},400);
|
||||
}
|
||||
function demoCountdown() { MeToast.countdown('{seconds} 秒后执行',5,{onComplete:()=>MeToast.success('已执行')}); }
|
||||
function demoCountdownPause() {
|
||||
const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')});
|
||||
setTimeout(()=>{c.pause();MeToast.warning('已暂停 3 秒后恢复',{duration:3000})},4000);
|
||||
setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000);
|
||||
}
|
||||
|
||||
// === Queue & Stack ===
|
||||
async function demoQueue() { await MeToast.queue(['第一步','第二步','第三步'],{delay:800,duration:2000,type:'info'}); }
|
||||
async function demoQueueCancel() {
|
||||
const q=MeToast.queue(['步骤1','步骤2','步骤3','步骤4','步骤5'],{delay:600,duration:1500,type:'info'});
|
||||
setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500);
|
||||
}
|
||||
function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); }
|
||||
function demoStackMixed() { MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150}); }
|
||||
|
||||
// === Action ===
|
||||
function demoActionDelete() { MeToast.action('文件 data.json 已删除',[{text:'撤销',onClick:()=>MeToast.success('已撤销'),color:'#3b82f6'}]); }
|
||||
function demoActionMulti() { MeToast.action('邮件已发送',[{text:'查看',onClick:()=>MeToast.info('打开邮件详情'),color:'#3b82f6'},{text:'撤回',onClick:()=>MeToast.warning('已撤回'),color:'#f59e0b'}]); }
|
||||
function demoActionNoClose() {
|
||||
let n=0;
|
||||
MeToast.action('点「+1」不会关闭,可连续点击',[{text:'+1',onClick:(t)=>{n++;t.update({message:'已点击 '+n+' 次(close:false 不关闭)'});},color:'#10b981',close:false}],{duration:8000});
|
||||
}
|
||||
|
||||
// === Group ===
|
||||
const group = MeToast.group('demo-group');
|
||||
function demoGroup() { group.success('分组成功 #'+group.count()); group.info('分组消息 #'+group.count()); }
|
||||
function dismissGroup() { MeToast.dismissGroup('demo-group'); MeToast.info('分组已关闭'); }
|
||||
function demoGroupMixed() {
|
||||
const g = MeToast.group('mixed-test');
|
||||
g.success('OK'); g.warning('Warning'); g.info('Info');
|
||||
setTimeout(()=>MeToast.dismissGroup('mixed-test'),4000);
|
||||
}
|
||||
|
||||
// === Animation ===
|
||||
function anim(name) { MeToast.success('动画: '+name,{animation:name,duration:3000,showProgress:false}); }
|
||||
function demoCustomAnim() {
|
||||
MeToast.animations.register('swing', {
|
||||
enter: { transform: 'rotate(-40deg) scale(0.3)', opacity: 0 },
|
||||
leave: { transform: 'rotate(20deg) scale(0.5)', opacity: 0 },
|
||||
duration: 600,
|
||||
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
});
|
||||
MeToast.success('自定义 swing 动画!', { animation: 'swing', duration: 3000 });
|
||||
}
|
||||
|
||||
// === Theme ===
|
||||
function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); }
|
||||
function demoRegisterTheme() {
|
||||
try {
|
||||
MeToast.themes.registerTheme('sunset',{bg:'rgba(255,248,240,0.96)',text:'#92400e',border:'rgba(249,115,22,0.2)',shadow:'0 10px 36px -10px rgba(249,115,22,0.2)',hoverShadow:'0 14px 48px -10px rgba(249,115,22,0.3)',progressBg:'rgba(249,115,22,0.1)',closeHoverBg:'rgba(249,115,22,0.1)'});
|
||||
MeToast.themes.switchTheme('sunset'); MeToast.configure({theme:'sunset'});
|
||||
MeToast.success('已切换到 sunset 主题',{theme:'sunset'});
|
||||
} catch(_) { MeToast.warning('主题已注册,已切换'); }
|
||||
}
|
||||
|
||||
// === Plugins ===
|
||||
function demoCustomPlugin() {
|
||||
MeToast.use({name:'click-counter',version:'1.0',install(){let c=0;Toast.on('afterShow',t=>{c++;setTimeout(()=>t.update({message:t.message.replace(/\(\d+\)/,'')+' (总通知#'+c+')'}),1);});}});
|
||||
MeToast.info('自定义插件已安装(消息后追加计数)');
|
||||
}
|
||||
function demoDedupe() {
|
||||
if (!MeToast.plugins.has('dedupe')) MeToast.use('dedupe');
|
||||
MeToast.info('同一条消息');
|
||||
MeToast.info('同一条消息');
|
||||
MeToast.info('同一条消息');
|
||||
MeToast.info('当前仅 ' + MeToast.count() + ' 条 — 相同消息自动合并');
|
||||
}
|
||||
|
||||
// === Hooks ===
|
||||
let hookOff = null;
|
||||
function demoHookBlock() {
|
||||
if (hookOff) hookOff();
|
||||
hookOff = Toast.on('beforeShow', (toast) => {
|
||||
if (toast.type === 'error') { document.getElementById('hook-status').textContent = '钩子:error 已被拦截 ✋'; return false; }
|
||||
});
|
||||
document.getElementById('hook-status').textContent = '钩子:已注册(error 类型将被拦截)';
|
||||
MeToast.info('拦截器已开启 — 试试触发 error');
|
||||
}
|
||||
function demoHookUnblock() {
|
||||
if (hookOff) { hookOff(); hookOff = null; }
|
||||
document.getElementById('hook-status').textContent = '钩子:已移除';
|
||||
MeToast.info('拦截器已关闭');
|
||||
}
|
||||
function demoHookCount() {
|
||||
const stats = [];
|
||||
['afterShow','afterClose','configChange','themeChange'].forEach(name => {
|
||||
const list = Toast._hooks.get(name);
|
||||
stats.push(name + ':' + (list ? list.length : 0));
|
||||
});
|
||||
MeToast.info('钩子数量 — ' + stats.join(' '), { duration: 5000 });
|
||||
}
|
||||
|
||||
// === 高级特性 ===
|
||||
function demoResetTimer() {
|
||||
const t=MeToast.info('这条不会自动消失(每次更新重置 3 秒)',{duration:3000,resetTimerOnUpdate:true,closeButton:true});
|
||||
let n=0; const iv=setInterval(()=>{n++;if(n>8){clearInterval(iv);t.close();return;}t.update({message:'重置计时器 #'+n});},1000);
|
||||
}
|
||||
function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); }
|
||||
function demoCustomRender() {
|
||||
MeToast.show({render:toast=>'<div style="background:#111;color:#0f0;padding:16px 20px;border-radius:10px;border:1px solid #0f0;font-family:monospace">🟢 <b>'+toast.message+'</b><br><span style="font-size:11px;opacity:0.6"># custom render</span></div>',message:'自定义渲染内容',duration:5000});
|
||||
}
|
||||
function demoNotify() {
|
||||
if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;}
|
||||
if(Notification.permission==='denied'){MeToast.error('通知权限已拒绝');return;}
|
||||
MeToast.info('页面隐藏时看系统通知',{notifyWhenHidden:true,duration:6000});
|
||||
}
|
||||
|
||||
// === Management ===
|
||||
function demoFindById() {
|
||||
const t=MeToast.info('这是测试 toast',{duration:5000});
|
||||
setTimeout(()=>{const found=MeToast.find(t.id); if(found)MeToast.success('找到了 #'+found.id);},500);
|
||||
}
|
||||
function demoGetAll() { const all=MeToast.getAll(); MeToast.info('当前共 '+all.size+' 条 toast'); }
|
||||
function demoClearByPos() { MeToast.error('右下角临时消息',{position:'bottom-right',duration:3000}); setTimeout(()=>{MeToast.clear('bottom-right');MeToast.info('右下角已清除');},500); }
|
||||
function demoGetConfig() { const c=MeToast.getConfig(); MeToast.info('配置: '+JSON.stringify({position:c.position,duration:c.duration,theme:c.theme,animation:c.animation,locale:c.locale}).replace(/"/g,''),{duration:5000}); }
|
||||
function demoRemove() { const t=MeToast.warning('这条将被立即移除(无动画)'); setTimeout(()=>{MeToast.removeToast(t.id);MeToast.info('已立即移除');},800); }
|
||||
|
||||
// === onError ===
|
||||
function demoOnError() {
|
||||
MeToast.configure({ onError: function(info) { MeToast.error('⚠️ 捕获: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' }); } });
|
||||
MeToast.info('已启用 onError 监控', { duration: 3000 });
|
||||
var badFn = function() { throw new Error('demo-error'); };
|
||||
Toast.on('afterShow', badFn);
|
||||
MeToast.success('这条会触发错误回调', { duration: 2000 });
|
||||
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
|
||||
}
|
||||
function demoOnErrorReset() { MeToast.configure({ onError: null }); MeToast.info('onError 已重置'); }
|
||||
|
||||
// === Interaction ===
|
||||
function demoDraggable() { MeToast.info('← 拖动我超过 120px 关闭',{draggable:true,duration:8000,closeButton:true}); }
|
||||
function demoDragThreshold() { MeToast.info('← 拖动阈值 300px(要拖更远)',{draggable:true,dragThreshold:300,duration:8000,closeButton:true}); }
|
||||
function demoPauseHover() { MeToast.info('悬停时倒计时会暂停',{pauseOnHover:true,duration:8000}); }
|
||||
function demoCloseOnClick() { MeToast.warning('点击我即可关闭',{closeOnClick:true,duration:10000}); }
|
||||
function demoWidth() { MeToast.info('宽 550px',{width:550}); setTimeout(()=>MeToast.info('宽 280px(窄)',{width:280}),500); }
|
||||
function demoClassName() { MeToast.success('带自定义 className',{className:'demo-custom-class'}); }
|
||||
function demoCustomIcon() {
|
||||
MeToast.info('通用通知', { iconHTML: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>' });
|
||||
}
|
||||
|
||||
// === i18n ===
|
||||
function switchLocale(loc) {
|
||||
MeToast.i18n.switchLocale(loc); MeToast.configure({locale:loc});
|
||||
MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English');
|
||||
}
|
||||
function demoFormat() {
|
||||
MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000});
|
||||
}
|
||||
|
||||
// === 图标网格 ===
|
||||
(function renderIcons() {
|
||||
var grid = document.getElementById('icon-grid');
|
||||
if (!grid) return;
|
||||
var icons = ['success','error','warning','info','loading','close','check','x','alert','question','star','heart','bell','mail','settings','user','home','search','plus','minus','edit','trash','download','upload','share','link','external','clock','calendar','map','compass','globe','wifi','cloud','sun','moon','zap','activity','cpu','database','server','terminal','code','git','package','layers','grid','list','filter','sort','refresh','sync','power','battery','bluetooth','volume','mic','camera','image','video','music','file','folder','clipboard','save','print','eye','eyeOff','lock','unlock','shield','key','flag','bookmark','tag','gift','award','target','crosshair','move','maximize','minimize','copy','cut','paste','rotateCw','rotateCcw','zoomIn','zoomOut','crop','sliders','toggleLeft','toggleRight','checkCircle','xCircle','alertCircle','infoCircle','helpCircle','alertTriangle','checkSquare','square','circle','triangle','hexagon','octagon','pentagon','diamond'];
|
||||
icons.forEach(function(ic) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn btn-outline btn-tiny';
|
||||
btn.textContent = ic;
|
||||
btn.title = 'type=' + ic;
|
||||
btn.onclick = function() { MeToast.success(ic, { type: ic, duration: 2000, showProgress: false }); };
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<title>API 文档 — MetonaToast</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070b14; --surface: rgba(255,255,255,0.03); --border: rgba(255,255,255,0.08);
|
||||
--text: #e8ecf4; --muted: #8b94a7; --accent: #a5b4fc; --accent2: #c7d2fe;
|
||||
--grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
||||
--radius: 12px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html { scroll-behavior:smooth; }
|
||||
body {
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
|
||||
background:var(--bg); color:var(--text); line-height:1.75; display:flex; min-height:100vh;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 40% at 80% -10%, rgba(99,102,241,0.12), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 0% 20%, rgba(236,72,153,0.05), transparent);
|
||||
}
|
||||
code {
|
||||
background:rgba(99,102,241,0.15); color:#a5b4fc; padding:1px 6px;
|
||||
border-radius:5px; font-size:.88em; white-space:nowrap;
|
||||
}
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
a:hover { color:var(--accent2); }
|
||||
|
||||
/* ===== 侧边栏 ===== */
|
||||
.sidebar {
|
||||
width:264px; min-width:264px; background:rgba(10,15,26,.8);
|
||||
border-right:1px solid var(--border); padding:22px 0;
|
||||
position:sticky; top:0; height:100vh; overflow-y:auto;
|
||||
backdrop-filter:blur(14px);
|
||||
}
|
||||
.sidebar::-webkit-scrollbar { width:5px; }
|
||||
.sidebar::-webkit-scrollbar-thumb { background:rgba(255,255,255,.12); border-radius:3px; }
|
||||
.sidebar-logo { font-size:16px; font-weight:800; padding:0 24px; margin-bottom:4px; }
|
||||
.grad-text { background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent; }
|
||||
.sidebar .back { display:block; font-size:12.5px; padding:6px 24px; color:var(--muted); }
|
||||
.sidebar .back:hover { color:var(--accent2); }
|
||||
.sidebar a.snav {
|
||||
display:block; padding:6.5px 24px; font-size:13px; color:var(--muted);
|
||||
transition:.15s; border-left:2px solid transparent;
|
||||
}
|
||||
.sidebar a.snav:hover { color:var(--accent2); }
|
||||
.sidebar a.snav.active { color:var(--accent2); border-left-color:#a855f7; background:rgba(139,92,246,.08); }
|
||||
.sidebar .section {
|
||||
font-size:11px; text-transform:uppercase; letter-spacing:1.2px;
|
||||
padding:18px 24px 6px; color:var(--muted); font-weight:700; opacity:.75;
|
||||
}
|
||||
|
||||
/* ===== 主内容 ===== */
|
||||
.main { flex:1; padding:44px 52px; max-width:980px; min-width:0; }
|
||||
.main h1 { font-size:32px; font-weight:800; margin-bottom:6px; letter-spacing:-.5px; }
|
||||
.main .h1-desc { color:var(--muted); font-size:14px; margin-bottom:8px; }
|
||||
.main h2 {
|
||||
font-size:22px; font-weight:700; margin:52px 0 12px; padding-top:22px;
|
||||
border-top:1px solid var(--border); scroll-margin-top:24px;
|
||||
}
|
||||
.main h2::before { content:'#'; margin-right:8px; background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
.main h3 { font-size:16.5px; font-weight:700; margin:26px 0 10px; color:var(--accent2); }
|
||||
.main p { margin:8px 0; color:var(--muted); font-size:14px; }
|
||||
.main p code { font-size:.9em; }
|
||||
.main table { width:100%; border-collapse:collapse; margin:14px 0 22px; font-size:13px; }
|
||||
.main th, .main td { text-align:left; padding:9px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||
.main th { color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.8px; }
|
||||
.main td:first-child { color:var(--accent); font-family:monospace; font-size:12px; white-space:nowrap; }
|
||||
.main td:nth-child(3) { font-family:monospace; font-size:11.5px; color:var(--muted); }
|
||||
.badge {
|
||||
display:inline-block; padding:1px 8px; border-radius:4px; font-size:10px;
|
||||
font-weight:700; margin-left:6px; vertical-align:middle;
|
||||
}
|
||||
.badge-new { background:rgba(16,185,129,.18); color:#34d399; border:1px solid rgba(16,185,129,.3); }
|
||||
.badge-v5 { background:rgba(168,85,247,.2); color:#d8b4fe; border:1px solid rgba(168,85,247,.4); }
|
||||
.hint { font-size:12.5px; color:var(--muted); margin-top:2px; }
|
||||
.return-box {
|
||||
background:rgba(16,185,129,.06); border:1px solid rgba(16,185,129,.2);
|
||||
border-radius:10px; padding:14px 18px; margin:14px 0;
|
||||
}
|
||||
.return-box h4 { font-size:11.5px; color:#34d399; margin-bottom:8px; text-transform:uppercase; letter-spacing:.8px; }
|
||||
|
||||
.terminal {
|
||||
background:#0a0f1c; border:1px solid var(--border); border-radius:10px;
|
||||
overflow:hidden; margin:12px 0 20px;
|
||||
}
|
||||
.terminal-bar { display:flex; align-items:center; gap:7px; padding:9px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid var(--border); }
|
||||
.terminal-bar .t-dot { width:10px; height:10px; border-radius:50%; }
|
||||
.terminal-bar .t-dot:nth-child(1){ background:#ff5f57; } .terminal-bar .t-dot:nth-child(2){ background:#febc2e; } .terminal-bar .t-dot:nth-child(3){ background:#28c840; }
|
||||
.terminal-bar .t-title { margin-left:10px; font-size:11px; color:var(--muted); }
|
||||
.terminal pre { padding:16px 18px; overflow-x:auto; font:12.5px/1.75 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace; color:#cbd5e1; }
|
||||
.terminal pre .kw { color:#c084fc; } .terminal pre .fn { color:#60a5fa; } .terminal pre .s { color:#34d399; } .terminal pre .cm { color:#475569; }
|
||||
|
||||
@media (max-width:860px) {
|
||||
body { flex-direction:column; }
|
||||
.sidebar { width:100%; min-width:0; height:auto; position:static; border-right:none; border-bottom:1px solid var(--border); padding:12px 16px; }
|
||||
.sidebar .section, .sidebar .back { padding:4px 8px; }
|
||||
.sidebar a.snav { display:inline-block; padding:4px 10px; border-left:none; font-size:12.5px; }
|
||||
.sidebar a.snav.active { border-left:none; border-radius:6px; }
|
||||
.main { padding:26px 20px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo"><span class="grad-text">MetonaToast 文档</span></div>
|
||||
<a class="back" href="index.html">← 返回首页</a>
|
||||
<a class="back" href="demo.html">🎮 在线演示</a>
|
||||
<div class="section">基础通知</div>
|
||||
<a class="snav" href="#show">show()</a>
|
||||
<a class="snav" href="#success">success()</a>
|
||||
<a class="snav" href="#error">error()</a>
|
||||
<a class="snav" href="#warning">warning()</a>
|
||||
<a class="snav" href="#info">info()</a>
|
||||
<a class="snav" href="#loading">loading()</a>
|
||||
<div class="section">高级功能</div>
|
||||
<a class="snav" href="#promise">promise()</a>
|
||||
<a class="snav" href="#confirm">confirm()</a>
|
||||
<a class="snav" href="#prompt">prompt()</a>
|
||||
<a class="snav" href="#progress">progress()</a>
|
||||
<a class="snav" href="#countdown">countdown()</a>
|
||||
<a class="snav" href="#queue">queue()</a>
|
||||
<a class="snav" href="#stack">stack()</a>
|
||||
<a class="snav" href="#action">action()</a>
|
||||
<div class="section">管理方法</div>
|
||||
<a class="snav" href="#group">group()</a>
|
||||
<a class="snav" href="#dismiss">dismiss()</a>
|
||||
<a class="snav" href="#clear">clear()</a>
|
||||
<a class="snav" href="#updatePosition">updatePosition()</a>
|
||||
<a class="snav" href="#remove">remove() / removeToast()</a>
|
||||
<a class="snav" href="#configure">configure()</a>
|
||||
<a class="snav" href="#use">use()</a>
|
||||
<a class="snav" href="#destroy">destroy()</a>
|
||||
<div class="section">扩展</div>
|
||||
<a class="snav" href="#hooks">钩子系统</a>
|
||||
<a class="snav" href="#react">React 适配器</a>
|
||||
<a class="snav" href="#config">全部配置项</a>
|
||||
<a class="snav" href="#callbacks">回调函数</a>
|
||||
<a class="snav" href="#animations">动画列表</a>
|
||||
<a class="snav" href="#themes">主题参考</a>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<h1>API 文档</h1>
|
||||
<p class="h1-desc">MetonaToast v0.5.0 完整 API 参考。所有基础通知方法支持字符串与对象两种调用形式,均可传入任何<a href="#config">配置项</a>作为可选参数。</p>
|
||||
|
||||
<!-- ===== 基础通知 ===== -->
|
||||
<h2 id="show">show(message, opts?)</h2>
|
||||
<p>显示默认类型 Toast,无特定颜色与图标。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">show.js</span></div>
|
||||
<pre><span class="cm">// 字符串形式</span>
|
||||
MeToast.<span class="fn">show</span>(<span class="s">'默认消息'</span>);
|
||||
MeToast.<span class="fn">show</span>(<span class="s">'自定义'</span>, { <span class="fn">duration</span>: <span class="num">2000</span>, <span class="fn">position</span>: <span class="s">'bottom-center'</span> });
|
||||
<span class="cm">// 对象形式</span>
|
||||
MeToast.<span class="fn">show</span>({ <span class="fn">title</span>: <span class="s">'标题'</span>, <span class="fn">message</span>: <span class="s">'内容'</span>, <span class="fn">duration</span>: <span class="num">3000</span> });</pre></div>
|
||||
|
||||
<h2 id="success">success(message, opts?)</h2>
|
||||
<p>绿色对勾图标 <code>#10b981</code>,type = success。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">success.js</span></div>
|
||||
<pre>MeToast.<span class="fn">success</span>(<span class="s">'保存成功!'</span>);
|
||||
MeToast.<span class="fn">success</span>({ <span class="fn">title</span>: <span class="s">'已保存'</span>, <span class="fn">message</span>: <span class="s">'数据已同步'</span> });
|
||||
Met.<span class="fn">success</span>(<span class="s">'Met 与 MeToast 等价'</span>);</pre></div>
|
||||
|
||||
<h2 id="error">error(message, opts?)</h2>
|
||||
<p>红色叉号图标 <code>#ef4444</code>,type = error。aria-live = assertive。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">error.js</span></div>
|
||||
<pre>MeToast.<span class="fn">error</span>(<span class="s">'网络错误,请重试'</span>);
|
||||
MeToast.<span class="fn">error</span>({ <span class="fn">title</span>: <span class="s">'提交失败'</span>, <span class="fn">message</span>: <span class="s">'服务器不可达'</span> });</pre></div>
|
||||
|
||||
<h2 id="warning">warning(message, opts?)</h2>
|
||||
<p>黄色三角图标 <code>#f59e0b</code>,type = warning。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">warning.js</span></div>
|
||||
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre></div>
|
||||
|
||||
<h2 id="info">info(message, opts?)</h2>
|
||||
<p>蓝色圆形图标 <code>#3b82f6</code>,type = info。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">info.js</span></div>
|
||||
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre></div>
|
||||
|
||||
<h2 id="loading">loading(message, opts?)</h2>
|
||||
<p>type = loading,duration 强制 0(不自动关闭)。返回 LoadingControl。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">loading.js</span></div>
|
||||
<pre><span class="kw">const</span> loading = MeToast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
|
||||
<span class="fn">setTimeout</span>(() => loading.<span class="fn">success</span>(<span class="s">'提交成功!'</span>), <span class="num">2000</span>);</pre></div>
|
||||
<div class="return-box">
|
||||
<h4>LoadingControl(链式转换 id 稳定)</h4>
|
||||
<table>
|
||||
<tr><th>方法</th><th>说明</th></tr>
|
||||
<tr><td>success/error/info/warning</td><td>原地更新为对应类型(同一实例,id 不变),duration 自动恢复默认值</td></tr>
|
||||
<tr><td>update(partial)</td><td>原地更新内容,不改变类型</td></tr>
|
||||
<tr><td>dismiss()</td><td>直接关闭,不转换</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ===== 高级功能 ===== -->
|
||||
<h2 id="promise">promise(promise, opts)</h2>
|
||||
<p>监听 Promise 生命周期,自动 loading → resolve/reject 切换 success/error,返回原 Promise。</p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>类型</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>promise</td><td>Promise</td><td>—</td><td>要监听的 Promise。非 Promise 打印错误并返回 rejected Promise</td></tr>
|
||||
<tr><td>opts.loading / success / error</td><td>string</td><td>加载中... / 操作成功 / 操作失败</td><td>各阶段文本</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">promise.js</span></div>
|
||||
<pre><span class="kw">try</span> {
|
||||
<span class="kw">await</span> MeToast.<span class="fn">promise</span>(<span class="fn">fetch</span>(<span class="s">'/api/data'</span>), {
|
||||
<span class="fn">loading</span>: <span class="s">'加载中...'</span>, <span class="fn">success</span>: <span class="s">'完成!'</span>, <span class="fn">error</span>: <span class="s">'失败'</span>,
|
||||
});
|
||||
} <span class="kw">catch</span> (e) { <span class="cm">/* reject 会继续抛出 */</span> }</pre></div>
|
||||
|
||||
<h2 id="confirm">confirm(message, opts?)</h2>
|
||||
<p>确认对话框,返回 <code>Promise<boolean></code>,内置 10 秒安全超时自动 resolve(false)。</p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>confirmText / confirmColor</td><td>确认 / #10b981</td><td>确认按钮文字与颜色</td></tr>
|
||||
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮文字与颜色</td></tr>
|
||||
<tr><td>type</td><td>warning</td><td>Toast 类型</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">confirm.js</span></div>
|
||||
<pre><span class="kw">const</span> ok = <span class="kw">await</span> MeToast.<span class="fn">confirm</span>(<span class="s">'确定删除?'</span>, { <span class="fn">confirmText</span>: <span class="s">'删除'</span>, <span class="fn">confirmColor</span>: <span class="s">'#ef4444'</span> });
|
||||
<span class="kw">if</span> (ok) MeToast.<span class="fn">success</span>(<span class="s">'已删除'</span>);</pre></div>
|
||||
|
||||
<h2 id="prompt">prompt(message, opts?)</h2>
|
||||
<p>输入对话框,返回 <code>Promise<string|null></code>。Enter / 提交返回输入值,取消返回 null,10 秒超时。</p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>placeholder / defaultValue</td><td>""</td><td>输入框占位与默认值</td></tr>
|
||||
<tr><td>inputType</td><td>text</td><td>input 的 type(password/email/number 等)</td></tr>
|
||||
<tr><td>submitText / submitColor</td><td>确认 / #3b82f6</td><td>提交按钮</td></tr>
|
||||
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">prompt.js</span></div>
|
||||
<pre><span class="kw">const</span> name = <span class="kw">await</span> MeToast.<span class="fn">prompt</span>(<span class="s">'请输入姓名'</span>, { <span class="fn">placeholder</span>: <span class="s">'请输入...'</span> });
|
||||
<span class="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre></div>
|
||||
|
||||
<h2 id="progress">progress(message, opts?)</h2>
|
||||
<p>进度条通知,不自动关闭。返回 ProgressControl。</p>
|
||||
<table>
|
||||
<tr><th>方法</th><th>说明</th></tr>
|
||||
<tr><td>setProgress(percent)</td><td>设置进度 0~100,自动 clamp</td></tr>
|
||||
<tr><td>complete(message?)</td><td>跳 100%,300ms 后转 success,1s 后自动关闭</td></tr>
|
||||
<tr><td>error(message?)</td><td>转 error,2s 后自动关闭</td></tr>
|
||||
<tr><td>dismiss()</td><td>直接关闭</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">progress.js</span></div>
|
||||
<pre><span class="kw">const</span> p = MeToast.<span class="fn">progress</span>(<span class="s">'上传中...'</span>, { <span class="fn">progressColor</span>: <span class="s">'#10b981'</span> });
|
||||
p.<span class="fn">setProgress</span>(<span class="num">45</span>);
|
||||
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>);</pre></div>
|
||||
|
||||
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
|
||||
<p>倒计时 Toast,<code>{seconds}</code> 占位符每秒自动替换。</p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>seconds</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
|
||||
<tr><td>opts.onComplete</td><td>—</td><td>归零回调</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">countdown.js</span></div>
|
||||
<pre>MeToast.<span class="fn">countdown</span>(<span class="s">'{seconds} 秒后执行'</span>, <span class="num">5</span>, { <span class="fn">onComplete</span>: () => MeToast.<span class="fn">success</span>(<span class="s">'已执行'</span>) });
|
||||
<span class="cm">// 返回 { cancel(), pause(), resume() }</span></pre></div>
|
||||
|
||||
<h2 id="queue">queue(messages, opts?)</h2>
|
||||
<p>顺序逐个显示消息队列,返回 thenable + cancel。支持 await。</p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>opts.delay</td><td>1000</td><td>每条关闭后到下一条的间隔(ms)</td></tr>
|
||||
<tr><td>opts.duration</td><td>3000</td><td>每条显示时长,可被消息级 duration 覆盖</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">queue.js</span></div>
|
||||
<pre><span class="kw">const</span> q = MeToast.<span class="fn">queue</span>([<span class="s">'步骤一'</span>, <span class="s">'步骤二'</span>, <span class="s">'步骤三'</span>], { <span class="fn">delay</span>: <span class="num">800</span>, <span class="fn">duration</span>: <span class="num">2000</span> });
|
||||
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
|
||||
<span class="kw">await</span> q; <span class="cm">// 等待完成</span></pre></div>
|
||||
|
||||
<h2 id="stack">stack(messages, opts?)</h2>
|
||||
<p>同时错峰显示多条消息。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">stack.js</span></div>
|
||||
<pre>MeToast.<span class="fn">stack</span>([<span class="s">'消息1'</span>, <span class="s">'消息2'</span>, { <span class="fn">message</span>: <span class="s">'警告'</span>, <span class="fn">type</span>: <span class="s">'warning'</span> }], { <span class="fn">stagger</span>: <span class="num">150</span> });</pre></div>
|
||||
|
||||
<h2 id="action">action(message, actions, opts?)</h2>
|
||||
<p>内嵌操作按钮。默认 duration=0 不自动关闭。点击按钮不会误触 closeOnClick(已隔离冒泡)。</p>
|
||||
<table>
|
||||
<tr><th>ActionButton</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td>text</td><td>—(必填)</td><td>按钮文字</td></tr>
|
||||
<tr><td>onClick</td><td>—(必填)</td><td>点击回调,参数为当前 toast</td></tr>
|
||||
<tr><td>color / style</td><td>#6366f1</td><td>背景色 / 内联样式(style.background 优先)</td></tr>
|
||||
<tr><td>close</td><td>true</td><td>点击后是否关闭。false 可多次点击</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">action.js</span></div>
|
||||
<pre>MeToast.<span class="fn">action</span>(<span class="s">'文件已删除'</span>, [
|
||||
{ <span class="fn">text</span>: <span class="s">'撤销'</span>, <span class="fn">onClick</span>: () => <span class="fn">restore</span>(), <span class="fn">color</span>: <span class="s">'#3b82f6'</span> },
|
||||
{ <span class="fn">text</span>: <span class="s">'查看'</span>, <span class="fn">onClick</span>: () => <span class="fn">open</span>(), <span class="fn">color</span>: <span class="s">'#10b981'</span>, <span class="fn">close</span>: <span class="kw">false</span> },
|
||||
]);</pre></div>
|
||||
|
||||
<!-- ===== 管理方法 ===== -->
|
||||
<h2 id="group">group(name)</h2>
|
||||
<p>创建分组,返回 GroupAPI。所有方法自动注入 <code>group: name</code>。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">group.js</span></div>
|
||||
<pre><span class="kw">const</span> orders = MeToast.<span class="fn">group</span>(<span class="s">'orders'</span>);
|
||||
orders.<span class="fn">success</span>(<span class="s">'订单已创建'</span>);
|
||||
orders.<span class="fn">count</span>(); <span class="cm">// 该组数量</span>
|
||||
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭整组</span>
|
||||
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre></div>
|
||||
|
||||
<h2 id="dismiss">dismiss(id?)</h2>
|
||||
<p>关闭 Toast。无参数关闭全部,传入 id 关闭指定。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">dismiss.js</span></div>
|
||||
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 全部</span>
|
||||
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 指定</span></pre></div>
|
||||
|
||||
<h2 id="clear">clear(position?)</h2>
|
||||
<p>按位置清除。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">clear.js</span></div>
|
||||
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
|
||||
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre></div>
|
||||
|
||||
<h2 id="updatePosition">updatePosition(position) <span class="badge badge-v5">v0.3</span></h2>
|
||||
<p>运行时将 Toast 移动到新位置容器,立即生效。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">position.js</span></div>
|
||||
<pre><span class="kw">const</span> t = MeToast.<span class="fn">info</span>(<span class="s">'可移动的 Toast'</span>);
|
||||
t.<span class="fn">updatePosition</span>(<span class="s">'bottom-left'</span>);</pre></div>
|
||||
|
||||
<h2 id="remove">remove() / removeToast(id) <span class="badge badge-new">立即移除</span></h2>
|
||||
<p>立即从 DOM 和内存移除,<b>不触发离场动画</b>。适用于无动画快速清除。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">remove.js</span></div>
|
||||
<pre>t.<span class="fn">remove</span>(); <span class="cm">// 实例方法</span>
|
||||
MeToast.<span class="fn">removeToast</span>(t.id); <span class="cm">// 按 id(等价)</span></pre></div>
|
||||
|
||||
<h2 id="configure">configure(opts)</h2>
|
||||
<p>全局配置,影响后续所有 Toast。theme/locale 变化触发副作用。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">configure.js</span></div>
|
||||
<pre>MeToast.<span class="fn">configure</span>({
|
||||
<span class="fn">position</span>: <span class="s">'top-right'</span>, <span class="fn">duration</span>: <span class="num">4000</span>, <span class="fn">theme</span>: <span class="s">'dark'</span>,
|
||||
<span class="fn">animation</span>: <span class="s">'slide'</span>, <span class="fn">locale</span>: <span class="s">'zh-CN'</span>,
|
||||
});</pre></div>
|
||||
|
||||
<h2 id="use">use(plugin)</h2>
|
||||
<p>安装插件:字符串预设名或插件对象。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">use.js</span></div>
|
||||
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有</span>
|
||||
MeToast.<span class="fn">use</span>(<span class="s">'persistence'</span>); <span class="cm">// 配置持久化 localStorage</span>
|
||||
MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <span class="cm">// 屏幕阅读器朗读</span>
|
||||
MeToast.<span class="fn">use</span>(<span class="s">'dedupe'</span>); <span class="cm">// 相同 type+message 自动去重</span></pre></div>
|
||||
<p class="hint">dedupe:检测已有相同 toast,更新它并阻止重复弹出(beforeShow 钩子实现,支持 uninstall 卸载钩子)。</p>
|
||||
|
||||
<h2 id="destroy">destroy()</h2>
|
||||
<p>完全销毁:关闭所有 Toast、移除全部 DOM 容器与注入样式、清空内存缓存与钩子。支持重复调用,<code>init()</code> 可恢复。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">destroy.js</span></div>
|
||||
<pre>MeToast.<span class="fn">destroy</span>();
|
||||
MeToast.<span class="fn">init</span>({ <span class="fn">config</span>: { <span class="fn">duration</span>: <span class="num">3000</span> } }); <span class="cm">// 恢复</span></pre></div>
|
||||
|
||||
<!-- ===== 钩子系统 ===== -->
|
||||
<h2 id="hooks">钩子系统 <span class="badge badge-v5">v0.3</span></h2>
|
||||
<p><code>Toast.on(name, handler)</code> 注册钩子并返回取消函数。<code>beforeShow</code> / <code>beforeClose</code> / <code>beforeUpdate</code> 的 handler 返回 <code>false</code> 可拦截对应操作。</p>
|
||||
<table>
|
||||
<tr><th>钩子</th><th>触发时机</th><th>拦截</th></tr>
|
||||
<tr><td>beforeInit / afterInit</td><td>init() 前后</td><td>—</td></tr>
|
||||
<tr><td>beforeDestroy / afterDestroy</td><td>destroy() 前后</td><td>—</td></tr>
|
||||
<tr><td>beforeShow / afterShow</td><td>显示前后</td><td>✅ 返回 false 阻止</td></tr>
|
||||
<tr><td>beforeClose / afterClose</td><td>关闭前后</td><td>✅ 返回 false 阻止</td></tr>
|
||||
<tr><td>beforeUpdate / afterUpdate</td><td>update() 前后</td><td>✅ 返回 false 阻止</td></tr>
|
||||
<tr><td>configChange</td><td>configure / updateConfig / resetConfig</td><td>—</td></tr>
|
||||
<tr><td>themeChange / localeChange</td><td>主题 / 语言切换</td><td>—</td></tr>
|
||||
<tr><td>click / hover</td><td>点击 / 悬停进出</td><td>—</td></tr>
|
||||
<tr><td>dragStart / dragEnd</td><td>拖拽开始 / 结束</td><td>—</td></tr>
|
||||
<tr><td>animationStart / animationEnd</td><td>入场动画开始 / 结束</td><td>—</td></tr>
|
||||
<tr><td>progressStart / progressEnd</td><td>倒计时开始 / 归零</td><td>—</td></tr>
|
||||
</table>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">hooks.js</span></div>
|
||||
<pre><span class="kw">import</span> MeToast, { <span class="fn">Toast</span> } <span class="kw">from</span> <span class="s">'@metona-team/metona-toast'</span>;
|
||||
|
||||
<span class="cm">// 拦截:非允许时段禁止错误提示</span>
|
||||
<span class="kw">const</span> off = <span class="fn">Toast</span>.<span class="fn">on</span>(<span class="s">'beforeShow'</span>, (toast) => {
|
||||
<span class="kw">if</span> (toast.type === <span class="s">'error'</span> && !isAllowed) <span class="kw">return</span> <span class="kw">false</span>;
|
||||
});
|
||||
<span class="cm">// ...</span>
|
||||
off(); <span class="cm">// 取消注册</span></pre></div>
|
||||
|
||||
<!-- ===== React ===== -->
|
||||
<h2 id="react">React 适配器 <span class="badge badge-v5">v0.4</span></h2>
|
||||
<p>主包保持零依赖。通过子路径 <code>@metona-team/metona-toast/react</code> 导入,<code>react</code> 为 optional peerDependency。</p>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">react.tsx</span></div>
|
||||
<pre><span class="kw">import</span> { <span class="fn">useToast</span>, <span class="fn">Toast</span> } <span class="kw">from</span> <span class="s">'@metona-team/metona-toast/react'</span>;
|
||||
|
||||
<span class="kw">function</span> <span class="fn">SubmitButton</span>() {
|
||||
<span class="kw">const</span> toast = <span class="fn">useToast</span>(); <span class="cm">// 组件卸载自动清理本组件创建的 Toast</span>
|
||||
<span class="kw">const</span> submit = <span class="kw">async</span> () => {
|
||||
<span class="kw">const</span> loading = toast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
|
||||
<span class="kw">try</span> { <span class="kw">await</span> api(); loading.<span class="fn">success</span>(<span class="s">'完成!'</span>); }
|
||||
<span class="kw">catch</span> (e) { loading.<span class="fn">error</span>(<span class="s">'失败'</span>); }
|
||||
};
|
||||
<span class="kw">return</span> <button onClick={submit}>提交</button>;
|
||||
}
|
||||
|
||||
<span class="cm">// 声明式 Toast:props 变化更新,卸载自动移除(autoClose 默认 true)</span>
|
||||
<span class="kw">function</span> <span class="fn">SaveIndicator</span>({ saving }) {
|
||||
<span class="kw">return</span> saving ? <<span class="fn">Toast</span> type=<span class="s">"info"</span> message=<span class="s">"正在保存..."</span> /> : null;
|
||||
}</pre></div>
|
||||
|
||||
<!-- ===== 配置参考 ===== -->
|
||||
<h2 id="config">全部配置项</h2>
|
||||
<p>可用于 <code>configure()</code>、<code>init()</code> 或单个 Toast 方法的 opts。</p>
|
||||
<table>
|
||||
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||
<tr><td>position</td><td>string</td><td>'top-right'</td><td>6 个位置之一(RTL 自动翻转)</td></tr>
|
||||
<tr><td>duration</td><td>number</td><td>4000</td><td>显示时长(ms),0 = 不自动关闭</td></tr>
|
||||
<tr><td>max</td><td>number</td><td>6</td><td>同位置最多条数,超出真正关闭最早的</td></tr>
|
||||
<tr><td>gap</td><td>number</td><td>12</td><td>Toast 间距(px)</td></tr>
|
||||
<tr><td>offset</td><td>number</td><td>24</td><td>容器距屏幕边缘(px)</td></tr>
|
||||
<tr><td>pauseOnHover</td><td>boolean</td><td>true</td><td>悬停暂停倒计时</td></tr>
|
||||
<tr><td>closeOnClick</td><td>boolean</td><td>true</td><td>点击关闭</td></tr>
|
||||
<tr><td>draggable</td><td>boolean</td><td>true</td><td>允许拖拽关闭</td></tr>
|
||||
<tr><td>dragThreshold</td><td>number</td><td>120</td><td>拖拽关闭阈值(px)</td></tr>
|
||||
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示倒计时进度条</td></tr>
|
||||
<tr><td>progressDirection</td><td>string</td><td>'horizontal'</td><td>horizontal / vertical</td></tr>
|
||||
<tr><td>icon</td><td>boolean</td><td>true</td><td>显示类型图标</td></tr>
|
||||
<tr><td>closeButton</td><td>boolean</td><td>true</td><td>显示关闭 × 按钮</td></tr>
|
||||
<tr><td>theme</td><td>string</td><td>'auto'</td><td>light / dark / auto / warm / 自定义名</td></tr>
|
||||
<tr><td>animation</td><td>string</td><td>'slide'</td><td>11 种内置或自定义动画名</td></tr>
|
||||
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 z-index</td></tr>
|
||||
<tr><td>width</td><td>number|string</td><td>360</td><td>宽度,数字表示 px</td></tr>
|
||||
<tr><td>className</td><td>string</td><td>''</td><td>附加 CSS 类名</td></tr>
|
||||
<tr><td>style</td><td>object</td><td>{}</td><td>附加内联样式</td></tr>
|
||||
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码</td></tr>
|
||||
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>update() 时重置倒计时</td></tr>
|
||||
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时发系统通知</td></tr>
|
||||
<tr><td>render</td><td>function</td><td>—</td><td>自定义渲染函数,完全接管 DOM</td></tr>
|
||||
<tr><td>onBeforeShow</td><td>function</td><td>—</td><td>返回 false 阻止显示</td></tr>
|
||||
<tr><td>onError</td><td>function</td><td>—</td><td>钩子/定时器异常全局回调</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="callbacks">回调函数</h2>
|
||||
<table>
|
||||
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
|
||||
<tr><td>onBeforeShow</td><td>(toast) => boolean | void</td><td>DOM 创建前,返回 false 阻止显示</td></tr>
|
||||
<tr><td>onShow</td><td>(toast) => void</td><td>创建并播放入场动画后</td></tr>
|
||||
<tr><td>onClose</td><td>(toast) => void</td><td>DOM 移除后(离场完成)</td></tr>
|
||||
<tr><td>onClick</td><td>(toast) => void</td><td>点击时(closeOnClick=true 时还会关闭)</td></tr>
|
||||
<tr><td>onUpdate</td><td>(toast) => void</td><td>update() 后</td></tr>
|
||||
<tr><td>onError</td><td>({ hook, source, error, toast }) => void</td><td>钩子/定时器异常时</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="animations">动画列表</h2>
|
||||
<p>未注册的动画名自动 fallback 到 slide。</p>
|
||||
<table>
|
||||
<tr><th>名称</th><th>效果</th><th>时长</th></tr>
|
||||
<tr><td>slide</td><td>从右侧滑入 + 回弹</td><td>400ms</td></tr>
|
||||
<tr><td>fade</td><td>纯淡入 + blur→清晰</td><td>500ms</td></tr>
|
||||
<tr><td>scale</td><td>弹性放大 (0.55→1.07→1)</td><td>450ms</td></tr>
|
||||
<tr><td>bounce</td><td>从天而降四段弹跳</td><td>650ms</td></tr>
|
||||
<tr><td>flip</td><td>3D 翻转入场 + 回摆</td><td>500ms</td></tr>
|
||||
<tr><td>rotate</td><td>旋转摇摆进入</td><td>500ms</td></tr>
|
||||
<tr><td>zoom</td><td>从中心爆发式弹出</td><td>500ms</td></tr>
|
||||
<tr><td>slideUp / slideDown</td><td>从下方 / 上方弹入</td><td>400ms</td></tr>
|
||||
<tr><td>slideLeft / slideRight</td><td>从左侧 / 右侧滑入</td><td>400ms</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="themes">主题参考</h2>
|
||||
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">theme.js</span></div>
|
||||
<pre><span class="cm">// 内置主题:light · dark · auto(跟随系统)· warm</span>
|
||||
MeToast.themes.<span class="fn">registerTheme</span>(<span class="s">'ocean'</span>, {
|
||||
<span class="fn">bg</span>: <span class="s">'rgba(240,249,255,0.96)'</span>,
|
||||
<span class="fn">text</span>: <span class="s">'#0c4a6e'</span>,
|
||||
<span class="fn">border</span>: <span class="s">'rgba(14,165,233,0.2)'</span>,
|
||||
<span class="fn">shadow</span>: <span class="s">'0 10px 36px -10px rgba(14,165,233,0.18)'</span>,
|
||||
<span class="fn">hoverShadow</span>: <span class="s">'0 14px 48px -10px rgba(14,165,233,0.22)'</span>,
|
||||
<span class="fn">progressBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
|
||||
<span class="fn">closeHoverBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
|
||||
});
|
||||
MeToast.themes.<span class="fn">switchTheme</span>(<span class="s">'ocean'</span>);</pre></div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="../dist/metona-toast.js"></script>
|
||||
<script>
|
||||
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
|
||||
|
||||
// 侧边栏滚动高亮
|
||||
const links = document.querySelectorAll('.sidebar a.snav');
|
||||
const sections = [];
|
||||
links.forEach(l => {
|
||||
const id = l.getAttribute('href').slice(1);
|
||||
const el = document.getElementById(id);
|
||||
if (el) sections.push({ id, el, link: l });
|
||||
});
|
||||
const spy = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
links.forEach(l => l.classList.remove('active'));
|
||||
const hit = sections.find(s => s.el === e.target);
|
||||
if (hit) hit.link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-15% 0px -70% 0px' });
|
||||
sections.forEach(s => spy.observe(s.el));
|
||||
|
||||
// 点击关闭高亮
|
||||
links.forEach(l => l.addEventListener('click', () => {
|
||||
links.forEach(x => x.classList.remove('active'));
|
||||
l.classList.add('active');
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,973 +0,0 @@
|
||||
{
|
||||
"planVersion": "1.0.0",
|
||||
"generatedDate": "2026-06-03",
|
||||
"project": "MarkLite",
|
||||
"basedOnRequirements": "docs/optimization-requirements.json",
|
||||
|
||||
"optimization_phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "基础设施与代码规范",
|
||||
"priority": "P0-CRITICAL",
|
||||
"estimatedEffort": "1-2 天",
|
||||
"description": "建立代码质量基线:ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复。这些是后续所有优化的前提。",
|
||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
||||
"rationale": "CQ-01 (ESLint) 是所有代码质量改进的基础;AR-05 (ErrorBoundary) 保护后续重构不会导致白屏;DX-07 (路径别名) 修复后简化后续 import。",
|
||||
"exitCriteria": [
|
||||
"npm run lint 通过且返回 0",
|
||||
"npm run format 能格式化全部源文件",
|
||||
"ErrorBoundary 包裹 App 组件",
|
||||
"TypeScript 路径别名在 vite config 中完整映射"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"name": "关键性能修复",
|
||||
"priority": "P0-CRITICAL",
|
||||
"estimatedEffort": "1-2 天",
|
||||
"description": "修复影响日常使用的核心性能瓶颈:Markdown 处理器缓存、Zustand 选择器重渲染、CodeMirror Compartment 主题切换、Preview 防抖、IndexedDB 去抖。",
|
||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
||||
"rationale": "PF-01 和 PF-02 是 P0-CRITICAL 级性能问题,直接影响用户感知;PF-04 的 Compartment 重构是 PF-03 (React.memo) 的前置;PF-07/08 是低风险高收益的防抖优化。",
|
||||
"exitCriteria": [
|
||||
"Markdown processor 实例复用率 > 90%",
|
||||
"编辑时仅 activeTab 相关组件重渲染(React DevTools Profiler 验证)",
|
||||
"darkMode 切换不销毁 EditorView",
|
||||
"Preview 渲染频率 < 10 次/秒(快速输入场景)",
|
||||
"IndexedDB 写入频率 < 3 次/秒(连续编辑场景)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"name": "架构重构与代码质量",
|
||||
"priority": "P1-HIGH",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "组件拆分、TypeScript 类型完善、错误处理统一、消除架构反模式(DOM CustomEvent、重复设置加载)。",
|
||||
"items": ["AR-01", "AR-02", "AR-03", "AR-04", "CQ-04", "CQ-05", "PF-03"],
|
||||
"rationale": "组件拆分 (AR-01/02) 和 React.memo (PF-03) 依赖 Phase 1 的 ESLint 和 Phase 2 的 Zustand 优化;类型完善 (CQ-04) 和错误处理 (CQ-05) 可与架构重构并行。",
|
||||
"exitCriteria": [
|
||||
"App.tsx < 150 行",
|
||||
"Sidebar.tsx < 120 行",
|
||||
"所有函数参数有显式类型标注",
|
||||
"无空 catch 块,所有错误有具体描述",
|
||||
"设置加载只触发一次 IndexedDB 读取"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"name": "用户体验改进",
|
||||
"priority": "P1-HIGH",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "替换原生 confirm()、添加加载状态指示、改进 Toast 通知系统、增强可访问性。",
|
||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
||||
"rationale": "UX 改进依赖 Phase 3 的组件拆分(新的 ConfirmDialog、Toast 组件需要独立目录)和 ErrorBoundary(统一错误处理模式)。",
|
||||
"exitCriteria": [
|
||||
"零处原生 confirm() 调用",
|
||||
"文件打开、目录加载、Markdown 渲染有 loading 状态",
|
||||
"Toast 支持多条堆叠 + 类型区分 (success/error/warning)",
|
||||
"所有交互元素有 ARIA 属性"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"name": "开发体验与测试",
|
||||
"priority": "P2-MEDIUM",
|
||||
"estimatedEffort": "2-3 天",
|
||||
"description": "建立单元测试框架、强化类型安全、添加项目文档和 Git hooks。",
|
||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
||||
"rationale": "测试框架应在代码稳定后添加(Phase 1-4 的重构会导致测试频繁失效);DX-02 (preload 类型安全) 依赖 CQ-04 (类型完善)。",
|
||||
"exitCriteria": [
|
||||
"Vitest + React Testing Library 可运行",
|
||||
"核心模块测试覆盖 > 60%",
|
||||
"README.md 包含完整开发指南",
|
||||
"pre-commit 钩子执行 ESLint + typecheck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 6,
|
||||
"name": "高级功能与安全加固",
|
||||
"priority": "P2-P3",
|
||||
"estimatedEffort": "3-5 天",
|
||||
"description": "代码分割、分屏模式、拖拽排序、文件搜索、安全加固 (CSP/file:// 协议)、CI/CD 配置。",
|
||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
||||
"rationale": "这些是「锦上添花」的优化,不阻塞核心功能。代码分割 (PF-06) 和分屏模式 (UX-03) 是大规模功能,需要在架构稳定后实施。",
|
||||
"exitCriteria": [
|
||||
"renderer bundle 拆分为 3+ 个 chunk",
|
||||
"分屏模式可正常编辑+预览",
|
||||
"CSP header 配置且预览正常渲染",
|
||||
"GitHub Actions CI 流水线 lint → typecheck → test → build"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"file_manifest": {
|
||||
"new_files": [
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"phase": 1,
|
||||
"item": "CQ-01",
|
||||
"description": "ESLint v9 flat config 格式配置文件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": ".prettierrc",
|
||||
"phase": 1,
|
||||
"item": "CQ-02",
|
||||
"description": "Prettier 代码格式化配置",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": ".prettierignore",
|
||||
"phase": 1,
|
||||
"item": "CQ-02",
|
||||
"description": "Prettier 忽略文件列表",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": ".editorconfig",
|
||||
"phase": 1,
|
||||
"item": "CQ-03",
|
||||
"description": "编辑器统一行为配置",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"description": "React ErrorBoundary 组件,显示错误信息和重启按钮",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ErrorBoundary/index.ts",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"description": "ErrorBoundary barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "从 App.tsx 提取的 About 对话框组件",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/AboutDialog/index.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "AboutDialog barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useFileOperations.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-01",
|
||||
"description": "从 App.tsx 提取的文件操作 hook(open/save/saveAs/openRecent)",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Sidebar/FileTree.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-02",
|
||||
"description": "从 Sidebar.tsx 提取的递归文件树组件",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSidebarResize.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-02",
|
||||
"description": "从 Sidebar.tsx 提取的 resize 逻辑 hook",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ConfirmDialog/ConfirmDialog.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "自定义确认对话框组件,替代原生 confirm()",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/ConfirmDialog/index.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "ConfirmDialog barrel export",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useConfirm.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"description": "确认对话框状态管理 hook",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/LoadingSpinner/LoadingSpinner.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-02",
|
||||
"description": "通用加载指示器组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/toolbar-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "工具栏相关 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/sidebar-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "侧边栏相关 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/app-icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "应用级 SVG 图标组件",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/icons/index.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"description": "Icons barrel export(保持向后兼容)",
|
||||
"size_estimate": "trivial"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown-processor.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-01",
|
||||
"description": "Markdown processor 缓存管理器",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSettingsInit.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"description": "统一设置加载 hook,一次 IndexedDB 读取分发到 store",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "Vitest 测试框架配置",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/stores/__tests__/tabStore.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "tabStore 单元测试",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/__tests__/fileUtils.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "fileUtils 单元测试",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/__tests__/markdown.test.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-01",
|
||||
"description": "Markdown 渲染单元测试",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"phase": 5,
|
||||
"item": "DX-04",
|
||||
"description": "项目介绍、安装、开发、构建说明",
|
||||
"size_estimate": "medium"
|
||||
},
|
||||
{
|
||||
"path": "CONTRIBUTING.md",
|
||||
"phase": 5,
|
||||
"item": "DX-04",
|
||||
"description": "贡献指南、代码规范、PR 流程",
|
||||
"size_estimate": "small"
|
||||
},
|
||||
{
|
||||
"path": ".github/workflows/ci.yml",
|
||||
"phase": 6,
|
||||
"item": "DX-06",
|
||||
"description": "GitHub Actions CI 配置",
|
||||
"size_estimate": "small"
|
||||
}
|
||||
],
|
||||
"modified_files": [
|
||||
{
|
||||
"path": "package.json",
|
||||
"phase": [1, 2, 5],
|
||||
"item": ["CQ-02", "DX-05", "DX-01"],
|
||||
"changes": [
|
||||
"添加 prettier、eslint-config-prettier 依赖",
|
||||
"添加 vitest、@testing-library/react、jsdom 依赖",
|
||||
"添加 husky、lint-staged 依赖",
|
||||
"添加 test、test:watch、test:coverage、format 脚本"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "electron.vite.config.ts",
|
||||
"phase": [1, 6],
|
||||
"item": ["DX-07", "PF-06"],
|
||||
"changes": [
|
||||
"renderer.resolve.alias 添加 @main 映射",
|
||||
"renderer.build.rollupOptions.output 添加 manualChunks 分割策略"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/main.tsx",
|
||||
"phase": 1,
|
||||
"item": "AR-05",
|
||||
"changes": [
|
||||
"用 ErrorBoundary 包裹 App 组件"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/App.tsx",
|
||||
"phase": [1, 3],
|
||||
"item": ["AR-01", "AR-05", "CQ-05"],
|
||||
"changes": [
|
||||
"提取 About 对话框到 AboutDialog 组件",
|
||||
"提取文件操作逻辑到 useFileOperations hook",
|
||||
"统一错误处理模式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-01",
|
||||
"changes": [
|
||||
"引入 markdown-processor.ts 缓存管理器",
|
||||
"renderMarkdown 改为使用缓存的 processor 实例",
|
||||
"添加 processor 缓存失效策略(仅 filePath 变化时重建)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/stores/tabStore.ts",
|
||||
"phase": [2, 3],
|
||||
"item": ["PF-02", "PF-08", "AR-07"],
|
||||
"changes": [
|
||||
"添加 getActiveTab() 选择器方法(返回 activeTab 对象引用)",
|
||||
"添加 getActiveTabId() 选择器方法",
|
||||
"将 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)",
|
||||
"统一异步保存模式为 Zustand subscribeWithSelector middleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Editor/Editor.tsx",
|
||||
"phase": 2,
|
||||
"item": "PF-02",
|
||||
"changes": [
|
||||
"替换 const tabs = useTabStore(s => s.tabs) 为 const activeTab = useTabStore(s => s.getActiveTab())",
|
||||
"消除 tabs.find() 调用"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Preview/Preview.tsx",
|
||||
"phase": [2],
|
||||
"item": ["PF-02", "PF-07"],
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器",
|
||||
"添加 150ms 防抖(useDebounce hook)",
|
||||
"使用新的 markdown processor 缓存"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"phase": 2,
|
||||
"item": "PF-02",
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Sidebar/Sidebar.tsx",
|
||||
"phase": [2, 3],
|
||||
"item": ["PF-02", "AR-02"],
|
||||
"changes": [
|
||||
"替换 tabs 订阅为 activeTab 选择器",
|
||||
"提取 FileTree 组件到独立文件",
|
||||
"提取 resize 逻辑到 useSidebarResize hook"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"phase": 2,
|
||||
"item": "PF-04",
|
||||
"changes": [
|
||||
"引入 Compartment API(@codemirror/state)",
|
||||
"theme 扩展放入 Compartment",
|
||||
"darkMode 变化时通过 compartment.reconfigure() 切换主题",
|
||||
"移除 EditorView 销毁重建逻辑"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Toolbar/Toolbar.tsx",
|
||||
"phase": 3,
|
||||
"item": "PF-03",
|
||||
"changes": [
|
||||
"用 React.memo 包装组件导出"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/TabBar/TabBar.tsx",
|
||||
"phase": [3, 4],
|
||||
"item": ["PF-03", "UX-01"],
|
||||
"changes": [
|
||||
"用 React.memo 包装组件导出",
|
||||
"替换 4 处 confirm() 为自定义 ConfirmDialog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useUnsavedWarning.ts",
|
||||
"phase": 4,
|
||||
"item": "UX-01",
|
||||
"changes": [
|
||||
"替换 confirm() 为 electron.dialog.showMessageBox 或自定义 ConfirmDialog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useTheme.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"changes": [
|
||||
"移除独立的 settingsRepository.load() 调用",
|
||||
"改为从统一的 useSettingsInit hook 获取设置"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useSettings.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-03",
|
||||
"changes": [
|
||||
"移除独立的 settingsRepository.load() 调用",
|
||||
"改为从统一的 useSettingsInit hook 获取设置"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/hooks/useFileWatch.ts",
|
||||
"phase": 3,
|
||||
"item": "AR-04",
|
||||
"changes": [
|
||||
"移除 window.dispatchEvent(CustomEvent) 调用",
|
||||
"改为调用 Zustand store action 更新文件状态"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Icons.tsx",
|
||||
"phase": 3,
|
||||
"item": "AR-06",
|
||||
"changes": [
|
||||
"拆分为 icons/toolbar-icons.tsx、icons/sidebar-icons.tsx、icons/app-icons.tsx",
|
||||
"原文件改为 re-export barrel 文件(向后兼容)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/components/Toast/Toast.tsx",
|
||||
"phase": 4,
|
||||
"item": "UX-05",
|
||||
"changes": [
|
||||
"支持多条堆叠 Toast",
|
||||
"添加 success/error/warning 类型",
|
||||
"添加手动关闭按钮"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/styles/global.css",
|
||||
"phase": 4,
|
||||
"item": ["UX-01", "UX-02", "UX-05"],
|
||||
"changes": [
|
||||
"添加 ConfirmDialog 样式",
|
||||
"添加 LoadingSpinner 样式",
|
||||
"添加 Toast 堆叠和类型样式"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/preload/index.ts",
|
||||
"phase": 5,
|
||||
"item": "DX-02",
|
||||
"changes": [
|
||||
"添加 ElectronAPI 类型约束: const api: ElectronAPI = { ... }",
|
||||
"确保 contextBridge.exposeInMainWorld 返回值类型匹配"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/types/settings.ts",
|
||||
"phase": 6,
|
||||
"item": "UX-03",
|
||||
"changes": [
|
||||
"ViewMode 类型扩展为 'editor' | 'preview' | 'split'"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/index.html",
|
||||
"phase": 6,
|
||||
"item": "SE-01",
|
||||
"changes": [
|
||||
"添加 CSP meta tag 或在 main process BrowserWindow 中配置 CSP header"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/window-manager.ts",
|
||||
"phase": 6,
|
||||
"item": "SE-01",
|
||||
"changes": [
|
||||
"在 BrowserWindow webPreferences 中配置 CSP header",
|
||||
"确认 nodeIntegration=false, contextIsolation=true, sandbox=true"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/lib/markdown.ts",
|
||||
"phase": 6,
|
||||
"item": "SE-02",
|
||||
"changes": [
|
||||
"rehypeFixImages 中的路径校验从简单 '..' 检查改为 path.resolve + allowlist 检查"
|
||||
]
|
||||
}
|
||||
],
|
||||
"deleted_files": []
|
||||
},
|
||||
|
||||
"technical_solutions": {
|
||||
"CQ-01_eslint_config": {
|
||||
"id": "CQ-01",
|
||||
"title": "ESLint v9 Flat Config",
|
||||
"solution": "创建 eslint.config.js,使用 ESM flat config 格式。",
|
||||
"implementation": {
|
||||
"file": "eslint.config.js",
|
||||
"format": "ESM (export default [...])",
|
||||
"config_content": {
|
||||
"imports": [
|
||||
"eslint-plugin-react-hooks",
|
||||
"eslint-plugin-react-refresh",
|
||||
"@typescript-eslint/eslint-plugin"
|
||||
],
|
||||
"ignores": ["dist/**", "node_modules/**", "out/**"],
|
||||
"languageOptions": {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": { "jsx": true }
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-console": "warn",
|
||||
"prefer-const": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
},
|
||||
"notes": "ESLint v9 不再支持 --ext 参数,flat config 中通过 files glob 控制。需更新 package.json 的 lint 脚本。"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"PF-01_markdown_cache": {
|
||||
"id": "PF-01",
|
||||
"title": "Markdown Processor 缓存",
|
||||
"solution": "将 unified processor 实例提取到独立模块,使用 Map 按 filePath 缓存。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/lib/markdown-processor.ts",
|
||||
"approach": "Module-level singleton cache",
|
||||
"cache_strategy": {
|
||||
"key": "filePath (string)",
|
||||
"value": "Processor instance (unified processor with all 7 plugins)",
|
||||
"invalidation": "filePath 变化时创建新 processor,旧实例保留在 cache 中供回退",
|
||||
"max_cache_size": 10,
|
||||
"eviction": "LRU - 超过 max_cache_size 时移除最久未使用的"
|
||||
},
|
||||
"code_sketch": "const processorCache = new Map<string, Processor>();\nexport function getProcessor(filePath: string): Processor {\n if (processorCache.has(filePath)) return processorCache.get(filePath)!;\n const p = unified().use(remarkParse)...;\n processorCache.set(filePath, p);\n return p;\n}"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW - unified processor 是无状态的,缓存安全"
|
||||
},
|
||||
|
||||
"PF-02_zustand_selectors": {
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器优化",
|
||||
"solution": "在 tabStore 中添加 getActiveTab 派生选择器,各组件只订阅 activeTab 引用。",
|
||||
"implementation": {
|
||||
"approach": "在 store 中添加 getter 方法 + useShallow",
|
||||
"store_changes": {
|
||||
"new_methods": [
|
||||
"getActiveTab(): Tab | undefined — 从 state.tabs 和 state.activeTabId 派生",
|
||||
"getActiveTabId(): string | null — 直接返回 state.activeTabId"
|
||||
],
|
||||
"notes": "Zustand 5 中 useShallow 来自 'zustand/react/shallow'"
|
||||
},
|
||||
"component_changes": {
|
||||
"before": "const tabs = useTabStore(s => s.tabs);\nconst activeTab = tabs.find(t => t.id === activeTabId);",
|
||||
"after": "const activeTab = useTabStore(s => s.tabs.find(t => t.id === s.activeTabId));",
|
||||
"note": "直接在选择器中 find,当 activeTab 的引用未变时不会触发重渲染。或使用 useShallow + getActiveTab 方法。"
|
||||
},
|
||||
"affected_files": [
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
||||
]
|
||||
},
|
||||
"dependencies": ["PF-01"],
|
||||
"risk": "MEDIUM - 需验证所有订阅点,遗漏会导致功能异常"
|
||||
},
|
||||
|
||||
"PF-04_codemirror_compartment": {
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror Compartment 主题切换",
|
||||
"solution": "使用 @codemirror/state 的 Compartment API 封装主题扩展,darkMode 变化时 reconfigure 而非销毁重建。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"approach": "Compartment + reconfigure",
|
||||
"code_sketch": "import { Compartment } from '@codemirror/state';\nconst themeCompartment = new Compartment();\n\n// 创建时:\nconst extensions = [\n // ... other extensions\n themeCompartment.of(darkMode ? oneDark : [])\n];\n\n// darkMode 变化时:\nuseEffect(() => {\n if (editorView) {\n editorView.dispatch({\n effects: themeCompartment.reconfigure(darkMode ? oneDark : [])\n });\n }\n}, [darkMode]);",
|
||||
"key_changes": [
|
||||
"移除 useEffect 中 EditorView 销毁重建逻辑",
|
||||
"theme 扩展改为 Compartment.of() 包装",
|
||||
"darkMode 变化改为 dispatch reconfigure effect",
|
||||
"保留其他扩展不变(keymap、language 等)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "HIGH - 影响编辑器核心初始化流程,需仔细测试切换前后编辑器状态保留"
|
||||
},
|
||||
|
||||
"PF-07_preview_debounce": {
|
||||
"id": "PF-07",
|
||||
"title": "Preview 渲染防抖",
|
||||
"solution": "创建 useDebounce hook,在 Preview.tsx 中对 content 变化进行 150ms 防抖。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/hooks/useDebounce.ts",
|
||||
"approach": "标准 useDebounce hook + useEffect",
|
||||
"code_sketch": "function useDebounce<T>(value: T, delay: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedValue(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debouncedValue;\n}",
|
||||
"usage_in_preview": "const content = activeTab?.content ?? '';\nconst debouncedContent = useDebounce(content, 150);\nuseEffect(() => {\n // renderMarkdown(debouncedContent)\n}, [debouncedContent]);"
|
||||
},
|
||||
"dependencies": ["PF-01", "PF-02"],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"PF-08_indexeddb_debounce": {
|
||||
"id": "PF-08",
|
||||
"title": "IndexedDB 保存去抖",
|
||||
"solution": "将 tabStore 中的 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/stores/tabStore.ts",
|
||||
"approach": "引入 debounce 工具函数",
|
||||
"code_sketch": "// utils/debounce.ts\nexport function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {\n let timer: ReturnType<typeof setTimeout>;\n return ((...args: any[]) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n }) as T;\n}\n\n// tabStore.ts\nconst debouncedSaveToDB = debounce(() => saveToDB(), 500);\nconst debouncedSaveActiveTab = debounce((id: string) => tabRepository.saveActiveTabId(id), 300);",
|
||||
"affected_patterns": [
|
||||
"setTimeout(() => saveToDB(), 0) → debouncedSaveToDB()",
|
||||
"setTimeout(() => tabRepository.saveActiveTabId(activeTabId), 0) → debouncedSaveActiveTab(activeTabId)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW - 去抖延迟可配置,用户关闭窗口前需 flush"
|
||||
},
|
||||
|
||||
"AR-05_error_boundary": {
|
||||
"id": "AR-05",
|
||||
"title": "React ErrorBoundary",
|
||||
"solution": "创建 class component ErrorBoundary,捕获渲染错误,显示友好错误页面。",
|
||||
"implementation": {
|
||||
"file": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
||||
"features": [
|
||||
"componentDidCatch + getDerivedStateFromError",
|
||||
"显示错误信息、错误栈(开发模式)",
|
||||
"「重新加载」按钮调用 location.reload() 或 window.electronAPI 重启",
|
||||
"「复制错误信息」按钮",
|
||||
"支持 fallback 自定义 UI"
|
||||
],
|
||||
"integration": "在 main.tsx 中 <ErrorBoundary><App /></ErrorBoundary>"
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "LOW"
|
||||
},
|
||||
|
||||
"AR-01_app_split": {
|
||||
"id": "AR-01",
|
||||
"title": "App.tsx 拆分",
|
||||
"solution": "提取 AboutDialog 组件和 useFileOperations hook。",
|
||||
"implementation": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "AboutDialog",
|
||||
"source": "App.tsx 内联 JSX (~40 行)",
|
||||
"target": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
||||
"props": "{ visible: boolean; onClose: () => void; version: string }"
|
||||
},
|
||||
{
|
||||
"name": "useFileOperations",
|
||||
"source": "App.tsx 中的 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 函数",
|
||||
"target": "src/renderer/hooks/useFileOperations.ts",
|
||||
"returns": "{ handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }"
|
||||
}
|
||||
],
|
||||
"expected_result": "App.tsx 从 ~307 行减少到 ~120 行"
|
||||
},
|
||||
"dependencies": ["CQ-01", "AR-05"],
|
||||
"risk": "LOW - 提取式重构,行为不变"
|
||||
},
|
||||
|
||||
"AR-03_unified_settings": {
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载",
|
||||
"solution": "创建 useSettingsInit hook,一次性从 IndexedDB 加载所有设置,分发到各 store。",
|
||||
"implementation": {
|
||||
"new_file": "src/renderer/hooks/useSettingsInit.ts",
|
||||
"approach": "单次加载 + Zustand setState 分发",
|
||||
"code_sketch": "export function useSettingsInit() {\n const [loaded, setLoaded] = useState(false);\n useEffect(() => {\n settingsRepository.load().then(settings => {\n useThemeStore.getState().applySettings(settings);\n useEditorStore.getState().applySettings(settings);\n setLoaded(true);\n });\n }, []);\n return loaded;\n}",
|
||||
"affected_files": [
|
||||
"src/renderer/hooks/useTheme.ts — 移除独立 load()",
|
||||
"src/renderer/hooks/useSettings.ts — 移除独立 load()"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "MEDIUM - 设置加载时序变化可能影响首次渲染"
|
||||
},
|
||||
|
||||
"UX-01_confirm_dialog": {
|
||||
"id": "UX-01",
|
||||
"title": "自定义确认对话框",
|
||||
"solution": "创建 ConfirmDialog 组件 + useConfirm hook,替代原生 confirm()。",
|
||||
"implementation": {
|
||||
"approach": "Promise-based confirm API",
|
||||
"code_sketch": "// useConfirm.ts\nexport function useConfirm() {\n const [state, setState] = useState<{ message: string; resolve: (v: boolean) => void } | null>(null);\n const confirm = (message: string) => new Promise<boolean>(resolve => setState({ message, resolve }));\n const handleConfirm = () => { state?.resolve(true); setState(null); };\n const handleCancel = () => { state?.resolve(false); setState(null); };\n return { confirm, dialog: state ? <ConfirmDialog .../> : null };\n}",
|
||||
"affected_locations": [
|
||||
"TabBar.tsx — 4 处 confirm() 调用",
|
||||
"useUnsavedWarning.ts — 1 处 confirm() 调用"
|
||||
]
|
||||
},
|
||||
"dependencies": ["AR-01"],
|
||||
"risk": "MEDIUM - Promise-based API 与原生 confirm() 的同步行为不同,需确保调用方正确 await"
|
||||
},
|
||||
|
||||
"UX-03_split_mode": {
|
||||
"id": "UX-03",
|
||||
"title": "分屏模式",
|
||||
"solution": "扩展 ViewMode 为 'split',布局组件根据 mode 同时渲染 Editor 和 Preview。",
|
||||
"implementation": {
|
||||
"approach": "CSS Grid/ResizeObserver 双栏布局",
|
||||
"key_changes": [
|
||||
"types/settings.ts: ViewMode = 'editor' | 'preview' | 'split'",
|
||||
"App.tsx 或新 Layout 组件: 根据 viewMode 条件渲染",
|
||||
"split 模式: 左 50% Editor + 右 50% Preview,可拖拽调整比例",
|
||||
"添加 Ctrl+3 快捷键切换到 split 模式",
|
||||
"可选:编辑器滚动同步预览滚动位置"
|
||||
]
|
||||
},
|
||||
"dependencies": ["PF-01", "PF-02", "PF-04"],
|
||||
"risk": "HIGH - 需要同时维护两个视图实例,内存和性能需关注"
|
||||
},
|
||||
|
||||
"PF-06_code_splitting": {
|
||||
"id": "PF-06",
|
||||
"title": "代码分割",
|
||||
"solution": "在 electron.vite.config.ts 中配置 Rollup manualChunks,按依赖类型分割。",
|
||||
"implementation": {
|
||||
"file": "electron.vite.config.ts",
|
||||
"approach": "manualChunks + dynamic import",
|
||||
"chunks_config": {
|
||||
"vendor-react": ["react", "react-dom"],
|
||||
"vendor-codemirror": ["@codemirror/state", "@codemirror/view", "@codemirror/commands", "@codemirror/lang-markdown", "@codemirror/language", "@codemirror/search", "@codemirror/theme-one-dark"],
|
||||
"vendor-markdown": ["unified", "remark-parse", "remark-gfm", "remark-rehype", "rehype-raw", "rehype-sanitize", "rehype-stringify", "rehype-highlight"],
|
||||
"vendor-db": ["dexie"]
|
||||
},
|
||||
"dynamic_import_targets": [
|
||||
"rehype-highlight (仅 Preview 组件需要)"
|
||||
]
|
||||
},
|
||||
"dependencies": [],
|
||||
"risk": "MEDIUM - 代码分割后需验证 Electron 打包正确性和加载时序"
|
||||
},
|
||||
|
||||
"SE-01_csp": {
|
||||
"id": "SE-01",
|
||||
"title": "CSP 配置",
|
||||
"solution": "在 main process 的 BrowserWindow 中通过 session.defaultSession.webRequest 配置 CSP header。",
|
||||
"implementation": {
|
||||
"file": "src/main/window-manager.ts",
|
||||
"approach": "session.webRequest.onHeadersReceived",
|
||||
"csp_policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data:; font-src 'self' data:;",
|
||||
"notes": "style-src 需要 'unsafe-inline' 因为 CodeMirror 通过 JS 注入内联样式。rehype-highlight 引入 highlight.js 样式也需要考虑。"
|
||||
},
|
||||
"dependencies": ["PF-04"],
|
||||
"risk": "MEDIUM - CSP 可能阻断 CodeMirror 样式注入,需测试"
|
||||
}
|
||||
},
|
||||
|
||||
"dependency_graph": {
|
||||
"description": "优化项之间的依赖关系。箭头表示 'A -> B' 意味着 B 依赖 A(A 需先完成)。",
|
||||
"edges": [
|
||||
{ "from": "CQ-01", "to": "CQ-02", "reason": "Prettier 需与 ESLint 集成 (eslint-config-prettier)" },
|
||||
{ "from": "CQ-01", "to": "AR-01", "reason": "组件拆分前需确保代码质量基线" },
|
||||
{ "from": "CQ-01", "to": "AR-02", "reason": "组件拆分前需确保代码质量基线" },
|
||||
{ "from": "CQ-01", "to": "PF-03", "reason": "React.memo 添加前需 ESLint 规则配合" },
|
||||
{ "from": "CQ-01", "to": "DX-05", "reason": "lint-staged 依赖 ESLint 配置" },
|
||||
{ "from": "CQ-01", "to": "DX-01", "reason": "测试代码也需遵循 ESLint 规则" },
|
||||
{ "from": "CQ-04", "to": "DX-02", "reason": "preload 类型安全依赖类型完善" },
|
||||
{ "from": "PF-01", "to": "PF-02", "reason": "Zustand 选择器优化涉及 Preview 组件,需配合 Markdown 缓存" },
|
||||
{ "from": "PF-01", "to": "PF-07", "reason": "Preview 防抖依赖渲染管道已优化" },
|
||||
{ "from": "PF-02", "to": "PF-07", "reason": "Preview 防抖依赖 activeTab 选择器" },
|
||||
{ "from": "PF-02", "to": "PF-03", "reason": "React.memo 效果依赖选择器优化减少不必要更新" },
|
||||
{ "from": "AR-01", "to": "UX-01", "reason": "ConfirmDialog 依赖组件拆分后的目录结构" },
|
||||
{ "from": "AR-01", "to": "UX-02", "reason": "LoadingSpinner 依赖组件拆分后的目录结构" },
|
||||
{ "from": "PF-01", "to": "UX-03", "reason": "分屏模式的预览面板依赖 Markdown 缓存" },
|
||||
{ "from": "PF-02", "to": "UX-03", "reason": "分屏模式需要高效的 store 订阅" },
|
||||
{ "from": "PF-04", "to": "UX-03", "reason": "分屏模式的编辑器依赖 Compartment 动态配置" },
|
||||
{ "from": "PF-04", "to": "SE-01", "reason": "CSP 配置需在 Compartment 稳定后进行,避免相互影响" },
|
||||
{ "from": "AR-05", "to": "AR-01", "reason": "ErrorBoundary 应在 App.tsx 拆分前完成,保护重构过程" }
|
||||
],
|
||||
"parallelizable": [
|
||||
["CQ-01", "AR-05", "DX-07"],
|
||||
["PF-01", "PF-04", "PF-08"],
|
||||
["CQ-04", "CQ-05", "AR-03", "AR-04"],
|
||||
["UX-01", "UX-02", "UX-05"],
|
||||
["DX-01", "DX-04"]
|
||||
]
|
||||
},
|
||||
|
||||
"risk_assessment": {
|
||||
"high_risk": [
|
||||
{
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror Compartment 重构",
|
||||
"risk_level": "HIGH",
|
||||
"description": "影响编辑器核心初始化流程。Compartment reconfigure 如果处理不当,可能导致编辑器状态丢失(光标位置、选区、undo 历史)。",
|
||||
"impact": "编辑器完全不可用",
|
||||
"probability": "MEDIUM",
|
||||
"mitigation": [
|
||||
"保留现有销毁重建逻辑作为 fallback(feature flag 控制)",
|
||||
"编写测试验证切换主题后光标位置和 undo 历史保留",
|
||||
"逐步迁移:先用 Compartment 切换主题,验证稳定后再扩展到其他扩展"
|
||||
],
|
||||
"rollback": "revert useCodeMirror.ts 到使用前的版本"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"title": "分屏模式",
|
||||
"risk_level": "HIGH",
|
||||
"description": "需要同时维护 Editor 和 Preview 两个实例,可能增加内存占用和 CPU 消耗。滚动同步逻辑复杂。",
|
||||
"impact": "性能下降、内存溢出(大文件场景)",
|
||||
"probability": "MEDIUM",
|
||||
"mitigation": [
|
||||
"Preview 面板使用延迟加载(只在 split 模式激活时挂载)",
|
||||
"大文件场景下限制预览刷新频率(500ms debounce)",
|
||||
"考虑虚拟化预览(只渲染可视区域)"
|
||||
],
|
||||
"rollback": "ViewMode 从 'editor' | 'preview' | 'split' 移除 'split' 值"
|
||||
}
|
||||
],
|
||||
"medium_risk": [
|
||||
{
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器重构",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "选择器重构可能遗漏某些订阅点,导致某些组件在 activeTab 变化时不再更新。",
|
||||
"impact": "编辑器/预览/状态栏内容不同步",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"使用 React DevTools Profiler 逐一验证每个受影响组件的重渲染行为",
|
||||
"逐个文件修改,每修改一个文件后手动测试",
|
||||
"编写 tabStore 选择器的单元测试"
|
||||
],
|
||||
"rollback": "逐文件恢复原始订阅方式"
|
||||
},
|
||||
{
|
||||
"id": "SE-01",
|
||||
"title": "CSP 配置",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "CSP 配置过于严格可能阻断 CodeMirror 内联样式注入或 rehype-highlight 的样式。",
|
||||
"impact": "编辑器或预览样式丢失",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"使用 'unsafe-inline' 允许 style-src(CodeMirror 依赖)",
|
||||
"分步启用 CSP 先 report-only 模式测试",
|
||||
"测试所有主题切换和 Markdown 语法高亮场景"
|
||||
],
|
||||
"rollback": "移除 CSP header 配置"
|
||||
},
|
||||
{
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "将两次 IndexedDB 读取合并为一次可能改变设置加载时序,导致首次渲染闪烁或主题闪烁。",
|
||||
"impact": "首次加载时短暂显示默认主题再切换到用户主题",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"在设置加载完成前显示 loading 状态(不渲染 App)",
|
||||
"使用 init() 方式在 main.tsx 中等待设置加载完成后再渲染"
|
||||
],
|
||||
"rollback": "恢复 useTheme 和 useSettings 各自独立加载"
|
||||
},
|
||||
{
|
||||
"id": "PF-08",
|
||||
"title": "IndexedDB 去抖",
|
||||
"risk_level": "MEDIUM",
|
||||
"description": "去抖延迟期间如果 Electron 进程被杀或用户关闭窗口,可能丢失最后一次保存。",
|
||||
"impact": "用户数据丢失(最后一次编辑)",
|
||||
"probability": "LOW",
|
||||
"mitigation": [
|
||||
"在 beforeunload / window close 事件中 flush debounce",
|
||||
"使用 navigator.sendBeacon 或 Electron app.on('before-quit') 事件触发同步保存",
|
||||
"记录 dirty flag,关闭前检查"
|
||||
],
|
||||
"rollback": "恢复 setTimeout(fn, 0) 立即保存"
|
||||
}
|
||||
],
|
||||
"low_risk": [
|
||||
{
|
||||
"id": "CQ-01",
|
||||
"title": "ESLint 配置",
|
||||
"risk_level": "LOW",
|
||||
"description": "纯增量添加,不影响运行时行为。",
|
||||
"mitigation": ["确保 .eslintignore 排除构建产物"]
|
||||
},
|
||||
{
|
||||
"id": "PF-01",
|
||||
"title": "Markdown 处理器缓存",
|
||||
"risk_level": "LOW",
|
||||
"description": "unified processor 是无状态管道,缓存复用安全。",
|
||||
"mitigation": ["设置 max cache size 防止内存泄漏"]
|
||||
},
|
||||
{
|
||||
"id": "PF-07",
|
||||
"title": "Preview 防抖",
|
||||
"risk_level": "LOW",
|
||||
"description": "防抖只是延迟渲染,不影响最终结果。",
|
||||
"mitigation": ["防抖时间可配置,默认 150ms"]
|
||||
},
|
||||
{
|
||||
"id": "AR-05",
|
||||
"title": "ErrorBoundary",
|
||||
"risk_level": "LOW",
|
||||
"description": "纯增量添加,只在出错时生效。",
|
||||
"mitigation": ["测试注入错误验证 ErrorBoundary 正常捕获"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"summary": {
|
||||
"total_optimization_items": 35,
|
||||
"total_new_files": 28,
|
||||
"total_modified_files": 22,
|
||||
"total_phases": 6,
|
||||
"estimated_total_effort": "12-18 天",
|
||||
"critical_path": "CQ-01 → AR-05 → AR-01 → UX-01 → DX-05",
|
||||
"parallel_speedup_potential": "约 30-40%(Phase 1 和 Phase 2 可部分并行)",
|
||||
"highest_impact_items": ["PF-02", "PF-01", "PF-04", "AR-05"],
|
||||
"recommended_execution_order": [
|
||||
"Phase 1 (Day 1-2): ESLint + Prettier + ErrorBoundary — 建立基线",
|
||||
"Phase 2 (Day 3-4): 性能修复 — 解决核心瓶颈",
|
||||
"Phase 3 (Day 5-7): 架构重构 — 提升可维护性",
|
||||
"Phase 4 (Day 8-10): UX 改进 — 提升用户体验",
|
||||
"Phase 5 (Day 11-13): 测试 + 文档 — 建立质量保障",
|
||||
"Phase 6 (Day 14-18): 高级功能 — 锦上添花"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
{
|
||||
"reportTitle": "MarkLite v0.1.1 代码质量优化交付报告",
|
||||
"reportDate": "2026-06-03",
|
||||
"version": "0.1.1",
|
||||
"author": "Asset Custodian (多Agent协作)",
|
||||
|
||||
"optimizationSummary": {
|
||||
"objective": "通过多Agent协作系统对 MarkLite 项目进行全方位代码质量优化和性能改进,涵盖代码质量审查、性能优化、UI/UX改进、类型完善和测试覆盖",
|
||||
"scope": "src/renderer/ 目录下全部前端代码,包括组件、状态管理、工具库、样式和测试",
|
||||
"totalPhases": 5,
|
||||
"completedOptimizations": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "基础设施建设",
|
||||
"items": [
|
||||
"ESLint flat config (typescript-eslint + react-hooks + react-refresh)",
|
||||
"lint-staged + husky Git pre-commit 钩子",
|
||||
"ErrorBoundary 全局错误边界组件"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"name": "性能优化",
|
||||
"items": [
|
||||
"Markdown 处理器 LRU 缓存(最大10条,PF-01)",
|
||||
"Zustand store 选择器优化(细粒度订阅,PF-02)",
|
||||
"CodeMirror Compartment 主题热切换(PF-04)",
|
||||
"预览区防抖机制(150ms,PF-05)"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"name": "代码架构改进",
|
||||
"items": [
|
||||
"sidebarStore 精简与 loadFromDB 优化",
|
||||
"useTheme 统一由 useSettingsInit 管理(AR-04)",
|
||||
"统一错误处理模块 errorHandler.ts(CQ-05)",
|
||||
"组件选择器优化减少不必要重渲染"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"name": "UI/UX 改进",
|
||||
"items": [
|
||||
"ConfirmDialog 通用确认对话框(支持 focus trap)",
|
||||
"LoadingSpinner 加载状态组件",
|
||||
"Toast 通知系统升级",
|
||||
"可访问性改进(ARIA属性、键盘导航)"
|
||||
],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"name": "测试与文档",
|
||||
"items": [
|
||||
"Vitest 测试框架配置(jsdom + globals)",
|
||||
"5个测试文件,78个测试用例全部通过",
|
||||
"DESIGN.md 和 README.md 文档更新",
|
||||
"husky + lint-staged Git hooks 集成"
|
||||
],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"qualityReview": {
|
||||
"score": 82,
|
||||
"maxScore": 100,
|
||||
"issuesFound": "multiple",
|
||||
"issuesResolved": "all",
|
||||
"status": "通过"
|
||||
}
|
||||
},
|
||||
|
||||
"fileChangeStatistics": {
|
||||
"scope": "v0.1.1 优化提交 (commit 3cecb0f)",
|
||||
"totalFilesChanged": 16,
|
||||
"insertions": 106,
|
||||
"deletions": 66,
|
||||
"netChange": "+40 lines",
|
||||
"modifiedFiles": [
|
||||
"DESIGN.md",
|
||||
"README.md",
|
||||
"package.json",
|
||||
"src/renderer/App.tsx",
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/TabBar/TabBar.tsx",
|
||||
"src/renderer/components/Toolbar/Toolbar.tsx",
|
||||
"src/renderer/hooks/useDragDrop.ts",
|
||||
"src/renderer/hooks/useSettings.ts",
|
||||
"src/renderer/hooks/useTheme.ts",
|
||||
"src/renderer/stores/sidebarStore.ts",
|
||||
"src/renderer/stores/tabStore.ts"
|
||||
],
|
||||
"newFiles": [],
|
||||
"projectTotals": {
|
||||
"sourceFileCount": 62,
|
||||
"totalSourceLines": 4808,
|
||||
"componentDirs": 16,
|
||||
"testFiles": 5,
|
||||
"storeModules": 3,
|
||||
"hookModules": 14,
|
||||
"typeModules": 5,
|
||||
"libModules": 4
|
||||
}
|
||||
},
|
||||
|
||||
"performanceImprovements": {
|
||||
"markdownProcessorCache": {
|
||||
"id": "PF-01",
|
||||
"mechanism": "LRU 缓存(Map-based,最大10条)",
|
||||
"implementation": "src/renderer/lib/markdown.ts",
|
||||
"description": "基于文件路径缓存已构建的 unified 处理器实例,LRU淘汰策略避免内存泄漏",
|
||||
"expectedImprovement": "重复渲染同一文件时跳过处理器构建阶段,预览刷新延迟降低"
|
||||
},
|
||||
"zustandSelectorOptimization": {
|
||||
"id": "PF-02",
|
||||
"mechanism": "细粒度选择器订阅",
|
||||
"implementation": "src/renderer/stores/sidebarStore.ts, tabStore.ts, editorStore.ts",
|
||||
"description": "sidebarStore 精简为单一状态源,移除冗余字段;组件通过原子选择器订阅最小状态集",
|
||||
"expectedImprovement": "减少无关状态变化触发的组件重渲染"
|
||||
},
|
||||
"codemirrorThemeCompartment": {
|
||||
"id": "PF-04",
|
||||
"mechanism": "Milkdown CSS class 切换(milkdown-dark)",
|
||||
"implementation": "src/renderer/components/Editor/useMilkdown.ts",
|
||||
"description": "darkMode 变化时切换 milkdown-dark CSS class,无需销毁重建 EditorView",
|
||||
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置",
|
||||
"note": "项目已从 CodeMirror 6 迁移至 Milkdown v7 WYSIWYG 编辑器,原 Compartment 方案不再适用"
|
||||
},
|
||||
"previewDebounce": {
|
||||
"id": "PF-05",
|
||||
"mechanism": "150ms 防抖(setTimeout + useRef)",
|
||||
"implementation": "src/renderer/components/Preview/Preview.tsx",
|
||||
"description": "编辑内容变化后延迟150ms再触发预览渲染,快速连续输入时合并为单次渲染",
|
||||
"expectedImprovement": "大量快速输入时预览区渲染次数显著减少,降低 CPU 使用"
|
||||
}
|
||||
},
|
||||
|
||||
"qualityMetrics": {
|
||||
"typescript": {
|
||||
"command": "npx tsc --noEmit",
|
||||
"errors": 0,
|
||||
"status": "PASS",
|
||||
"strictMode": true
|
||||
},
|
||||
"eslint": {
|
||||
"command": "npx eslint src/ --ext .ts,.tsx",
|
||||
"errors": 0,
|
||||
"warnings": 7,
|
||||
"warningDetails": [
|
||||
"react-hooks/exhaustive-deps: useEffect missing dependency 'activeTab' (1处,可接受的业务逻辑)",
|
||||
"no-console: console 语句 (6处,错误处理和调试日志中合理使用)"
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"tests": {
|
||||
"framework": "Vitest 4.1.8",
|
||||
"environment": "jsdom",
|
||||
"totalTestFiles": 5,
|
||||
"totalTests": 78,
|
||||
"passed": 78,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"duration": "55ms (tests only), 9.68s (total)",
|
||||
"testSuites": [
|
||||
{ "file": "src/renderer/lib/__tests__/errorHandler.test.ts", "tests": 12 },
|
||||
{ "file": "src/renderer/stores/__tests__/tabStore.test.ts", "tests": 17 },
|
||||
{ "file": "src/renderer/lib/__tests__/fileUtils.test.ts", "tests": 21 },
|
||||
{ "file": "src/renderer/stores/__tests__/editorStore.test.ts", "tests": 12 },
|
||||
{ "file": "src/renderer/lib/__tests__/markdown.test.ts", "tests": 16 }
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"coverage": {
|
||||
"status": "未配置 @vitest/coverage-v8 依赖",
|
||||
"note": "vitest.config.ts 已配置 v8 provider,需安装 @vitest/coverage-v8 后启用"
|
||||
},
|
||||
"qualityScore": {
|
||||
"reviewScore": 82,
|
||||
"maxScore": 100,
|
||||
"rating": "良好",
|
||||
"breakdown": {
|
||||
"codeStructure": "85/100 - 组件划分合理,目录结构清晰",
|
||||
"typeScript": "90/100 - 0编译错误,类型覆盖良好",
|
||||
"errorHandling": "80/100 - 统一错误处理模块,ErrorBoundary",
|
||||
"performance": "78/100 - LRU缓存、防抖、Compartment优化",
|
||||
"testing": "75/100 - 78测试覆盖核心逻辑,缺少集成测试",
|
||||
"documentation": "80/100 - DESIGN.md + README.md 更新完善",
|
||||
"accessibility": "70/100 - ConfirmDialog focus trap,待扩展"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"subsequentRecommendations": {
|
||||
"phase6AdvancedFeatures": [
|
||||
{
|
||||
"id": "AF-01",
|
||||
"title": "虚拟滚动",
|
||||
"description": "FileTree 和 TabBar 在大量文件/标签时引入虚拟列表(react-window 或自实现)",
|
||||
"priority": "中",
|
||||
"effort": "中"
|
||||
},
|
||||
{
|
||||
"id": "AF-02",
|
||||
"title": "Web Worker Markdown 渲染",
|
||||
"description": "将 Markdown 解析移至 Web Worker,主线程零阻塞",
|
||||
"priority": "高",
|
||||
"effort": "高"
|
||||
},
|
||||
{
|
||||
"id": "AF-03",
|
||||
"title": "增量编辑同步",
|
||||
"description": "基于 CodeMirror transactions 的增量 diff 更新预览,替代全量重渲染",
|
||||
"priority": "高",
|
||||
"effort": "高"
|
||||
},
|
||||
{
|
||||
"id": "AF-04",
|
||||
"title": "i18n 国际化",
|
||||
"description": "提取硬编码中文字符串到 i18n 资源文件",
|
||||
"priority": "低",
|
||||
"effort": "中"
|
||||
},
|
||||
{
|
||||
"id": "AF-05",
|
||||
"title": "代码覆盖率提升",
|
||||
"description": "安装 @vitest/coverage-v8,目标覆盖率 70%+,补充组件集成测试",
|
||||
"priority": "高",
|
||||
"effort": "中"
|
||||
}
|
||||
],
|
||||
"longTermMaintenance": [
|
||||
"定期更新依赖(Electron、CodeMirror、Zustand),关注 breaking changes",
|
||||
"保持 ESLint 规则集更新,跟进 typescript-eslint 新版本",
|
||||
"每季度进行一次代码质量审查(参照本次多Agent审查流程)",
|
||||
"考虑引入 CI/CD 流水线自动化测试和 lint 检查",
|
||||
"监控 Electron 安全公告,及时升级版本修复漏洞",
|
||||
"补充 E2E 测试(Playwright 或 Spectron),覆盖核心用户流程",
|
||||
"建立代码贡献规范(CONTRIBUTING.md),统一 PR 审查流程"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
{
|
||||
"project": "MarkLite",
|
||||
"version": "0.1.1",
|
||||
"analysisDate": "2026-06-03",
|
||||
"techStack": {
|
||||
"electron": "28.3.3",
|
||||
"react": "18.3.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "electron-vite 3.0.0",
|
||||
"stateManagement": "zustand 5.0.6",
|
||||
"editor": "Milkdown 7.21.1 (WYSIWYG) + textarea (source mode)",
|
||||
"database": "Dexie (IndexedDB)"
|
||||
},
|
||||
"currentStatus": {
|
||||
"typescriptCheck": "PASS",
|
||||
"eslintConfig": "MISSING",
|
||||
"testCoverage": "NONE",
|
||||
"documentation": "NONE",
|
||||
"ci": "NONE"
|
||||
},
|
||||
"codebaseStats": {
|
||||
"totalSourceFiles": 44,
|
||||
"mainProcessFiles": 5,
|
||||
"preloadFiles": 1,
|
||||
"rendererFiles": 36,
|
||||
"sharedFiles": 2,
|
||||
"components": 14,
|
||||
"stores": 3,
|
||||
"hooks": 7,
|
||||
"dbRepositories": 3,
|
||||
"typeDefinitions": 5,
|
||||
"styleFiles": 3
|
||||
},
|
||||
"optimizationRequirements": {
|
||||
"1_codeQuality": {
|
||||
"title": "代码质量与规范",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "CQ-01",
|
||||
"title": "配置 ESLint (Flat Config)",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "package.json 中已有 @typescript-eslint/eslint-plugin、eslint、eslint-plugin-react-hooks 等依赖,但缺少 eslint.config.js 配置文件。npm run lint 无法正常工作。",
|
||||
"currentState": "devDependencies 已安装但无配置文件",
|
||||
"expectedState": "创建 eslint.config.js (ESLint v9 flat config 格式),配置 TypeScript + React + React Hooks 规则",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"configFormat": "ESM flat config (eslint.config.js)",
|
||||
"plugins": [
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"eslint-plugin-react-hooks",
|
||||
"eslint-plugin-react-refresh"
|
||||
],
|
||||
"rules": [
|
||||
"typescript-eslint/recommended",
|
||||
"react-hooks/recommended",
|
||||
"no-unused-vars (error)",
|
||||
"no-console (warn)",
|
||||
"prefer-const"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CQ-02",
|
||||
"title": "添加 Prettier 代码格式化",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "项目无统一代码格式化工具,代码风格靠人工维护(缩进、引号、分号等不一致风险高)。",
|
||||
"currentState": "无 Prettier 配置",
|
||||
"expectedState": "添加 .prettierrc 配置 + 与 ESLint 集成",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"config": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CQ-03",
|
||||
"title": "添加 .editorconfig",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "统一编辑器行为配置,确保不同开发者环境一致。",
|
||||
"currentState": "无 .editorconfig",
|
||||
"expectedState": "添加 .editorconfig 文件",
|
||||
"effort": "trivial"
|
||||
},
|
||||
{
|
||||
"id": "CQ-04",
|
||||
"title": "修复 TypeScript 严格模式下的隐式 any",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "虽然 tsc --noEmit 通过,但部分回调参数缺少类型标注(如 CustomEvent detail),依赖类型推断。",
|
||||
"currentState": "编译通过但存在隐式类型",
|
||||
"expectedState": "所有函数参数显式标注类型",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "CQ-05",
|
||||
"title": "统一错误处理模式",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "多处 catch 块为空或仅 console.error,用户看不到有意义的错误信息。App.tsx 中 showToast('操作失败') 过于笼统。",
|
||||
"currentState": "分散的 try-catch,错误消息不具体",
|
||||
"expectedState": "统一的错误处理函数,包含具体错误描述和日志",
|
||||
"effort": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"2_performance": {
|
||||
"title": "性能优化",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "PF-01",
|
||||
"title": "Markdown 渲染处理器缓存",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "renderMarkdown() 每次调用都创建新的 unified() 处理器管道(含 7 个插件),大文件渲染时开销显著。unified 处理器创建后应缓存复用。",
|
||||
"currentState": "每次渲染创建新 processor (7个插件)",
|
||||
"expectedState": "缓存 processor 实例,仅在 filePath 变化时重建",
|
||||
"effort": "small",
|
||||
"impact": "HIGH - 减少每次渲染的初始化开销",
|
||||
"details": {
|
||||
"location": "src/renderer/lib/markdown.ts",
|
||||
"solution": "使用 Map<string, Processor> 按 filePath 缓存,或使用 useMemo 在组件层面缓存"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-02",
|
||||
"title": "Zustand 选择器优化 - 避免订阅整个 tabs 数组",
|
||||
"priority": "P0-CRITICAL",
|
||||
"description": "Editor.tsx、Preview.tsx、StatusBar.tsx、Sidebar.tsx 都订阅 tabs 数组然后 .find() 查找 activeTab。每次任何 tab 的 content 变化都会触发这些组件重渲染。",
|
||||
"currentState": "const tabs = useTabStore(s => s.tabs); const activeTab = tabs.find(...)",
|
||||
"expectedState": "使用 useShallow 或自定义选择器仅订阅 activeTab",
|
||||
"effort": "medium",
|
||||
"impact": "HIGH - 减少不必要的重渲染",
|
||||
"affectedFiles": [
|
||||
"src/renderer/components/Editor/Editor.tsx",
|
||||
"src/renderer/components/Preview/Preview.tsx",
|
||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
||||
],
|
||||
"solution": "在 tabStore 中添加 getActiveTab 选择器方法,或使用 zustand useShallow"
|
||||
},
|
||||
{
|
||||
"id": "PF-03",
|
||||
"title": "React.memo 包装纯展示组件",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "Toolbar、StatusBar、Icons 等纯展示组件未使用 React.memo,父组件重渲染时会连带重渲染。",
|
||||
"currentState": "无任何组件使用 React.memo",
|
||||
"expectedState": "对 Toolbar、StatusBar、TabBar、Preview 等组件使用 memo 包装",
|
||||
"effort": "small",
|
||||
"impact": "MEDIUM"
|
||||
},
|
||||
{
|
||||
"id": "PF-04",
|
||||
"title": "CodeMirror 主题切换优化",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "useCodeMirror.ts 中 darkMode 变化时销毁并重建整个 EditorView(第93行),导致编辑器完全重新初始化。",
|
||||
"currentState": "darkMode 变化 → 整个 EditorView 销毁重建",
|
||||
"expectedState": "使用 CodeMirror Compartment 动态切换主题扩展",
|
||||
"effort": "medium",
|
||||
"impact": "HIGH - 切换主题时保持编辑器状态",
|
||||
"details": {
|
||||
"location": "src/renderer/components/Editor/useCodeMirror.ts",
|
||||
"solution": "使用 @codemirror/state 的 Compartment API 动态 reconfigure 主题扩展"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-05",
|
||||
"title": "CodeMirror 内容更新优化",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "setContent() 使用 from:0 到 length 全量替换,对大文件(10万行+)性能差。应使用事务 diff 或仅在内容实际变化时替换。",
|
||||
"currentState": "全量替换: changes: { from: 0, to: current.length, insert: newContent }",
|
||||
"expectedState": "仅在内容不同时替换,大文件考虑增量更新",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "PF-06",
|
||||
"title": "Bundle 大小优化 - 代码分割",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "所有渲染进程代码打包为单一 bundle。rehype-highlight (含 highlight.js)、CodeMirror 等大型库未做懒加载。",
|
||||
"currentState": "单 bundle,无代码分割",
|
||||
"expectedState": "动态 import 预览相关库、分割 vendor chunk",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"chunks": [
|
||||
"vendor-react: react + react-dom",
|
||||
"vendor-codemirror: @codemirror/*",
|
||||
"vendor-markdown: unified + remark + rehype 生态",
|
||||
"app: 业务代码"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PF-07",
|
||||
"title": "Preview 渲染防抖",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "Preview.tsx 在 activeTab?.content 变化时立即触发异步渲染。快速输入时会频繁调用 renderMarkdown。",
|
||||
"currentState": "content 变化立即渲染",
|
||||
"expectedState": "添加 150-300ms 防抖,避免快速输入时频繁渲染",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "PF-08",
|
||||
"title": "保存到 IndexedDB 的去抖优化",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "tabStore 中多处使用 setTimeout(() => saveToDB(), 0) 异步保存,但没有去抖。频繁操作时会多次写入。",
|
||||
"currentState": "setTimeout(fn, 0) 立即触发",
|
||||
"expectedState": "使用 debounce (300-500ms) 合并多次保存",
|
||||
"effort": "small"
|
||||
}
|
||||
]
|
||||
},
|
||||
"3_architecture": {
|
||||
"title": "架构优化",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "AR-01",
|
||||
"title": "App.tsx 拆分 - About 对话框提取",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "App.tsx 有 307 行,About 对话框 JSX 内联(约 40 行),文件打开/保存逻辑也堆积在组件中。",
|
||||
"currentState": "App.tsx 包含 About 弹窗、文件操作、事件监听等所有逻辑",
|
||||
"expectedState": "提取 AboutDialog 组件、提取 useFileOperations hook",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "AboutDialog",
|
||||
"from": "App.tsx lines 261-300",
|
||||
"to": "src/renderer/components/AboutDialog/AboutDialog.tsx"
|
||||
},
|
||||
{
|
||||
"name": "useFileOperations",
|
||||
"description": "提取 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 为自定义 hook",
|
||||
"to": "src/renderer/hooks/useFileOperations.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AR-02",
|
||||
"title": "Sidebar 组件拆分",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "Sidebar.tsx 有 241 行,包含 FileTree 递归组件、resize 逻辑、独立文件列表等。应拆分为独立文件。",
|
||||
"currentState": "FileTree 组件定义在 Sidebar.tsx 内",
|
||||
"expectedState": "FileTree 提取为独立组件,resize 逻辑提取为 hook",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"extractions": [
|
||||
{
|
||||
"name": "FileTree",
|
||||
"from": "Sidebar.tsx lines 180-239",
|
||||
"to": "src/renderer/components/Sidebar/FileTree.tsx"
|
||||
},
|
||||
{
|
||||
"name": "useSidebarResize",
|
||||
"description": "提取 resize 相关逻辑为自定义 hook",
|
||||
"to": "src/renderer/hooks/useSidebarResize.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "AR-03",
|
||||
"title": "统一设置加载 - 消除 useTheme 和 useSettings 的重复加载",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "useTheme 和 useSettings 各自独立调用 settingsRepository.load(),导致启动时两次 IndexedDB 读取。",
|
||||
"currentState": "useTheme.load() + useSettings.load() = 两次 DB 读取",
|
||||
"expectedState": "统一为单个 useSettingsInit hook,一次加载分发到 store",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-04",
|
||||
"title": "消除 DOM CustomEvent 中间层",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "useFileWatch.ts 通过 window.dispatchEvent(CustomEvent) 传递文件修改事件,App.tsx 再 addEventListener 监听。应直接通过 Zustand store 或回调传递。",
|
||||
"currentState": "IPC → CustomEvent DOM → App.tsx listener",
|
||||
"expectedState": "IPC → Zustand store action → 组件响应",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-05",
|
||||
"title": "添加 React ErrorBoundary",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "无全局错误边界。任何渲染错误会导致白屏。Electron 应用中尤其需要,因为用户无法刷新页面恢复。",
|
||||
"currentState": "无 ErrorBoundary",
|
||||
"expectedState": "添加 ErrorBoundary 组件包裹 App,显示错误信息和重启按钮",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-06",
|
||||
"title": "Icons 组件按需加载",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "Icons.tsx 有 216 行,包含 16 个 SVG 图标组件。虽然是函数组件有 tree-shaking,但单文件过大影响维护。",
|
||||
"currentState": "16 个图标定义在单文件",
|
||||
"expectedState": "按功能模块拆分: toolbar-icons.tsx, sidebar-icons.tsx, app-icons.tsx",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "AR-07",
|
||||
"title": "tabStore 异步操作模式统一",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "tabStore 中大量使用 setTimeout(() => saveToDB(), 0) 和 setTimeout(() => tabRepository.saveActiveTabId(), 0)。模式不统一且容易出错。",
|
||||
"currentState": "分散的 setTimeout 调用",
|
||||
"expectedState": "使用 Zustand middleware (subscribeWithSelector) 或统一的 persist 逻辑",
|
||||
"effort": "medium"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4_userExperience": {
|
||||
"title": "用户体验",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "UX-01",
|
||||
"title": "替换原生 confirm() 为自定义确认对话框",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "TabBar.tsx 和 useUnsavedWarning.ts 使用浏览器原生 confirm() 对话框,样式丑陋且不可自定义。Electron 应用应使用自定义模态框或 electron.dialog。",
|
||||
"currentState": "5 处使用 confirm() 原生对话框",
|
||||
"expectedState": "自定义 ConfirmDialog 组件或使用 electron.dialog.showMessageBox",
|
||||
"effort": "medium",
|
||||
"affectedLocations": [
|
||||
"src/renderer/components/TabBar/TabBar.tsx (4处)",
|
||||
"src/renderer/hooks/useUnsavedWarning.ts (1处)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "UX-02",
|
||||
"title": "添加加载状态指示",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "文件打开、目录树加载、Markdown 渲染等异步操作无加载状态。用户无法感知操作是否正在进行。",
|
||||
"currentState": "无 loading 状态",
|
||||
"expectedState": "添加文件打开 spinner、目录树加载骨架屏、预览渲染指示器",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"title": "添加编辑器+预览分屏模式",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "当前只有 'editor' 和 'preview' 两种模式,缺少 VS Code 风格的左右分屏同时编辑和预览。",
|
||||
"currentState": "viewMode: 'editor' | 'preview'",
|
||||
"expectedState": "新增 'split' 模式: 左侧编辑器 + 右侧实时预览",
|
||||
"effort": "large",
|
||||
"details": {
|
||||
"implementation": [
|
||||
"Settings.ViewMode 新增 'split'",
|
||||
"Editor 和 Preview 同时渲染",
|
||||
"编辑器滚动同步预览",
|
||||
"Ctrl+3 快捷键"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "UX-04",
|
||||
"title": "TabBar 拖拽排序",
|
||||
"priority": "P3-LOW",
|
||||
"description": "标签页不支持拖拽排序,无法自定义标签顺序。",
|
||||
"currentState": "标签页固定顺序",
|
||||
"expectedState": "支持拖拽重新排列标签页",
|
||||
"effort": "large"
|
||||
},
|
||||
{
|
||||
"id": "UX-05",
|
||||
"title": "改进 Toast 通知系统",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "当前 Toast 仅支持单条消息,3秒后消失。不支持不同类型(success/error/warning)和堆叠。",
|
||||
"currentState": "单条 toast,无类型区分",
|
||||
"expectedState": "支持多条堆叠、不同类型(成功/错误/警告)、可关闭",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "UX-06",
|
||||
"title": "添加文件搜索功能 (Ctrl+P)",
|
||||
"priority": "P3-LOW",
|
||||
"description": "无快速文件搜索/打开功能。当打开文件夹后,应支持按文件名快速搜索打开。",
|
||||
"currentState": "仅支持文件夹树浏览",
|
||||
"expectedState": "Ctrl+P 快速搜索面板,模糊匹配文件名",
|
||||
"effort": "large"
|
||||
},
|
||||
{
|
||||
"id": "UX-07",
|
||||
"title": "可访问性增强",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "部分组件缺少 ARIA 属性。Sidebar resize handle 无键盘支持。文件树展开/折叠无键盘导航。",
|
||||
"currentState": "基本的 role/aria-label 已添加",
|
||||
"expectedState": "完整的键盘导航、focus 管理、屏幕阅读器支持",
|
||||
"effort": "medium",
|
||||
"details": {
|
||||
"missing": [
|
||||
"resize handle 需要 role='separator' + 键盘调整",
|
||||
"文件树需要 aria-expanded、aria-owns",
|
||||
"右键菜单需要 role='menu' + 键盘导航",
|
||||
"About 对话框需要 focus trap"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"5_developmentExperience": {
|
||||
"title": "开发体验",
|
||||
"priority": "MEDIUM",
|
||||
"items": [
|
||||
{
|
||||
"id": "DX-01",
|
||||
"title": "添加单元测试框架",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "项目零测试覆盖。核心逻辑(tabStore、fileUtils、markdown 渲染)应有单元测试。",
|
||||
"currentState": "无测试文件、无测试框架",
|
||||
"expectedState": "Vitest + React Testing Library,核心模块测试覆盖 > 60%",
|
||||
"effort": "large",
|
||||
"details": {
|
||||
"framework": "vitest + @testing-library/react + jsdom",
|
||||
"priority_test_targets": [
|
||||
"src/renderer/stores/tabStore.ts - 标签页 CRUD 逻辑",
|
||||
"src/renderer/lib/fileUtils.ts - 文件名/路径工具",
|
||||
"src/renderer/lib/markdown.ts - Markdown 渲染",
|
||||
"src/renderer/db/schema.ts - 数据库 schema",
|
||||
"src/main/file-system.ts - 文件系统操作"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-02",
|
||||
"title": "强化 preload 类型安全",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "types/ipc.ts 定义了 ElectronAPI 接口和 IpcInvokeMap,但 preload/index.ts 未使用这些类型约束。contextBridge.exposeInMainWorld 返回值无类型检查。",
|
||||
"currentState": "preload 使用字面量对象,未引用 ElectronAPI 类型",
|
||||
"expectedState": "preload 实现强制匹配 ElectronAPI 接口类型",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"solution": "const api: ElectronAPI = { ... } 在 preload 中强制类型检查"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-03",
|
||||
"title": "添加类型安全的 IPC 通信层",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "types/ipc.ts 定义了 IpcInvokeMap 但未被使用。应建立类型安全的 IPC 调用包装器。",
|
||||
"currentState": "IpcInvokeMap 已定义但未使用",
|
||||
"expectedState": "渲染进程通过类型安全的 invoke 函数调用 IPC",
|
||||
"effort": "medium"
|
||||
},
|
||||
{
|
||||
"id": "DX-04",
|
||||
"title": "添加项目文档",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "无 README.md、无 CONTRIBUTING.md、无开发环境搭建文档。",
|
||||
"currentState": "无文档",
|
||||
"expectedState": "README.md 含项目介绍、安装步骤、开发指南、构建说明",
|
||||
"effort": "small",
|
||||
"details": {
|
||||
"files": [
|
||||
"README.md - 项目介绍、截图、安装、开发、构建",
|
||||
"CONTRIBUTING.md - 贡献指南、代码规范、PR 流程",
|
||||
"CHANGELOG.md - 版本变更记录"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DX-05",
|
||||
"title": "添加 Git hooks (husky + lint-staged)",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "无 pre-commit 钩子,代码质量无法在提交时保证。",
|
||||
"currentState": "无 Git hooks",
|
||||
"expectedState": "husky + lint-staged: 提交前自动运行 ESLint + 类型检查",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "DX-06",
|
||||
"title": "添加 CI/CD 配置",
|
||||
"priority": "P3-LOW",
|
||||
"description": "无持续集成配置。应添加 GitHub Actions 或 Gitee CI 配置。",
|
||||
"currentState": "无 CI 配置",
|
||||
"expectedState": "CI 流水线: lint → typecheck → test → build",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "DX-07",
|
||||
"title": "添加路径别名到 Vite 配置",
|
||||
"priority": "P3-LOW",
|
||||
"description": "tsconfig.json 定义了 @renderer/*、@main/*、@shared/* 路径别名,但 electron.vite.config.ts 中 renderer 的 alias 只有 @renderer 和 @shared,缺少 @main。",
|
||||
"currentState": "renderer alias 缺少 @main 映射",
|
||||
"expectedState": "补全所有路径别名映射",
|
||||
"effort": "trivial"
|
||||
}
|
||||
]
|
||||
},
|
||||
"6_security": {
|
||||
"title": "安全加固",
|
||||
"priority": "HIGH",
|
||||
"items": [
|
||||
{
|
||||
"id": "SE-01",
|
||||
"title": "CSP (Content Security Policy) 配置",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "未配置 CSP。Preview 使用 dangerouslySetInnerHTML 渲染 HTML,虽然 rehype-sanitize 做了清理,但缺少 CSP 作为第二道防线。",
|
||||
"currentState": "无 CSP 配置",
|
||||
"expectedState": "在 index.html 和 BrowserWindow 中配置严格 CSP",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "SE-02",
|
||||
"title": "Preview 中 file:// 协议安全",
|
||||
"priority": "P1-HIGH",
|
||||
"description": "rehypeFixImages 将相对路径图片转为 file:// URL。虽然拒绝了 '..' 但未做更严格的路径校验(如 symlink 跟踪)。",
|
||||
"currentState": "仅检查 '..' 路径遍历",
|
||||
"expectedState": "使用 path.resolve + 检查结果路径是否在允许目录内",
|
||||
"effort": "small"
|
||||
},
|
||||
{
|
||||
"id": "SE-03",
|
||||
"title": "Electron 安全最佳实践检查",
|
||||
"priority": "P2-MEDIUM",
|
||||
"description": "需确认 BrowserWindow 配置中 webPreferences 的安全设置(nodeIntegration、contextIsolation、sandbox 等)。",
|
||||
"currentState": "未检查 window-manager.ts 中的 webPreferences",
|
||||
"expectedState": "确保 nodeIntegration=false、contextIsolation=true、sandbox=true",
|
||||
"effort": "small"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"implementationPlan": {
|
||||
"phase1_foundation": {
|
||||
"name": "基础补全 (1-2天)",
|
||||
"priority": "P0",
|
||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
||||
"description": "ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复"
|
||||
},
|
||||
"phase2_criticalPerf": {
|
||||
"name": "关键性能修复 (1-2天)",
|
||||
"priority": "P0",
|
||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
||||
"description": "Markdown 缓存 + Zustand 选择器 + CodeMirror Compartment + 防抖"
|
||||
},
|
||||
"phase3_codeQuality": {
|
||||
"name": "代码质量提升 (2-3天)",
|
||||
"priority": "P1",
|
||||
"items": ["CQ-04", "CQ-05", "AR-01", "AR-02", "AR-03", "AR-04", "PF-03"],
|
||||
"description": "类型完善 + 错误处理统一 + 组件拆分 + 重复逻辑消除"
|
||||
},
|
||||
"phase4_userExperience": {
|
||||
"name": "用户体验改进 (2-3天)",
|
||||
"priority": "P1",
|
||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
||||
"description": "自定义确认框 + 加载状态 + Toast 改进 + 可访问性"
|
||||
},
|
||||
"phase5_developerExperience": {
|
||||
"name": "开发体验 (2-3天)",
|
||||
"priority": "P2",
|
||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
||||
"description": "单元测试 + 类型安全 + 文档 + Git hooks"
|
||||
},
|
||||
"phase6_advanced": {
|
||||
"name": "高级功能 (3-5天)",
|
||||
"priority": "P2-P3",
|
||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
||||
"description": "代码分割 + 分屏模式 + 拖拽排序 + 搜索 + 安全加固 + CI"
|
||||
}
|
||||
},
|
||||
"riskAssessment": {
|
||||
"highRisk": [
|
||||
{
|
||||
"id": "PF-04",
|
||||
"risk": "CodeMirror Compartment 重构可能影响编辑器初始化和标签切换逻辑",
|
||||
"mitigation": "保留现有逻辑作为 fallback,逐步迁移到 Compartment"
|
||||
},
|
||||
{
|
||||
"id": "UX-03",
|
||||
"risk": "分屏模式需要同时维护两个编辑器实例,内存和性能需关注",
|
||||
"mitigation": "使用虚拟化、延迟加载预览面板"
|
||||
}
|
||||
],
|
||||
"mediumRisk": [
|
||||
{
|
||||
"id": "PF-02",
|
||||
"risk": "Zustand 选择器重构可能遗漏某些订阅点",
|
||||
"mitigation": "使用 React DevTools Profiler 验证重渲染情况"
|
||||
},
|
||||
{
|
||||
"id": "SE-01",
|
||||
"risk": "CSP 配置过于严格可能阻断 CodeMirror 样式注入",
|
||||
"mitigation": "使用 nonce 或 hash 方式允许 CodeMirror 内联样式"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+5
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marklite",
|
||||
"version": "0.3.13",
|
||||
"version": "0.6.2",
|
||||
"description": "Lightweight Markdown Editor for Windows",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
@@ -28,32 +28,13 @@
|
||||
"author": "MarkLite",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@milkdown/core": "7.21.1",
|
||||
"@milkdown/ctx": "7.21.1",
|
||||
"@milkdown/plugin-clipboard": "7.21.1",
|
||||
"@milkdown/plugin-history": "7.21.1",
|
||||
"@milkdown/plugin-indent": "7.21.1",
|
||||
"@milkdown/plugin-listener": "7.21.1",
|
||||
"@milkdown/plugin-trailing": "7.21.1",
|
||||
"@milkdown/preset-commonmark": "7.21.1",
|
||||
"@milkdown/preset-gfm": "7.21.1",
|
||||
"@milkdown/prose": "7.21.1",
|
||||
"@milkdown/react": "7.21.1",
|
||||
"@milkdown/utils": "7.21.1",
|
||||
"dexie": "^4.0.11",
|
||||
"metona-toast": "file:../MetonaToast",
|
||||
"@metona-team/metona-editor": "0.4.1",
|
||||
"@metona-team/metona-sqlark": "0.7.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": {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { searchInDir } from '../file-system'
|
||||
|
||||
describe('searchInDir (主进程多文件搜索)', () => {
|
||||
let dir: string
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'marklite-search-'))
|
||||
mkdirSync(join(dir, 'sub'))
|
||||
mkdirSync(join(dir, 'node_modules'))
|
||||
writeFileSync(join(dir, 'a.md'), '# Hello\nline two\nWorld hello again\n')
|
||||
writeFileSync(join(dir, 'sub', 'b.markdown'), 'nothing here\nHELLO UPPER\n')
|
||||
writeFileSync(join(dir, 'skip.js'), 'hello in js\n')
|
||||
writeFileSync(join(dir, 'node_modules', 'c.md'), 'hello in deps\n')
|
||||
writeFileSync(join(dir, '.hidden.md'), 'hello hidden\n')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should find matches across files (case-insensitive default)', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'hello' })
|
||||
expect(result.success).toBe(true)
|
||||
// a.md: 2 处(# Hello / World hello again) + b.markdown: 1 处(HELLO UPPER)
|
||||
expect(result.matches).toHaveLength(3)
|
||||
expect(result.totalFiles).toBe(2)
|
||||
})
|
||||
|
||||
it('should respect case-sensitive option', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'HELLO', caseSensitive: true })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches?.[0].filePath).toContain('b.markdown')
|
||||
})
|
||||
|
||||
it('should support regex search', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '^line', useRegex: true })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches?.[0].line).toBe(2)
|
||||
})
|
||||
|
||||
it('should reject invalid regex with error', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '([', useRegex: true })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('无效的正则表达式')
|
||||
})
|
||||
|
||||
it('should skip node_modules and dotfiles', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'hidden' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should reject empty query', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should return error for invalid directory', async () => {
|
||||
const result = await searchInDir({ dirPath: join(dir, 'nope'), query: 'x' })
|
||||
// 目录不存在:walk 内部吞掉 readdir 错误,返回 0 结果(success: true)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+137
-10
@@ -1,7 +1,13 @@
|
||||
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
|
||||
import { join, extname, dirname, basename } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||
import type {
|
||||
ReadFileResult,
|
||||
SaveFileResult,
|
||||
FileNode,
|
||||
SearchInDirPayload,
|
||||
SearchInDirResult,
|
||||
} from '../shared/types'
|
||||
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
||||
|
||||
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
|
||||
@@ -10,26 +16,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) {
|
||||
@@ -39,16 +48,43 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
||||
|
||||
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
||||
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
|
||||
// B14-fix: 检测原文件编码(UTF-16 LE/BE BOM),保存时保持同编码,
|
||||
// 避免 UTF-16 文件被强制改写为 UTF-8 造成乱码
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
||||
const randomSuffix = randomBytes(8).toString('hex')
|
||||
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
|
||||
try {
|
||||
await writeFile(tmpFile, content, 'utf-8')
|
||||
let data: Buffer
|
||||
try {
|
||||
const existing = await readFile(filePath)
|
||||
if (existing.length >= 2 && existing[0] === 0xfe && existing[1] === 0xff) {
|
||||
// 原文件 UTF-16 BE:BOM + BE 编码写回
|
||||
const bom = Buffer.from([0xfe, 0xff])
|
||||
const body = Buffer.from(content, 'utf16le')
|
||||
body.swap16()
|
||||
data = Buffer.concat([bom, body])
|
||||
} else if (existing.length >= 2 && existing[0] === 0xff && existing[1] === 0xfe) {
|
||||
// 原文件 UTF-16 LE:BOM + LE 编码写回
|
||||
const bom = Buffer.from([0xff, 0xfe])
|
||||
const body = Buffer.from(content, 'utf16le')
|
||||
data = Buffer.concat([bom, body])
|
||||
} else {
|
||||
data = Buffer.from(content, 'utf-8')
|
||||
}
|
||||
} catch {
|
||||
// 原文件不存在/不可读:按 UTF-8 写新文件
|
||||
data = Buffer.from(content, 'utf-8')
|
||||
}
|
||||
await writeFile(tmpFile, data)
|
||||
await rename(tmpFile, filePath)
|
||||
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 +94,7 @@ export async function buildDirTree(
|
||||
dirPath: string,
|
||||
depth = 0,
|
||||
maxDepth = 10,
|
||||
visited?: Set<string>
|
||||
visited?: Set<string>,
|
||||
): Promise<FileNode[]> {
|
||||
if (depth > maxDepth) return []
|
||||
|
||||
@@ -109,3 +145,94 @@ export async function buildDirTree(
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// v0.6.2: 多文件搜索 — 递归遍历目录,逐行匹配 md/markdown/txt 文件内容
|
||||
const SEARCH_MAX_MATCHES = 500
|
||||
const SEARCH_MAX_DEPTH = 10
|
||||
const SEARCH_LINE_PREVIEW_LEN = 200
|
||||
|
||||
function buildSearchMatcher(
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
useRegex: boolean,
|
||||
): { test: (line: string) => boolean } | null {
|
||||
if (useRegex) {
|
||||
try {
|
||||
const re = new RegExp(query, caseSensitive ? '' : 'i')
|
||||
return { test: (line: string) => re.test(line) }
|
||||
} catch {
|
||||
return null // 无效正则
|
||||
}
|
||||
}
|
||||
const needle = caseSensitive ? query : query.toLowerCase()
|
||||
return {
|
||||
test: (line: string) =>
|
||||
(caseSensitive ? line : line.toLowerCase()).includes(needle),
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchInDir(
|
||||
payload: SearchInDirPayload,
|
||||
): Promise<SearchInDirResult> {
|
||||
const { dirPath, query, caseSensitive = false, useRegex = false } = payload
|
||||
if (!query) {
|
||||
return { success: false, error: '搜索内容为空' }
|
||||
}
|
||||
const matcher = buildSearchMatcher(query, caseSensitive, useRegex)
|
||||
if (!matcher) {
|
||||
return { success: false, error: '无效的正则表达式' }
|
||||
}
|
||||
|
||||
const matches: SearchInDirResult['matches'] = []
|
||||
let totalFiles = 0
|
||||
let truncated = false
|
||||
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (truncated || depth > SEARCH_MAX_DEPTH) return
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (truncated) return
|
||||
const name = entry.name
|
||||
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue
|
||||
const childPath = join(dir, name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(childPath, depth + 1)
|
||||
continue
|
||||
}
|
||||
if (!ALLOWED_EXTENSIONS_SET.has(extname(name).toLowerCase())) continue
|
||||
totalFiles++
|
||||
try {
|
||||
const fileStat = await stat(childPath)
|
||||
if (fileStat.size > MAX_FILE_SIZE) continue
|
||||
const content = await readFile(childPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!matcher.test(lines[i])) continue
|
||||
matches?.push({
|
||||
filePath: childPath,
|
||||
line: i + 1,
|
||||
lineText: lines[i].slice(0, SEARCH_LINE_PREVIEW_LEN),
|
||||
})
|
||||
if ((matches?.length ?? 0) >= SEARCH_MAX_MATCHES) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单文件读取失败(编码/权限)跳过,不中断整体搜索
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await walk(dirPath, 0)
|
||||
return { success: true, matches, totalFiles, truncated }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -27,6 +27,10 @@ export class FileWatcher {
|
||||
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
||||
this.watcher.on('error', () => {
|
||||
const originalPath = this.currentPath
|
||||
// B12-fix: watcher 已失效,必然不再处于"自写"状态 —
|
||||
// 若 error 恰好发生在保存流程 start() 与 setSelfWriting(false) 之间,
|
||||
// 残留的 isSelfWriting=true 会永久吞掉轮询恢复后的外部修改通知
|
||||
this.isSelfWriting = false
|
||||
this.stop()
|
||||
if (originalPath) {
|
||||
this.pollTimer = setInterval(() => {
|
||||
|
||||
+143
-140
@@ -1,140 +1,143 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { registerIpcHandlers } from './ipc-handlers'
|
||||
import { readFileContent } from './file-system'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
const state = {
|
||||
activeFilePath: null as string | null,
|
||||
pendingFilePath: null as string | null,
|
||||
isClosing: false,
|
||||
closeTimeout: null as NodeJS.Timeout | null
|
||||
}
|
||||
|
||||
const fileWatcher = new FileWatcher(() => mainWindow)
|
||||
const sidebarWatcher = new SidebarWatcher(() => mainWindow)
|
||||
|
||||
function openFileInTab(filePath: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
readFileContent(filePath).then(result => {
|
||||
if (result.success && mainWindow && !mainWindow.isDestroyed()) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||
}
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- IPC file open error
|
||||
console.error('openFileInTab failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const lockOk = setupSingleInstanceLock((filePath) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
if (filePath) openFileInTab(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
if (!lockOk) {
|
||||
app.quit()
|
||||
} else {
|
||||
|
||||
function setupCloseHandler(): void {
|
||||
if (!mainWindow) return
|
||||
mainWindow.on('close', (e) => {
|
||||
if (state.isClosing) return
|
||||
state.isClosing = true
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('window:confirmClose')
|
||||
} else {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
state.closeTimeout = setTimeout(() => {
|
||||
state.closeTimeout = null
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close')
|
||||
mainWindow.close()
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
}
|
||||
|
||||
// C-05: 窗口重建时重置状态
|
||||
function initWindow(): void {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.pendingFilePath) {
|
||||
openFileInTab(state.pendingFilePath)
|
||||
state.pendingFilePath = null
|
||||
}
|
||||
})
|
||||
|
||||
setupCloseHandler()
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
fileWatcher.stop()
|
||||
sidebarWatcher.stop()
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.on('open-file', (event, filePath) => {
|
||||
event.preventDefault()
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
|
||||
state.pendingFilePath = filePath
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
openFileInTab(filePath)
|
||||
} else {
|
||||
state.pendingFilePath = filePath
|
||||
}
|
||||
})
|
||||
|
||||
// C-03: IPC 处理器只注册一次
|
||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||
|
||||
initWindow()
|
||||
|
||||
const cmdFile = getFilePathFromArgs(process.argv)
|
||||
if (cmdFile) {
|
||||
state.pendingFilePath = cmdFile
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// C-03: activate 只重建窗口,不重复注册 IPC
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
initWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { registerIpcHandlers } from './ipc-handlers'
|
||||
import { readFileContent } from './file-system'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
const state = {
|
||||
activeFilePath: null as string | null,
|
||||
pendingFilePath: null as string | null,
|
||||
isClosing: false,
|
||||
closeTimeout: null as NodeJS.Timeout | null,
|
||||
}
|
||||
|
||||
const fileWatcher = new FileWatcher(() => mainWindow)
|
||||
const sidebarWatcher = new SidebarWatcher(() => mainWindow)
|
||||
|
||||
function openFileInTab(filePath: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
readFileContent(filePath)
|
||||
.then(result => {
|
||||
if (result.success && mainWindow && !mainWindow.isDestroyed()) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console -- IPC file open error
|
||||
console.error('openFileInTab failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const lockOk = setupSingleInstanceLock(filePath => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
if (filePath) openFileInTab(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
if (!lockOk) {
|
||||
app.quit()
|
||||
} else {
|
||||
function setupCloseHandler(): void {
|
||||
if (!mainWindow) return
|
||||
mainWindow.on('close', e => {
|
||||
if (state.isClosing) return
|
||||
state.isClosing = true
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('window:confirmClose')
|
||||
} else {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
// B4-fix: 超时 12s — 需长于渲染进程 MeToast.confirm 的 10 秒安全超时,
|
||||
// 否则用户尚未确认就被强杀(丢失最后 500ms 防抖窗口内的编辑)
|
||||
state.closeTimeout = setTimeout(() => {
|
||||
state.closeTimeout = null
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close')
|
||||
mainWindow.close()
|
||||
}
|
||||
}, 12000)
|
||||
})
|
||||
}
|
||||
|
||||
// C-05: 窗口重建时重置状态
|
||||
function initWindow(): void {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.pendingFilePath) {
|
||||
openFileInTab(state.pendingFilePath)
|
||||
state.pendingFilePath = null
|
||||
}
|
||||
})
|
||||
|
||||
setupCloseHandler()
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
fileWatcher.stop()
|
||||
sidebarWatcher.stop()
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.on('open-file', (event, filePath) => {
|
||||
event.preventDefault()
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
|
||||
state.pendingFilePath = filePath
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
openFileInTab(filePath)
|
||||
} else {
|
||||
state.pendingFilePath = filePath
|
||||
}
|
||||
})
|
||||
|
||||
// C-03: IPC 处理器只注册一次
|
||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||
|
||||
initWindow()
|
||||
|
||||
const cmdFile = getFilePathFromArgs(process.argv)
|
||||
if (cmdFile) {
|
||||
state.pendingFilePath = cmdFile
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// C-03: activate 只重建窗口,不重复注册 IPC
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
initWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+302
-228
@@ -1,228 +1,302 @@
|
||||
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
if (!filePath || typeof filePath !== 'string') return false
|
||||
// 拒绝空字节
|
||||
if (filePath.includes('\0')) return false
|
||||
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
|
||||
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
|
||||
const sepPattern = /[/\\]/
|
||||
const parts = filePath.split(sepPattern)
|
||||
if (parts.some(part => part === '..')) return false
|
||||
// 必须是绝对路径
|
||||
if (!isAbsolute(filePath)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
|
||||
): void {
|
||||
// 打开文件对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileResult = await readFileContent(filePath)
|
||||
if (fileResult.success) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
win.setTitle(`MarkLite - ${basename(filePath)}`)
|
||||
return { filePath, content: fileResult.content }
|
||||
}
|
||||
return { error: fileResult.error }
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 读取文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
return readFileContent(filePath)
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
if (data.filePath && !validatePath(data.filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
const win = getMainWindow()
|
||||
try {
|
||||
if (data.filePath) {
|
||||
// Mark self-writing before stopping watcher to suppress any events
|
||||
// that arrive between stop and start
|
||||
fileWatcher.setSelfWriting(true)
|
||||
fileWatcher.stop()
|
||||
const result = await saveFileContent(data.filePath, data.content)
|
||||
// Restart watcher while selfWriting is still true so any immediate 'change'
|
||||
// event from the restart is suppressed — prevents overwriting editor content
|
||||
fileWatcher.start(data.filePath)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
state.activeFilePath = data.filePath
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
const saveResult = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!saveResult.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
if (result.success) {
|
||||
state.activeFilePath = saveResult.filePath
|
||||
fileWatcher.start(saveResult.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return result
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (state.activeFilePath) {
|
||||
fileWatcher.start(state.activeFilePath)
|
||||
}
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!result.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
fileWatcher.start(result.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前路径
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
|
||||
|
||||
// 文件统计
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 重新加载
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
|
||||
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
|
||||
const result = await readFileContent(state.activeFilePath)
|
||||
return { ...result, filePath: state.activeFilePath }
|
||||
})
|
||||
|
||||
// 目录树
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
try {
|
||||
const tree = await buildDirTree(dirPath)
|
||||
return { success: true, tree, rootPath: dirPath }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 打开文件夹对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 目录监听
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) return
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
const normalizedPath = filePath || null
|
||||
if (normalizedPath && !validatePath(normalizedPath)) {
|
||||
fileWatcher.stop()
|
||||
return
|
||||
}
|
||||
state.activeFilePath = normalizedPath
|
||||
fileWatcher.start(normalizedPath || '')
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
|
||||
}
|
||||
})
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
fileWatcher.stop()
|
||||
win.removeAllListeners('close')
|
||||
win.close()
|
||||
}
|
||||
})
|
||||
|
||||
// B-01: 重置关闭状态 + 清除超时定时器
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
})
|
||||
}
|
||||
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree, searchInDir } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import type { SearchInDirPayload } from '../shared/types'
|
||||
import { stat, readFile, writeFile } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
if (!filePath || typeof filePath !== 'string') return false
|
||||
// 拒绝空字节
|
||||
if (filePath.includes('\0')) return false
|
||||
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
|
||||
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
|
||||
const sepPattern = /[/\\]/
|
||||
const parts = filePath.split(sepPattern)
|
||||
if (parts.some(part => part === '..')) return false
|
||||
// 必须是绝对路径
|
||||
if (!isAbsolute(filePath)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: {
|
||||
activeFilePath: string | null
|
||||
pendingFilePath: string | null
|
||||
isClosing: boolean
|
||||
closeTimeout: NodeJS.Timeout | null
|
||||
},
|
||||
): void {
|
||||
// 打开文件对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileResult = await readFileContent(filePath)
|
||||
if (fileResult.success) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
win.setTitle(`MarkLite - ${basename(filePath)}`)
|
||||
return { filePath, content: fileResult.content }
|
||||
}
|
||||
return { error: fileResult.error }
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 读取文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
return readFileContent(filePath)
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_SAVE,
|
||||
async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
if (data.filePath && !validatePath(data.filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
const win = getMainWindow()
|
||||
try {
|
||||
if (data.filePath) {
|
||||
// Mark self-writing before stopping watcher to suppress any events
|
||||
// that arrive between stop and start
|
||||
fileWatcher.setSelfWriting(true)
|
||||
fileWatcher.stop()
|
||||
const result = await saveFileContent(data.filePath, data.content)
|
||||
// Restart watcher while selfWriting is still true so any immediate 'change'
|
||||
// event from the restart is suppressed — prevents overwriting editor content
|
||||
fileWatcher.start(data.filePath)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
state.activeFilePath = data.filePath
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
const saveResult = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
|
||||
})
|
||||
if (!saveResult.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
if (result.success) {
|
||||
state.activeFilePath = saveResult.filePath
|
||||
fileWatcher.start(saveResult.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return result
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (state.activeFilePath) {
|
||||
fileWatcher.start(state.activeFilePath)
|
||||
}
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_SAVE_AS,
|
||||
async (_event: IpcMainInvokeEvent, data: { content: string }) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
|
||||
})
|
||||
if (!result.canceled) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
fileWatcher.start(result.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 获取当前路径
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
|
||||
|
||||
// 文件统计
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 重新加载
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
|
||||
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
|
||||
const result = await readFileContent(state.activeFilePath)
|
||||
return { ...result, filePath: state.activeFilePath }
|
||||
})
|
||||
|
||||
// 目录树
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIR_READ_TREE,
|
||||
async (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
try {
|
||||
const tree = await buildDirTree(dirPath)
|
||||
return { success: true, tree, rootPath: dirPath }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 打开文件夹对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 目录监听
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) return
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// v0.6.2: 多文件搜索 — 目录内递归搜索匹配行
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIR_SEARCH,
|
||||
async (_event: IpcMainInvokeEvent, payload: SearchInDirPayload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: '无效的搜索参数' }
|
||||
}
|
||||
if (!validatePath(payload.dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
if (typeof payload.query !== 'string') {
|
||||
return { success: false, error: '无效的搜索内容' }
|
||||
}
|
||||
return searchInDir(payload)
|
||||
},
|
||||
)
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TAB_SWITCHED,
|
||||
(_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
const normalizedPath = filePath || null
|
||||
if (normalizedPath && !validatePath(normalizedPath)) {
|
||||
fileWatcher.stop()
|
||||
return
|
||||
}
|
||||
state.activeFilePath = normalizedPath
|
||||
fileWatcher.start(normalizedPath || '')
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
fileWatcher.stop()
|
||||
win.removeAllListeners('close')
|
||||
win.close()
|
||||
}
|
||||
})
|
||||
|
||||
// B-01: 重置关闭状态 + 清除超时定时器
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
})
|
||||
|
||||
// v0.6.0: 数据备份导出 — 保存对话框 + 写 JSON 文件
|
||||
ipcMain.handle(IPC_CHANNELS.DATA_EXPORT, async (_event: IpcMainInvokeEvent, content: string) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const dateStr = new Date().toISOString().slice(0, 10)
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
title: '导出数据备份',
|
||||
defaultPath: `marklite-backup-${dateStr}.json`,
|
||||
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
|
||||
})
|
||||
if (result.canceled) return { success: false, canceled: true }
|
||||
await writeFile(result.filePath, content, 'utf8')
|
||||
return { success: true, filePath: result.filePath }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// v0.6.0: 数据备份导入 — 打开对话框 + 读 JSON 文件
|
||||
ipcMain.handle(IPC_CHANNELS.DATA_IMPORT, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: '导入数据备份',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
const content = await readFile(result.filePaths[0], 'utf8')
|
||||
return { success: true, content }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+29
-12
@@ -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),
|
||||
@@ -37,28 +37,45 @@ const api: ElectronAPI = {
|
||||
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
||||
searchInDir: (payload: import('../shared/types').SearchInDirPayload) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIR_SEARCH, payload),
|
||||
|
||||
// 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)
|
||||
|
||||
+190
-161
@@ -1,161 +1,190 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { flushSaveToDB } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { useConfirm } from './hooks/useConfirm'
|
||||
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
|
||||
import { useStatusBarItem } from './hooks/useStatusBarItem'
|
||||
import { useAutoSaveStore } from './stores/autoSaveStore'
|
||||
import { toggleAutoSaveExternal } from './hooks/useAutoSave'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
import { SourceEditor } from './components/SourceEditor/SourceEditor'
|
||||
import { Preview } from './components/Preview/Preview'
|
||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
||||
|
||||
/** 状态栏 — 自动保存开关组件 */
|
||||
function AutoSaveStatusItem() {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const { isAutoSaving, autoSaveEnabled } = useAutoSaveStore()
|
||||
|
||||
if (!activeTab?.filePath) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
|
||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||
onClick={toggleAutoSaveExternal}
|
||||
>
|
||||
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode } = useSettings()
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
|
||||
useSettingsInit()
|
||||
useEffect(() => { loadFromDB() }, [loadFromDB])
|
||||
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
|
||||
useDragDrop()
|
||||
useFileWatch()
|
||||
useAutoSave()
|
||||
|
||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||
return confirm({
|
||||
title: '未保存的更改',
|
||||
message,
|
||||
variant: 'warning',
|
||||
confirmLabel: '不保存',
|
||||
cancelLabel: '取消'
|
||||
})
|
||||
}, [confirm])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useIpcListeners()
|
||||
|
||||
// ── 状态栏扩展点架构 ───────────────────────────────────
|
||||
useDefaultStatusBarItems()
|
||||
|
||||
useStatusBarItem({
|
||||
id: 'statusbar.auto-save',
|
||||
alignment: 'right',
|
||||
priority: 200,
|
||||
Component: AutoSaveStatusItem
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
|
||||
}, [activeTabId, tabs])
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
// BUG-10: Don't overwrite editor content the user has modified since the last save.
|
||||
// The watcher restart after save can trigger a false 'change' event, and re-reading
|
||||
// the file at that point would replace newer editor content with the just-saved content.
|
||||
if (tab?.isModified) return
|
||||
if (!tab) return
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
viewMode={viewMode} onViewModeChange={saveViewMode}
|
||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
|
||||
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>}
|
||||
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
App.displayName = 'App'
|
||||
export default App
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { flushSaveToDB } from './stores/tabStore'
|
||||
import { useEditorStore, getMetonaEditor } from './stores/editorStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { MeToast, showToast } from './lib/toast'
|
||||
import { closeDatabase } from './db/schema'
|
||||
import { recentFilesRepository } from './db/recentFilesRepository'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { themeMode, cycleTheme } = useTheme()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
const handleCloseSearch = useCallback(() => setShowSearch(false), [])
|
||||
|
||||
useSettingsInit()
|
||||
useEffect(() => {
|
||||
loadFromDB()
|
||||
}, [loadFromDB])
|
||||
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
|
||||
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
|
||||
useDragDrop()
|
||||
useFileWatch()
|
||||
// useAutoSave() already called above to get state for toolbar
|
||||
|
||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||
// v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false))
|
||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||
return MeToast.confirm(message, {
|
||||
title: '未保存的更改',
|
||||
confirmText: '不保存',
|
||||
cancelText: '取消',
|
||||
})
|
||||
}, [])
|
||||
|
||||
// v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
|
||||
const handleBeforeForceClose = useCallback(async () => {
|
||||
await flushSaveToDB()
|
||||
await closeDatabase()
|
||||
}, [])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs, () => setShowSearch(true))
|
||||
useIpcListeners()
|
||||
|
||||
// v0.6.2: 多文件搜索 — 打开结果文件并定位到匹配行
|
||||
const handleSearchOpenResult = useCallback(
|
||||
async (filePath: string, line: number) => {
|
||||
const existing = tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
useTabStore.getState().switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
} else {
|
||||
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
setShowSearch(false)
|
||||
// 等待编辑器完成标签切换后再定位
|
||||
setTimeout(() => {
|
||||
const editor = getMetonaEditor()
|
||||
if (editor) {
|
||||
editor.scrollToLine(line)
|
||||
editor.setCursorPosition(line, 0)
|
||||
editor.focus()
|
||||
}
|
||||
}, 150)
|
||||
},
|
||||
[tabs, createTab],
|
||||
)
|
||||
|
||||
// B3-fix: 只依赖活动文件的路径 — 此前依赖 tabs 数组导致每次击键
|
||||
// (updateTabContent)都触发 tabSwitched IPC 并重启主进程文件 watcher
|
||||
const activeFilePath = useMemo(
|
||||
() => tabs.find(t => t.id === activeTabId)?.filePath ?? null,
|
||||
[tabs, activeTabId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.tabSwitched(activeFilePath)
|
||||
}, [activeFilePath])
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (!tab) {
|
||||
setExternallyModified(null)
|
||||
return
|
||||
}
|
||||
// B6-fix: 标签有未保存修改时不再静默无反应,显式提示
|
||||
if (tab.isModified) {
|
||||
showToast('当前标签有未保存的修改,请先保存或放弃修改后再重新加载', 'warning')
|
||||
return
|
||||
}
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
} else {
|
||||
showToast(`重新加载失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile}
|
||||
onSave={handleSave}
|
||||
themeMode={themeMode}
|
||||
onCycleTheme={cycleTheme}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
onShowSearch={() => setShowSearch(true)}
|
||||
isAutoSaving={isAutoSaving}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
onToggleAutoSave={toggleAutoSave}
|
||||
/>
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner
|
||||
filePath={externallyModified.filePath}
|
||||
onReload={handleReloadModified}
|
||||
onDismiss={() => setExternallyModified(null)}
|
||||
/>
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
<div id="editor-panel">
|
||||
<Editor themeMode={themeMode} onAppSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen
|
||||
onOpen={handleOpenFile}
|
||||
onNew={() => createTab(null, '')}
|
||||
onOpenRecent={handleOpenRecent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
{showSearch && (
|
||||
<SearchPanel onClose={handleCloseSearch} onOpenResult={handleSearchOpenResult} />
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
App.displayName = 'App'
|
||||
export default App
|
||||
|
||||
@@ -1,55 +1,66 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
import { APP_VERSION } from '../../lib/constants'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
|
||||
} else {
|
||||
window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
import { APP_VERSION } from '../../lib/constants'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
|
||||
} else {
|
||||
window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>浮动格式栏</span>
|
||||
<span>数据备份</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.7.4</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Only restore focus if the element is still in the DOM
|
||||
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
|
||||
previousFocusRef.current.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
@@ -1 +0,0 @@
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
@@ -1,129 +1,294 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useMilkdown } from './useMilkdown'
|
||||
import { EditorToolbar } from './EditorToolbar'
|
||||
import { SearchReplace } from '../SearchReplace'
|
||||
import { setEditorViewGetter } from '../../stores/editorStore'
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const currentContentRef = useRef('')
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
} = useMilkdown({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
if (!activeTabId) return
|
||||
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
|
||||
// 避免将文档误标记为已修改
|
||||
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
|
||||
if (tab?.content === value) return
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
if (activeTab.content !== currentContentRef.current) {
|
||||
currentContentRef.current = activeTab.content
|
||||
// Queue setContent after Milkdown editor has finished async create()
|
||||
requestAnimationFrame(() => {
|
||||
setContent(activeTab.content)
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
||||
})
|
||||
})
|
||||
}
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
// Snapshot the current editor state before the new tab replaces content
|
||||
const scrollPos = getScrollTop()
|
||||
const sel = getSelection()
|
||||
// Use a microtask to ensure we save before React re-renders the new tab content
|
||||
Promise.resolve().then(() => {
|
||||
updateTabScroll(activeTabId, {
|
||||
scrollTop: scrollPos,
|
||||
selectionStart: sel.from,
|
||||
selectionEnd: sel.to
|
||||
})
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Register getView for OutlinePanel navigation
|
||||
useEffect(() => {
|
||||
setEditorViewGetter(getView)
|
||||
return () => setEditorViewGetter(() => null)
|
||||
}, [getView])
|
||||
|
||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleStrongCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
getView={getView}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
import mermaid from 'mermaid'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
|
||||
import { settingsRepository } from '../../db/settingsRepository'
|
||||
import { renderMarkdownSync } from '../../lib/markdown'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
|
||||
type EditMode = 'edit' | 'split' | 'preview'
|
||||
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
|
||||
|
||||
// v0.4.5: Mermaid 初始化(全局一次性配置)
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' })
|
||||
|
||||
interface EditorProps {
|
||||
themeMode: ThemeMode
|
||||
onAppSave?: () => void
|
||||
}
|
||||
|
||||
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
|
||||
function mapViewMode(vm: string): EditMode {
|
||||
if (vm === 'source') return 'edit'
|
||||
if (vm === 'preview') return 'preview'
|
||||
return 'split'
|
||||
}
|
||||
|
||||
/** 将 MetonaEditor mode 反向映射到应用 viewMode */
|
||||
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
|
||||
if (mode === 'edit') return 'source'
|
||||
if (mode === 'preview') return 'preview'
|
||||
return 'editor'
|
||||
}
|
||||
|
||||
/** 预设插件(字符串形式,与 demo 一致) */
|
||||
const EDITOR_PLUGINS: string[] = [
|
||||
'searchReplace', // Ctrl+F/Ctrl+H
|
||||
'imagePaste', // Ctrl+V 粘贴图片
|
||||
'exportTool', // 导出 MD/HTML
|
||||
'shortcutHelp', // 按 ? 弹出快捷键面板
|
||||
]
|
||||
|
||||
/**
|
||||
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
||||
*/
|
||||
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const setZenMode = useEditorStore(s => s.setZenMode)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<MarkdownEditor | null>(null)
|
||||
const currentContentRef = useRef('')
|
||||
|
||||
// 稳定的回调引用
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
activeTabIdRef.current = activeTabId
|
||||
|
||||
// ── 初始化 MetonaEditor ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const config = {
|
||||
value: activeTab?.content ?? '',
|
||||
mode: mapViewMode(viewMode),
|
||||
height: '100%',
|
||||
toolbar: [
|
||||
'bold',
|
||||
'italic',
|
||||
'strikethrough',
|
||||
'underline',
|
||||
'code',
|
||||
'|',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'|',
|
||||
'quote',
|
||||
'ul',
|
||||
'ol',
|
||||
'indent',
|
||||
'outdent',
|
||||
'|',
|
||||
'link',
|
||||
'image',
|
||||
'table',
|
||||
'hr',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'edit',
|
||||
'split',
|
||||
'preview',
|
||||
'fullscreen',
|
||||
],
|
||||
locale: 'zh-CN',
|
||||
theme: themeMode as ThemeName,
|
||||
placeholder: '在此输入 Markdown 内容...',
|
||||
spellcheck: false,
|
||||
tabSize: 2,
|
||||
wordCount: true,
|
||||
autofocus: true,
|
||||
lineNumbers: true,
|
||||
autoBrackets: true,
|
||||
readOnly: viewMode === 'preview',
|
||||
plugins: EDITOR_PLUGINS,
|
||||
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
|
||||
floatingToolbar: true,
|
||||
// v0.6.1: 专注模式宽度使用 0.4.1 官方 API — 100% 全宽(默认 960px 偏窄)
|
||||
zenMaxWidth: '100%',
|
||||
// v0.2.4 新增配置项
|
||||
syncScroll: true,
|
||||
wordWrap: true,
|
||||
outline: false, // 使用自研 OutlinePanel
|
||||
historyLimit: 100,
|
||||
historyDebounce: 400,
|
||||
|
||||
// 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
|
||||
// v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
|
||||
highlight: MeEditor.highlight,
|
||||
render: (md: string) => {
|
||||
const tabId = activeTabIdRef.current
|
||||
const filePath = tabId
|
||||
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
|
||||
: null
|
||||
return renderMarkdownSync(md, filePath)
|
||||
},
|
||||
|
||||
// 内容变化 → 同步到 tabStore
|
||||
onChange: (value: string) => {
|
||||
const tabId = activeTabIdRef.current
|
||||
if (!tabId) return
|
||||
const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
|
||||
if (tab?.content === value) return
|
||||
currentContentRef.current = value
|
||||
updateTabContent(tabId, value)
|
||||
setModified(tabId, true)
|
||||
},
|
||||
|
||||
// 模式切换 → 同步到 editorStore 并持久化
|
||||
onModeChange: (mode: string) => {
|
||||
const mapped = reverseMapMode(mode as EditMode)
|
||||
setViewMode(mapped)
|
||||
settingsRepository.save({ viewMode: mapped })
|
||||
},
|
||||
|
||||
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
|
||||
onSave: () => {
|
||||
onAppSave?.()
|
||||
},
|
||||
|
||||
// v0.2.4 新增回调: 链接点击 → 安全打开外部链接
|
||||
onLinkClick: (href: string) => {
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
}
|
||||
},
|
||||
|
||||
// v0.2.4 新增回调: 焦点事件(预留扩展点)
|
||||
onFocus: () => {
|
||||
// 编辑器获得焦点
|
||||
},
|
||||
onBlur: () => {
|
||||
// 编辑器失去焦点
|
||||
},
|
||||
}
|
||||
|
||||
const editor = MeEditor.create(container, config)
|
||||
|
||||
editorRef.current = editor
|
||||
setMetonaEditorGetter(() => editor)
|
||||
currentContentRef.current = activeTab?.content ?? ''
|
||||
|
||||
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
|
||||
const renderMermaid = () => {
|
||||
try {
|
||||
mermaid.run({ querySelector: '.me-mermaid .mermaid' })
|
||||
} catch {
|
||||
/* 容错 */
|
||||
}
|
||||
}
|
||||
editor.on('afterRender', renderMermaid)
|
||||
// 首次渲染后延迟触发一次
|
||||
setTimeout(renderMermaid, 300)
|
||||
|
||||
// v0.6.0: Zen 状态同步 → editorStore(Toolbar 消费)
|
||||
// 文档统计由编辑器自带底栏展示(wordCount: true),无需应用层同步
|
||||
const syncZen = (zen: boolean) => {
|
||||
setZenMode(Boolean(zen))
|
||||
}
|
||||
editor.on('zenChange', syncZen)
|
||||
|
||||
return () => {
|
||||
editor.destroy()
|
||||
editorRef.current = null
|
||||
setMetonaEditorGetter(() => null)
|
||||
}
|
||||
// 仅在挂载时创建一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ── 标签切换:同步内容到编辑器 ──────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeTab || !editorRef.current) return
|
||||
if (activeTab.content === currentContentRef.current) return
|
||||
|
||||
currentContentRef.current = activeTab.content
|
||||
|
||||
// silent: true — 不触发 onChange,避免重复更新 tabStore
|
||||
editorRef.current.setValue(activeTab.content, { silent: true })
|
||||
|
||||
// 恢复滚动位置 + 光标位置
|
||||
requestAnimationFrame(() => {
|
||||
const c = containerRef.current
|
||||
if (!c) return
|
||||
const textarea = c.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textarea.scrollTop = activeTab.scrollTop
|
||||
}
|
||||
const preview = c.querySelector('.me-preview') as HTMLElement | null
|
||||
if (preview) {
|
||||
preview.scrollTop = activeTab.scrollTop
|
||||
}
|
||||
// B10-fix: 恢复光标位置(此前 selectionStart/End 已持久化但从未恢复)
|
||||
const editor = editorRef.current
|
||||
if (editor && activeTab.selectionStart > 0) {
|
||||
const before = activeTab.content.substring(
|
||||
0,
|
||||
Math.min(activeTab.selectionStart, activeTab.content.length),
|
||||
)
|
||||
const lines = before.split('\n')
|
||||
editor.setCursorPosition(lines.length, lines[lines.length - 1].length)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
|
||||
useEffect(() => {
|
||||
const c = containerRef.current
|
||||
return () => {
|
||||
if (!activeTabId || !editorRef.current || !c) return
|
||||
const textarea = c.querySelector('textarea')
|
||||
const preview = c.querySelector('.me-preview') as HTMLElement | null
|
||||
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
|
||||
updateTabScroll(activeTabId, { scrollTop })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// ── 主题同步 ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 全局主题(documentElement + localStorage)
|
||||
MeEditor.setTheme(themeMode)
|
||||
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
|
||||
editorRef.current?.setTheme(themeMode)
|
||||
} catch {
|
||||
// 容错
|
||||
}
|
||||
}, [themeMode])
|
||||
|
||||
// ── 视图模式同步 ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const targetMode = mapViewMode(viewMode)
|
||||
if (editor.getMode() !== targetMode) {
|
||||
editor.setMode(targetMode)
|
||||
}
|
||||
// 预览模式设为只读
|
||||
editor.setReadOnly(viewMode === 'preview')
|
||||
}, [viewMode])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<div ref={containerRef} className="metona-editor-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { Editor } from '@milkdown/core'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import type { $Command } from '@milkdown/utils'
|
||||
import {
|
||||
toggleStrongCommand,
|
||||
toggleEmphasisCommand,
|
||||
wrapInHeadingCommand,
|
||||
wrapInBlockquoteCommand,
|
||||
wrapInBulletListCommand,
|
||||
wrapInOrderedListCommand,
|
||||
createCodeBlockCommand,
|
||||
toggleInlineCodeCommand,
|
||||
insertImageCommand,
|
||||
toggleLinkCommand,
|
||||
insertHrCommand
|
||||
} from '@milkdown/preset-commonmark'
|
||||
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
action: (fn: (editor: Editor) => void) => void
|
||||
}
|
||||
|
||||
export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
|
||||
const exec = useCallback(<T,>(command: $Command<T>, payload?: T) => {
|
||||
action((editor) => {
|
||||
try {
|
||||
editor.action(callCommand(command.key, payload))
|
||||
} catch {
|
||||
// command may not be registered
|
||||
}
|
||||
})
|
||||
}, [action])
|
||||
|
||||
const handleHeading = useCallback((level: number) => {
|
||||
exec(wrapInHeadingCommand, level)
|
||||
}, [exec])
|
||||
|
||||
return (
|
||||
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(toggleStrongCommand)}
|
||||
title="粗体 (Ctrl+B)"
|
||||
aria-label="粗体"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(toggleEmphasisCommand)}
|
||||
title="斜体 (Ctrl+I)"
|
||||
aria-label="斜体"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(toggleStrikethroughCommand)}
|
||||
title="删除线"
|
||||
aria-label="删除线"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(toggleInlineCodeCommand)}
|
||||
title="行内代码"
|
||||
aria-label="行内代码"
|
||||
>
|
||||
{'</>'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => handleHeading(1)}
|
||||
title="标题"
|
||||
aria-label="一级标题"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => handleHeading(2)}
|
||||
title="二级标题"
|
||||
aria-label="二级标题"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => handleHeading(3)}
|
||||
title="三级标题"
|
||||
aria-label="三级标题"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(wrapInBulletListCommand)}
|
||||
title="无序列表"
|
||||
aria-label="无序列表"
|
||||
>
|
||||
•≡
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(wrapInOrderedListCommand)}
|
||||
title="有序列表"
|
||||
aria-label="有序列表"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(wrapInBlockquoteCommand)}
|
||||
title="引用"
|
||||
aria-label="引用"
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(createCodeBlockCommand)}
|
||||
title="代码块"
|
||||
aria-label="代码块"
|
||||
>
|
||||
{'{ }'}
|
||||
</button>
|
||||
<div className="toolbar-divider-sm" role="separator" />
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
|
||||
title="链接"
|
||||
aria-label="插入链接"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
|
||||
title="图片"
|
||||
aria-label="插入图片"
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn-sm"
|
||||
onClick={() => exec(insertHrCommand)}
|
||||
title="分割线"
|
||||
aria-label="插入分割线"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
EditorToolbar.displayName = 'EditorToolbar'
|
||||
@@ -1,344 +0,0 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
rootCtx,
|
||||
defaultValueCtx,
|
||||
editorViewCtx,
|
||||
prosePluginsCtx
|
||||
} from '@milkdown/core'
|
||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||
import { commonmark } from '@milkdown/preset-commonmark'
|
||||
import { gfm } from '@milkdown/preset-gfm'
|
||||
import { history } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { indent } from '@milkdown/plugin-indent'
|
||||
import { trailing } from '@milkdown/plugin-trailing'
|
||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||
import { Plugin, PluginKey, TextSelection } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorState } from '@milkdown/prose/state'
|
||||
|
||||
// --- Auto-pair plugin (D2) ---
|
||||
|
||||
const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
|
||||
|
||||
function createAutoPairPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
handleTextInput(view, from, to, text) {
|
||||
// 选中文本时用配对符号包裹
|
||||
if (from !== to && PAIRS[text]) {
|
||||
const close = PAIRS[text]
|
||||
const { tr } = view.state
|
||||
tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleKeyDown(view, event) {
|
||||
const char = event.key
|
||||
|
||||
// Backspace: 删除配对符号(光标在两个配对符号中间时)
|
||||
// 必须在 PAIRS 检查之前处理,因为 'Backspace' 不在 PAIRS 映射中
|
||||
if (char === 'Backspace') {
|
||||
const { state } = view
|
||||
const { from, to } = state.selection
|
||||
if (from !== to || from < 2) return false
|
||||
const before = state.doc.textBetween(from - 1, from)
|
||||
const after = state.doc.textBetween(from, from + 1)
|
||||
if (PAIRS[before] === after) {
|
||||
const tr = state.tr.delete(from - 1, from + 1)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理配对符号的自动闭合
|
||||
if (!PAIRS[char]) return false
|
||||
|
||||
const { state } = view
|
||||
const { from, to } = state.selection
|
||||
|
||||
if (from !== to) {
|
||||
// 有选区时:包裹
|
||||
const close = PAIRS[char]
|
||||
const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
// 无选区:插入配对并光标置中
|
||||
const close = PAIRS[char]
|
||||
const tr = state.tr.insertText(char + close, from)
|
||||
// 光标置于中间
|
||||
tr.setSelection(
|
||||
TextSelection.create(tr.doc, from + 1)
|
||||
)
|
||||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Search highlight plugin ---
|
||||
|
||||
export interface SearchMatch {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface SearchPluginState {
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
||||
|
||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
||||
return new Plugin<SearchPluginState>({
|
||||
key: searchPluginKey,
|
||||
state: {
|
||||
init(): SearchPluginState {
|
||||
return { matches: [], currentIndex: -1, query: '' }
|
||||
},
|
||||
apply(tr, value): SearchPluginState {
|
||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
||||
if (meta) return meta
|
||||
// When the document changes, map existing match positions through the change
|
||||
// but only if there's an active search
|
||||
if (tr.docChanged && value.query) {
|
||||
// The SearchReplace component will re-search when doc changes,
|
||||
// but we map positions so decorations stay roughly correct in the interim
|
||||
return { ...value }
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
const pluginState = searchPluginKey.getState(state)
|
||||
if (!pluginState || pluginState.matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
||||
const cls =
|
||||
index === pluginState.currentIndex
|
||||
? 'search-match-active'
|
||||
: 'search-match-highlight'
|
||||
return Decoration.inline(match.from, match.to, { class: cls })
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseMilkdownOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const initialContentRef = useRef(content)
|
||||
// H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
|
||||
const setContentRequestId = useRef(0)
|
||||
|
||||
// Keep onChangeRef fresh
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Initialize editor
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown === prevMarkdown) return
|
||||
// H-03: 只有未被外部更新抑制时才触发 onChange。
|
||||
// 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
|
||||
if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
|
||||
onChangeRef.current(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for blur events (used for potential future state saving)
|
||||
lm.blur(() => {
|
||||
// blur handler placeholder
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(indent)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Dark mode: update CSS custom properties on the container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (darkMode) {
|
||||
container.classList.add('milkdown-dark')
|
||||
} else {
|
||||
container.classList.remove('milkdown-dark')
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
// External content update (tab switch)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
// H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
|
||||
const requestId = ++setContentRequestId.current
|
||||
isExternalUpdate.current = true
|
||||
try {
|
||||
editor.action(milkdownReplaceAll(newContent))
|
||||
} catch {
|
||||
// replaceAll may fail if editor is not fully ready
|
||||
} finally {
|
||||
// 仅当没有新的 setContent 启动时才重置标志
|
||||
if (setContentRequestId.current === requestId) {
|
||||
setContentRequestId.current = 0
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll position management
|
||||
const getScrollTop = useCallback((): number => {
|
||||
const container = containerRef.current
|
||||
if (!container) return 0
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
if (scroller) {
|
||||
scroller.scrollTop = top
|
||||
} else {
|
||||
container.scrollTop = top
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Selection management
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return { from: 0, to: 0 }
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (view?.state?.selection) {
|
||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||
}
|
||||
return { from: 0, to: 0 }
|
||||
})
|
||||
} catch {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSelection = useCallback((from?: number, to?: number) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
try {
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (!view) return
|
||||
const docSize = view.state.doc.content.size
|
||||
// 恢复保存的选区,越界时夹紧到文档末尾
|
||||
const safeFrom = Math.min(from ?? 0, docSize)
|
||||
const safeTo = Math.min(to ?? safeFrom, docSize)
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, safeFrom, safeTo)
|
||||
)
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
})
|
||||
} catch {
|
||||
// 编辑器未就绪或选区无效,回退到仅 focus
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||
pmEditor?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get ProseMirror EditorView instance
|
||||
const getView = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return null
|
||||
try {
|
||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Execute a Milkdown action (for toolbar commands)
|
||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
fn(editor)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
editorRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,56 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
const isDev =
|
||||
(typeof import.meta !== 'undefined' &&
|
||||
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
|
||||
false
|
||||
|
||||
return (
|
||||
<div className="error-boundary-root" role="alert">
|
||||
<h2 className="error-boundary-title">应用遇到了错误</h2>
|
||||
{isDev && (
|
||||
<pre className="error-boundary-detail">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
)}
|
||||
<button className="error-boundary-reset" onClick={this.handleReset}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
const isDev =
|
||||
(typeof import.meta !== 'undefined' &&
|
||||
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
|
||||
false
|
||||
|
||||
return (
|
||||
<div className="error-boundary-root" role="alert">
|
||||
<h2 className="error-boundary-title">应用遇到了错误</h2>
|
||||
{isDev && <pre className="error-boundary-detail">{this.state.error?.message}</pre>}
|
||||
<button className="error-boundary-reset" onClick={this.handleReset}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,83 +12,88 @@ 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">
|
||||
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/>
|
||||
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/>
|
||||
<path d="M12 11h4" strokeDasharray="2 2"/>
|
||||
<svg
|
||||
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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<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"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditMode({ 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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
<circle cx="18" cy="6" r="1" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PreviewMode({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceMode({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
<svg
|
||||
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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
<svg
|
||||
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" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -97,17 +102,26 @@ export function Sun({ size = defaultProps.size }: IconProps) {
|
||||
export function Gitee({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z"/>
|
||||
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
<svg
|
||||
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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -115,18 +129,34 @@ 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">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -134,38 +164,74 @@ 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">
|
||||
<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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function File({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
|
||||
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4" />
|
||||
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronRight({ size = 10 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderPlus({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -173,11 +239,78 @@ 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">
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/>
|
||||
<polyline points="12 16 12 8"/>
|
||||
<polyline points="8 12 12 8 16 12"/>
|
||||
<line x1="12" y1="16" x2="12" y2="22"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<polyline points="12 16 12 8" />
|
||||
<polyline points="8 12 12 8 16 12" />
|
||||
<line x1="12" y1="16" x2="12" y2="22" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== v0.6.0: 数据备份图标 =====
|
||||
export function Download({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== v0.6.2: 多文件搜索图标 =====
|
||||
export function SearchIcon({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Upload({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -185,21 +318,39 @@ export function UploadCloud({ size = 64 }: IconProps) {
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeNew({ size = 20 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="18" x2="12" y2="12" />
|
||||
<line x1="9" y1="15" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
label?: string
|
||||
/** 是否全屏覆盖 */
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 36
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-02: 通用加载指示器组件
|
||||
*/
|
||||
export const LoadingSpinner = React.memo(function LoadingSpinner({
|
||||
size = 'medium',
|
||||
label,
|
||||
overlay = false
|
||||
}: LoadingSpinnerProps) {
|
||||
const px = sizeMap[size]
|
||||
|
||||
const spinner = (
|
||||
<div
|
||||
className={`loading-spinner loading-spinner-${size}`}
|
||||
role="status"
|
||||
aria-label={label || '加载中'}
|
||||
>
|
||||
<svg
|
||||
className="loading-spinner-svg"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label && <span className="loading-spinner-label">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!overlay) return spinner
|
||||
|
||||
return (
|
||||
<div className="loading-overlay" aria-busy="true">
|
||||
{spinner}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner'
|
||||
@@ -1 +0,0 @@
|
||||
export { LoadingSpinner } from './LoadingSpinner'
|
||||
@@ -1,16 +1,26 @@
|
||||
import React from 'react'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
filePath: string
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({
|
||||
filePath,
|
||||
onReload,
|
||||
onDismiss,
|
||||
}: ModifiedBannerProps) {
|
||||
return (
|
||||
<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>
|
||||
<span>{getFileName(filePath)} 已被外部程序修改</span>
|
||||
<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 (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseHeadings } from '../outlineUtils'
|
||||
|
||||
describe('parseHeadings', () => {
|
||||
it('should parse headings from markdown', () => {
|
||||
const headings = parseHeadings('# Title\n## Section\ncontent')
|
||||
expect(headings).toHaveLength(2)
|
||||
expect(headings[0]).toEqual({ level: 1, text: 'Title', pos: 0 })
|
||||
expect(headings[1].level).toBe(2)
|
||||
expect(headings[1].text).toBe('Section')
|
||||
})
|
||||
|
||||
it('should skip headings inside fenced code blocks', () => {
|
||||
const md = [
|
||||
'# Real Title',
|
||||
'```markdown',
|
||||
'# Fake Heading',
|
||||
'## Fake Sub',
|
||||
'```',
|
||||
'## After Code',
|
||||
].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['Real Title', 'After Code'])
|
||||
})
|
||||
|
||||
it('should skip headings inside tilde code blocks', () => {
|
||||
const md = ['# A', '~~~', '### Inside', '~~~', '## B'].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('should keep position offsets (pos) correct', () => {
|
||||
const md = 'aaa\n## B'
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings).toHaveLength(1)
|
||||
expect(headings[0].pos).toBe(4)
|
||||
})
|
||||
|
||||
it('should handle unclosed fence (rest treated as code)', () => {
|
||||
const md = ['# A', '```', '## Inside', '# Also Inside'].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('should return empty array for empty markdown', () => {
|
||||
expect(parseHeadings('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,22 +5,39 @@ export interface Heading {
|
||||
pos: number
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*
|
||||
* B9-fix: 逐行扫描并跟踪代码围栏(``` / ~~~)状态,
|
||||
* 跳过代码块内的 # 行(此前会被误判为标题,大纲点击导航到错误位置)。
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
const lines = markdown.split('\n')
|
||||
let pos = 0
|
||||
let fence: string | null = null // 当前围栏标记(``` 或 ~~~),null = 不在代码块内
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line)
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0]
|
||||
if (fence === null) {
|
||||
fence = marker
|
||||
} else if (marker === fence && fenceMatch[1].length < 4) {
|
||||
fence = null
|
||||
}
|
||||
pos += line.length + 1
|
||||
continue
|
||||
}
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
if (fence === null) {
|
||||
const m = /^(#{1,6})\s+(.+)$/.exec(line)
|
||||
if (m) {
|
||||
headings.push({ level: m[1].length, text: m[2].trim(), pos })
|
||||
}
|
||||
}
|
||||
|
||||
pos += line.length + 1
|
||||
}
|
||||
|
||||
return headings
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href: string | null = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
id="preview"
|
||||
className="markdown-body"
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import type { SearchMatch } from '../../../shared/types'
|
||||
|
||||
interface SearchPanelProps {
|
||||
onClose: () => void
|
||||
onOpenResult: (filePath: string, line: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.2: 多文件搜索面板 — 在侧边栏已打开的文件夹内递归搜索
|
||||
* md/markdown/txt 文件内容,结果按 文件+行号 展示,点击打开并定位。
|
||||
*/
|
||||
export const SearchPanel = React.memo(function SearchPanel({ onClose, onOpenResult }: SearchPanelProps) {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const [query, setQuery] = useState('')
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
const [useRegex, setUseRegex] = useState(false)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [matches, setMatches] = useState<SearchMatch[]>([])
|
||||
const [totalFiles, setTotalFiles] = useState(0)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) return
|
||||
if (!rootPath || !window.electronAPI) {
|
||||
showToast('请先在侧边栏打开一个文件夹', 'warning')
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const result = await window.electronAPI.searchInDir({
|
||||
dirPath: rootPath,
|
||||
query: q,
|
||||
caseSensitive,
|
||||
useRegex,
|
||||
})
|
||||
if (result.success) {
|
||||
setMatches(result.matches ?? [])
|
||||
setTotalFiles(result.totalFiles ?? 0)
|
||||
setTruncated(result.truncated ?? false)
|
||||
} else {
|
||||
setMatches([])
|
||||
showToast(result.error ?? '搜索失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('多文件搜索失败', error)
|
||||
showToast('搜索失败', 'error')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
},
|
||||
[rootPath, caseSensitive, useRegex],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
runSearch(query)
|
||||
},
|
||||
[query, runSearch],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="search-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="多文件搜索"
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="search-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="search-header">
|
||||
<form className="search-form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
rootPath ? `在 ${getFileName(rootPath)} 中搜索...` : '请先在侧边栏打开文件夹'
|
||||
}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
aria-label="搜索内容"
|
||||
/>
|
||||
<button className="search-run-btn" type="submit" disabled={searching || !query.trim()}>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="search-options">
|
||||
<label className="search-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caseSensitive}
|
||||
onChange={e => setCaseSensitive(e.target.checked)}
|
||||
/>
|
||||
区分大小写
|
||||
</label>
|
||||
<label className="search-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRegex}
|
||||
onChange={e => setUseRegex(e.target.checked)}
|
||||
/>
|
||||
正则表达式
|
||||
</label>
|
||||
<button className="search-close-btn" onClick={onClose} aria-label="关闭搜索">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-body">
|
||||
{!searched ? (
|
||||
<div className="search-empty">输入关键词开始搜索(Ctrl+Shift+F 再次打开)</div>
|
||||
) : searching ? (
|
||||
<div className="search-empty">搜索中...</div>
|
||||
) : matches.length === 0 ? (
|
||||
<div className="search-empty">
|
||||
无匹配结果{totalFiles > 0 ? `(已扫描 ${totalFiles} 个文件)` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="search-summary">
|
||||
{matches.length} 条匹配
|
||||
{truncated && '(结果过多,仅显示前 500 条)'} · 扫描 {totalFiles} 个文件
|
||||
</div>
|
||||
<div className="search-results" role="list" aria-label="搜索结果">
|
||||
{matches.map((m, i) => (
|
||||
<button
|
||||
key={`${m.filePath}:${m.line}:${i}`}
|
||||
className="search-result-item"
|
||||
onClick={() => onOpenResult(m.filePath, m.line)}
|
||||
title={m.filePath}
|
||||
>
|
||||
<span className="search-result-file">{getFileName(m.filePath)}</span>
|
||||
<span className="search-result-line">:{m.line}</span>
|
||||
<span className="search-result-text">{m.lineText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SearchPanel.displayName = 'SearchPanel'
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchPanel } from './SearchPanel'
|
||||
@@ -1,446 +0,0 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, memo } from 'react'
|
||||
import type { EditorView } from '@milkdown/prose/view'
|
||||
import { TextSelection } from '@milkdown/prose/state'
|
||||
import type { Node as ProseMirrorNode } from '@milkdown/prose/model'
|
||||
import { searchPluginKey, type SearchMatch, type SearchPluginState } from '../Editor/useMilkdown'
|
||||
|
||||
interface SearchReplaceProps {
|
||||
/** 获取 ProseMirror EditorView 实例 */
|
||||
getView: () => EditorView | null
|
||||
/** 关闭面板 */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 ProseMirror 文档中查找所有匹配位置
|
||||
* D6: 支持正则表达式 + 大小写敏感
|
||||
*/
|
||||
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] {
|
||||
const matches: SearchMatch[] = []
|
||||
if (!query) return matches
|
||||
|
||||
if (useRegex) {
|
||||
let regex: RegExp
|
||||
try {
|
||||
const flags = caseSensitive ? 'g' : 'gi'
|
||||
regex = new RegExp(query, flags)
|
||||
} catch {
|
||||
// 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户
|
||||
return []
|
||||
}
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return true
|
||||
const text = node.text || ''
|
||||
regex.lastIndex = 0
|
||||
let result: RegExpExecArray | null
|
||||
while ((result = regex.exec(text)) !== null) {
|
||||
matches.push({ from: pos + result.index, to: pos + result.index + result[0].length })
|
||||
if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环
|
||||
}
|
||||
return true
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
// 普通字符串匹配
|
||||
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return true
|
||||
const text = node.text || ''
|
||||
const searchText = caseSensitive ? text : text.toLowerCase()
|
||||
let offset = 0
|
||||
let idx: number
|
||||
while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
|
||||
matches.push({ from: pos + idx, to: pos + idx + query.length })
|
||||
offset = idx + 1
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到指定文档位置
|
||||
*/
|
||||
function scrollToPos(view: EditorView, pos: number) {
|
||||
try {
|
||||
const { node } = view.domAtPos(pos)
|
||||
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
} catch {
|
||||
// Position may be out of range
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 ProseMirror plugin state(触发 decorations 重绘)
|
||||
*/
|
||||
function dispatchSearchState(view: EditorView, state: SearchPluginState) {
|
||||
const tr = view.state.tr.setMeta(searchPluginKey, state)
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除搜索高亮(dispatch 空状态)
|
||||
*/
|
||||
function clearSearchDecorations(view: EditorView) {
|
||||
dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' })
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchReplace — 编辑器内搜索/替换面板
|
||||
*
|
||||
* 功能:
|
||||
* - Ctrl+F 打开搜索,Ctrl+H 打开替换
|
||||
* - 大小写敏感切换
|
||||
* - Enter 下一个,Shift+Enter 上一个
|
||||
* - 替换当前匹配 / 全部替换
|
||||
* - ESC 关闭面板
|
||||
*/
|
||||
export const SearchReplace = memo(function SearchReplace({
|
||||
getView,
|
||||
onClose
|
||||
}: SearchReplaceProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [replacement, setReplacement] = useState('')
|
||||
const [showReplace, setShowReplace] = useState(false)
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
const [useRegex, setUseRegex] = useState(false)
|
||||
const [regexError, setRegexError] = useState(false)
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
const [currentMatch, setCurrentMatch] = useState(-1)
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep latest state in refs for use in callbacks without stale closures
|
||||
const matchesRef = useRef<SearchMatch[]>([])
|
||||
const currentIndexRef = useRef(-1)
|
||||
const caseSensitiveRef = useRef(false)
|
||||
const useRegexRef = useRef(false)
|
||||
const queryRef = useRef('')
|
||||
|
||||
// Sync refs
|
||||
caseSensitiveRef.current = caseSensitive
|
||||
useRegexRef.current = useRegex
|
||||
queryRef.current = query
|
||||
|
||||
// 执行搜索
|
||||
const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => {
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
// D6: 检查正则语法
|
||||
setRegexError(false)
|
||||
if (rx && searchQuery.trim()) {
|
||||
try {
|
||||
new RegExp(searchQuery, 'g')
|
||||
} catch {
|
||||
setRegexError(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
clearSearchDecorations(view)
|
||||
matchesRef.current = []
|
||||
currentIndexRef.current = -1
|
||||
setMatchCount(0)
|
||||
setCurrentMatch(-1)
|
||||
return
|
||||
}
|
||||
|
||||
const matches = findMatches(view.state.doc, searchQuery, cs, rx)
|
||||
matchesRef.current = matches
|
||||
const newIdx = matches.length > 0 ? 0 : -1
|
||||
currentIndexRef.current = newIdx
|
||||
|
||||
setMatchCount(matches.length)
|
||||
setCurrentMatch(newIdx)
|
||||
|
||||
// Dispatch to ProseMirror plugin to render decorations
|
||||
dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery })
|
||||
|
||||
// Scroll to first match
|
||||
if (matches.length > 0) {
|
||||
scrollToPos(view, matches[0].from)
|
||||
}
|
||||
}, [getView])
|
||||
|
||||
// 搜索框输入变化
|
||||
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
doSearch(value, caseSensitiveRef.current, useRegexRef.current)
|
||||
}, [doSearch])
|
||||
|
||||
// 大小写切换
|
||||
const toggleCaseSensitive = useCallback(() => {
|
||||
const newCS = !caseSensitiveRef.current
|
||||
setCaseSensitive(newCS)
|
||||
doSearch(queryRef.current, newCS, useRegexRef.current)
|
||||
}, [doSearch])
|
||||
|
||||
// 正则切换
|
||||
const toggleRegex = useCallback(() => {
|
||||
const newRx = !useRegexRef.current
|
||||
setUseRegex(newRx)
|
||||
doSearch(queryRef.current, caseSensitiveRef.current, newRx)
|
||||
}, [doSearch])
|
||||
|
||||
// 导航到下一个/上一个匹配
|
||||
const navigateMatch = useCallback((direction: 1 | -1) => {
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
const matches = matchesRef.current
|
||||
if (matches.length === 0) return
|
||||
|
||||
let next = currentIndexRef.current + direction
|
||||
if (next >= matches.length) next = 0
|
||||
if (next < 0) next = matches.length - 1
|
||||
|
||||
currentIndexRef.current = next
|
||||
setCurrentMatch(next)
|
||||
|
||||
// Update plugin state to move the active highlight
|
||||
dispatchSearchState(view, {
|
||||
matches,
|
||||
currentIndex: next,
|
||||
query: queryRef.current
|
||||
})
|
||||
|
||||
// Scroll to match and select it
|
||||
const match = matches[next]
|
||||
try {
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, match.from, match.to)
|
||||
)
|
||||
view.dispatch(tr)
|
||||
} catch {
|
||||
// Selection range might be invalid
|
||||
}
|
||||
scrollToPos(view, match.from)
|
||||
}, [getView])
|
||||
|
||||
// 替换当前匹配
|
||||
const replaceCurrent = useCallback(() => {
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
const matches = matchesRef.current
|
||||
const idx = currentIndexRef.current
|
||||
if (idx < 0 || idx >= matches.length) return
|
||||
|
||||
const match = matches[idx]
|
||||
|
||||
// Replace text in ProseMirror document
|
||||
const tr = view.state.tr.insertText(replacement, match.from, match.to)
|
||||
view.dispatch(tr)
|
||||
|
||||
// Re-search after replacement (document changed)
|
||||
// Use requestAnimationFrame to let ProseMirror process the transaction
|
||||
requestAnimationFrame(() => {
|
||||
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
||||
})
|
||||
}, [getView, replacement, doSearch])
|
||||
|
||||
// 全部替换
|
||||
const replaceAll = useCallback(() => {
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
// 重新搜索以获取匹配的最新位置
|
||||
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
||||
if (freshMatches.length === 0) return
|
||||
|
||||
// 从后往前替换以保持位置正确
|
||||
const tr = view.state.tr
|
||||
for (let i = freshMatches.length - 1; i >= 0; i--) {
|
||||
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
|
||||
}
|
||||
view.dispatch(tr)
|
||||
|
||||
// 更新ref
|
||||
matchesRef.current = []
|
||||
|
||||
// 替换后重新搜索
|
||||
requestAnimationFrame(() => {
|
||||
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
||||
})
|
||||
}, [getView, replacement, doSearch])
|
||||
|
||||
// 键盘事件
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
const view = getView()
|
||||
if (view) clearSearchDecorations(view)
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
navigateMatch(-1)
|
||||
} else {
|
||||
navigateMatch(1)
|
||||
}
|
||||
}
|
||||
}, [onClose, navigateMatch, getView])
|
||||
|
||||
// 全局 Ctrl+F / Ctrl+H 拦截
|
||||
useEffect(() => {
|
||||
const handleGlobalKey = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
setShowReplace(false)
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
if (isCtrl && e.key === 'h') {
|
||||
e.preventDefault()
|
||||
setShowReplace(true)
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleGlobalKey)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKey)
|
||||
}, [])
|
||||
|
||||
// 打开时聚焦搜索框
|
||||
useEffect(() => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
// 清理高亮(组件卸载时)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const view = getView()
|
||||
if (view) clearSearchDecorations(view)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="search-replace-panel"
|
||||
role="search"
|
||||
aria-label="搜索和替换"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="search-row">
|
||||
<div className="search-input-group">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="搜索..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
aria-label="搜索文本"
|
||||
/>
|
||||
<span className="search-count" aria-live="polite">
|
||||
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={`search-btn ${caseSensitive ? 'active' : ''}`}
|
||||
onClick={toggleCaseSensitive}
|
||||
title="区分大小写 (点击切换)"
|
||||
aria-label="区分大小写"
|
||||
aria-pressed={caseSensitive}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className={`search-btn ${useRegex ? 'active' : ''}`}
|
||||
onClick={toggleRegex}
|
||||
title="使用正则表达式 (点击切换)"
|
||||
aria-label="正则表达式"
|
||||
aria-pressed={useRegex}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => navigateMatch(-1)}
|
||||
title="上一个 (Shift+Enter)"
|
||||
aria-label="上一个匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => navigateMatch(1)}
|
||||
title="下一个 (Enter)"
|
||||
aria-label="下一个匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => setShowReplace(!showReplace)}
|
||||
title="切换替换模式 (Ctrl+H)"
|
||||
aria-label="替换模式"
|
||||
aria-expanded={showReplace}
|
||||
>
|
||||
⇄
|
||||
</button>
|
||||
<button
|
||||
className="search-btn search-close"
|
||||
onClick={() => {
|
||||
const view = getView()
|
||||
if (view) clearSearchDecorations(view)
|
||||
onClose()
|
||||
}}
|
||||
title="关闭 (Esc)"
|
||||
aria-label="关闭搜索"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{showReplace && (
|
||||
<div className="search-row">
|
||||
<div className="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="替换..."
|
||||
value={replacement}
|
||||
onChange={e => setReplacement(e.target.value)}
|
||||
aria-label="替换文本"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="search-btn replace-btn"
|
||||
onClick={replaceCurrent}
|
||||
title="替换当前"
|
||||
aria-label="替换当前匹配"
|
||||
disabled={currentMatch < 0}
|
||||
>
|
||||
替换
|
||||
</button>
|
||||
<button
|
||||
className="search-btn replace-btn"
|
||||
onClick={replaceAll}
|
||||
title="全部替换"
|
||||
aria-label="替换全部匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SearchReplace.displayName = 'SearchReplace'
|
||||
@@ -1 +0,0 @@
|
||||
export { SearchReplace } from './SearchReplace'
|
||||
@@ -1,185 +1,217 @@
|
||||
import React, { useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { useActiveHeading } from '../../hooks/useActiveHeading'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import type { Heading } from '../OutlinePanel'
|
||||
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
||||
import { TextSelection } from '@milkdown/prose/state'
|
||||
|
||||
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
// Parse headings from active tab content
|
||||
const headings = useMemo(() => {
|
||||
if (!activeTab?.content) return []
|
||||
return parseHeadings(activeTab.content)
|
||||
}, [activeTab?.content])
|
||||
|
||||
// D4: 追踪预览面板中的活跃标题
|
||||
const previewRef = useRef<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
// 仅在预览模式时获取 preview DOM 节点
|
||||
if (viewMode === 'preview') {
|
||||
previewRef.current = document.getElementById('preview')
|
||||
} else {
|
||||
previewRef.current = null
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
const activeHeadingIndex = useActiveHeading(
|
||||
viewMode === 'preview' ? previewRef : { current: null },
|
||||
headings
|
||||
)
|
||||
|
||||
// Navigate to heading in ProseMirror document tree
|
||||
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
|
||||
const view = getEditorView()
|
||||
if (!view) return
|
||||
try {
|
||||
let foundPos: number | null = null
|
||||
let currentIndex = 0
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (foundPos !== null) return false
|
||||
if (node.type.name === 'heading' && node.attrs.level === heading.level) {
|
||||
if (node.textContent.trim() === heading.text) {
|
||||
if (currentIndex === index) {
|
||||
foundPos = pos
|
||||
return false
|
||||
}
|
||||
currentIndex++
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (foundPos === null) return
|
||||
|
||||
const tr = view.state.tr
|
||||
.setSelection(TextSelection.create(view.state.doc, foundPos, foundPos))
|
||||
.scrollIntoView()
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
} catch {
|
||||
// Position may be invalid if content has changed
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab, setLoading])
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
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) } }}
|
||||
>
|
||||
<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>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-outline-section">
|
||||
<OutlinePanel
|
||||
headings={headings}
|
||||
onNavigate={handleHeadingNavigate}
|
||||
activeHeadingIndex={activeHeadingIndex}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
import React, { useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { useActiveHeading } from '../../hooks/useActiveHeading'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import type { Heading } from '../OutlinePanel'
|
||||
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
|
||||
|
||||
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
// Parse headings from active tab content
|
||||
const headings = useMemo(() => {
|
||||
if (!activeTab?.content) return []
|
||||
return parseHeadings(activeTab.content)
|
||||
}, [activeTab?.content])
|
||||
|
||||
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview)
|
||||
const previewRef = useRef<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (viewMode === 'preview') {
|
||||
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
|
||||
} else {
|
||||
previewRef.current = null
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
const activeHeadingIndex = useActiveHeading(
|
||||
viewMode === 'preview' ? previewRef : { current: null },
|
||||
headings,
|
||||
)
|
||||
|
||||
// Navigate to heading in MetonaEditor
|
||||
// v0.6.0: 使用官方 API(scrollToLine + setCursorPosition)替代 DOM hack
|
||||
const handleHeadingNavigate = useCallback((heading: Heading) => {
|
||||
const editor = getMetonaEditor()
|
||||
if (!editor) return
|
||||
|
||||
try {
|
||||
// 获取当前内容,查找标题文本在源代码中的位置
|
||||
const content = editor.getValue()
|
||||
const headingPattern = new RegExp(`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm')
|
||||
const match = headingPattern.exec(content)
|
||||
if (!match) return
|
||||
|
||||
// 按行号导航 — 官方 API 处理滚动与光标
|
||||
const line = content.substring(0, match.index).split('\n').length
|
||||
editor.scrollToLine(line)
|
||||
editor.setCursorPosition(line, 0)
|
||||
editor.focus()
|
||||
} catch {
|
||||
// 导航失败,静默忽略
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
return
|
||||
}
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
} else {
|
||||
// B13-fix: 读取失败(文件被删除/权限问题)显式提示
|
||||
showToast(`无法打开 "${path}": ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('文件树打开文件失败', error)
|
||||
showToast(`无法打开 "${path}"`, 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
},
|
||||
[tabs, switchToTab, createTab, setLoading],
|
||||
)
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{independentFiles.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-outline-section">
|
||||
<OutlinePanel
|
||||
headings={headings}
|
||||
onNavigate={handleHeadingNavigate}
|
||||
activeHeadingIndex={activeHeadingIndex}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
interface SourceEditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
|
||||
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
|
||||
*
|
||||
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
|
||||
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
|
||||
*/
|
||||
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
|
||||
const pendingSelectionRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingSelectionRef.current === null) return
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const pos = pendingSelectionRef.current
|
||||
// microtask:在 React 完成 DOM 更新后恢复光标
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = pos
|
||||
textarea.selectionEnd = pos
|
||||
})
|
||||
pendingSelectionRef.current = null
|
||||
}, [activeTab?.content])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!activeTabId) return
|
||||
updateTabContent(activeTabId, e.target.value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || !activeTabId) return
|
||||
|
||||
// Tab 插入两个空格而非跳转焦点
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
||||
const newPos = start + 2
|
||||
updateTabContent(activeTabId, newValue)
|
||||
setModified(activeTabId, true)
|
||||
pendingSelectionRef.current = newPos
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+B: 粗体
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const selected = value.substring(start, end)
|
||||
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
|
||||
updateTabContent(activeTabId, newValue)
|
||||
setModified(activeTabId, true)
|
||||
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+I: 斜体
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const value = textarea.value
|
||||
const selected = value.substring(start, end)
|
||||
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
|
||||
updateTabContent(activeTabId, newValue)
|
||||
setModified(activeTabId, true)
|
||||
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
|
||||
return
|
||||
}
|
||||
}, [activeTabId, updateTabContent, setModified])
|
||||
|
||||
return (
|
||||
<div className={`source-editor-wrapper${darkMode ? ' source-editor-dark' : ''}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="source-editor-textarea"
|
||||
value={activeTab?.content ?? ''}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
aria-label="Markdown 源码编辑器"
|
||||
placeholder="在此输入 Markdown 内容..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SourceEditor.displayName = 'SourceEditor'
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useStatusBarStore, type StatusBarItem } from '../../stores/statusBarStore'
|
||||
|
||||
/**
|
||||
* StatusBar — 纯渲染层
|
||||
*
|
||||
* 不包含任何业务逻辑。所有状态栏项由各功能模块通过
|
||||
* useStatusBarStore.register() 注入,本组件按 alignment + priority 排序渲染。
|
||||
* 每个 item.Component 是独立 React 组件,可自由使用 hooks。
|
||||
*/
|
||||
|
||||
export const StatusBar = React.memo(function StatusBar() {
|
||||
const items = useStatusBarStore(s => s.items)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = Object.values(items)
|
||||
if (list.length === 0) return { left: [] as StatusBarItem[], right: [] as StatusBarItem[] }
|
||||
const byAlignment = (a: 'left' | 'right') =>
|
||||
list.filter(i => i.alignment === a).sort((x, y) => x.priority - y.priority)
|
||||
return { left: byAlignment('left'), right: byAlignment('right') }
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div id="statusbar" role="status" aria-live="polite">
|
||||
<div className="status-left">
|
||||
{sorted.left.map(({ id, Component }) => (
|
||||
<Component key={id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="status-right">
|
||||
{sorted.right.map(({ id, Component }, i) => (
|
||||
<React.Fragment key={id}>
|
||||
{i > 0 && <span className="status-divider">|</span>}
|
||||
<Component />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
StatusBar.displayName = 'StatusBar'
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { useDocStats } from '../../hooks/useDocStats'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
/** 文件信息 — 文件名或"就绪" */
|
||||
export function FileInfoItem() {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
return (
|
||||
<span id="status-text">
|
||||
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 加载指示器 — 打开文件/加载目录/渲染中 */
|
||||
export function LoadingItem() {
|
||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
||||
const hasLoading = Object.values(loadingStates).some(Boolean)
|
||||
if (!hasLoading) return null
|
||||
|
||||
const label = loadingStates['file-open']
|
||||
? '正在打开文件...'
|
||||
: loadingStates['dir-load']
|
||||
? '正在加载目录...'
|
||||
: loadingStates['markdown-render']
|
||||
? '正在渲染...'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<span className="status-loading" aria-busy="true">
|
||||
<LoadingSpinner size="small" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文档统计 — 行数 / 词数 / 字符数 */
|
||||
export function DocStatsItem() {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const stats = useDocStats(activeTab?.content)
|
||||
if (!activeTab) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="status-stat" title="行数" aria-label={`${stats.lines} 行`}>{stats.lines} 行</span>
|
||||
<span className="status-divider">|</span>
|
||||
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>{stats.words} 词</span>
|
||||
<span className="status-divider">|</span>
|
||||
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>{stats.chars} 字符</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 静态项 — 编码 */
|
||||
export function EncodingItem() {
|
||||
return <span id="status-encoding">UTF-8</span>
|
||||
}
|
||||
|
||||
/** 静态项 — 语言 */
|
||||
export function LangItem() {
|
||||
return <span id="status-lang">Markdown</span>
|
||||
}
|
||||
@@ -1,304 +1,306 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
const moveTab = useTabStore(s => s.moveTab)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
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(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
// D1: 拖拽排序事件处理
|
||||
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
dragTabIdRef.current = tabId
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', tabId)
|
||||
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
|
||||
el?.classList.add('dragging')
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverIndex(index)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(null)
|
||||
const fromId = dragTabIdRef.current
|
||||
if (fromId) {
|
||||
moveTab(fromId, toIndex)
|
||||
}
|
||||
dragTabIdRef.current = null
|
||||
// 清理 dragging 类
|
||||
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
||||
}, [moveTab])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
||||
dragTabIdRef.current = null
|
||||
}, [])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
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)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { MeToast } from '../../lib/toast'
|
||||
import { Close, Plus } from '../Icons'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
const moveTab = useTabStore(s => s.moveTab)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
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 scrollToActiveTab = useCallback(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= listRect.left - tabRect.left + 20
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += tabRect.right - listRect.right + 20
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
|
||||
title: '关闭标签',
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
},
|
||||
[tabs, closeTab],
|
||||
)
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
|
||||
title: '关闭标签',
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified
|
||||
.map(t => (t.filePath ? getFileName(t.filePath) : '未命名'))
|
||||
.join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭其他标签',
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭全部标签',
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
|
||||
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
|
||||
title: '关闭右侧标签',
|
||||
confirmText: '关闭',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight])
|
||||
|
||||
// D1: 拖拽排序事件处理
|
||||
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
dragTabIdRef.current = tabId
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', tabId)
|
||||
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
|
||||
el?.classList.add('dragging')
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverIndex(index)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(null)
|
||||
const fromId = dragTabIdRef.current
|
||||
if (fromId) {
|
||||
moveTab(fromId, toIndex)
|
||||
}
|
||||
dragTabIdRef.current = null
|
||||
// 清理 dragging 类
|
||||
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
||||
},
|
||||
[moveTab],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragOverIndex(null)
|
||||
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
||||
dragTabIdRef.current = null
|
||||
}, [])
|
||||
|
||||
const hasRightTabs =
|
||||
menu.visible &&
|
||||
(() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
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)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={e => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={e => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
|
||||
@@ -1,66 +1,188 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons'
|
||||
import type { ViewMode } from '../../types/settings'
|
||||
import React, { useCallback } from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload, SearchIcon } from '../Icons'
|
||||
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
|
||||
import { backupRepository } from '../../db/backupRepository'
|
||||
import { closeDatabase } from '../../db/schema'
|
||||
import { flushSaveToDB } from '../../stores/tabStore'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
themeMode: ThemeMode
|
||||
onCycleTheme: () => void
|
||||
onShowAbout: () => void
|
||||
onShowSearch: () => void
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
onToggleAutoSave: () => void
|
||||
}
|
||||
|
||||
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
||||
const THEME_LABELS: Record<ThemeMode, string> = {
|
||||
light: '亮色',
|
||||
dark: '暗色',
|
||||
warm: '暖色',
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、数据备份、关于。
|
||||
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
||||
*/
|
||||
export const Toolbar = React.memo(function Toolbar({
|
||||
onOpen,
|
||||
onSave,
|
||||
themeMode,
|
||||
onCycleTheme,
|
||||
onShowAbout,
|
||||
onShowSearch,
|
||||
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 事务导入)
|
||||
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')
|
||||
// B7-fix: 先 flush 快照并干净关闭数据库,确保导入数据落盘后再重载
|
||||
// (此前固定 800ms 后 reload,OPFS 写未完成会丢导入数据)
|
||||
try {
|
||||
await flushSaveToDB()
|
||||
await closeDatabase()
|
||||
} catch {
|
||||
/* flush/close 失败仍继续 reload,由自愈流程兜底 */
|
||||
}
|
||||
window.location.reload()
|
||||
} else {
|
||||
showToast('恢复备份失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('解析备份文件失败', error)
|
||||
showToast('备份文件格式无效', 'error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
|
||||
<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>
|
||||
<div className="toolbar-divider" role="separator" />
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('editor')}
|
||||
title="WYSIWYG 编辑 (Ctrl+1)"
|
||||
aria-label="WYSIWYG 编辑模式"
|
||||
aria-pressed={viewMode === 'editor'}
|
||||
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||
onClick={onToggleAutoSave}
|
||||
title={
|
||||
isAutoSaving
|
||||
? '正在自动保存...'
|
||||
: autoSaveEnabled
|
||||
? '自动保存已开启 — 点击关闭'
|
||||
: '自动保存已关闭 — 点击开启'
|
||||
}
|
||||
aria-label={
|
||||
isAutoSaving ? '正在自动保存' : autoSaveEnabled ? '关闭自动保存' : '开启自动保存'
|
||||
}
|
||||
>
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'source' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('source')}
|
||||
title="源码编辑 (Ctrl+3)"
|
||||
aria-label="源码编辑模式"
|
||||
aria-pressed={viewMode === 'source'}
|
||||
>
|
||||
<SourceMode size={18} />
|
||||
<span>源码</span>
|
||||
</button>
|
||||
<button
|
||||
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
title="预览 (Ctrl+2)"
|
||||
aria-label="预览模式"
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
>
|
||||
<PreviewMode size={18} />
|
||||
<span>预览</span>
|
||||
<span>{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动' : '手动'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right" role="group" aria-label="设置">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
|
||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
<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={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleExport}
|
||||
title="导出数据备份"
|
||||
aria-label="导出数据备份"
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={handleImport}
|
||||
title="导入数据备份"
|
||||
aria-label="导入数据备份"
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onShowSearch}
|
||||
title="多文件搜索 (Ctrl+Shift+F)"
|
||||
aria-label="多文件搜索"
|
||||
>
|
||||
<SearchIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onCycleTheme}
|
||||
title={`当前 ${nextLabel} — 点击切换`}
|
||||
aria-label={`当前${nextLabel}主题,点击切换`}
|
||||
>
|
||||
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onShowAbout}
|
||||
title="关于"
|
||||
aria-label="关于 MarkLite"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { recentFilesRepository } from '../recentFilesRepository'
|
||||
|
||||
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端,jsdom 无 OPFS →
|
||||
// schema.ts 自动回退 AriaEngine memory 后端(见 DISK_ENGINE 探测)。
|
||||
describe('recentFilesRepository (真实 AriaEngine + memory 后端)', () => {
|
||||
it('should add and read recent files', async () => {
|
||||
await recentFilesRepository.add('/test/a.md')
|
||||
const files = await recentFilesRepository.getAll(10)
|
||||
expect(files).toContain('/test/a.md')
|
||||
})
|
||||
|
||||
it('should update lastOpened on re-add', async () => {
|
||||
await recentFilesRepository.add('/test/b.md')
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
await recentFilesRepository.add('/test/b.md')
|
||||
const files = await recentFilesRepository.getAll(10)
|
||||
expect(files[0]).toBe('/test/b.md')
|
||||
})
|
||||
|
||||
it('should enforce 50 item limit', async () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
await recentFilesRepository.add(`/test/f${String(i).padStart(2, '0')}.md`)
|
||||
}
|
||||
const files = await recentFilesRepository.getAll(60)
|
||||
expect(files.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('should remove a file', async () => {
|
||||
await recentFilesRepository.add('/test/remove-me.md')
|
||||
await recentFilesRepository.remove('/test/remove-me.md')
|
||||
const files = await recentFilesRepository.getAll(60)
|
||||
expect(files).not.toContain('/test/remove-me.md')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getDb } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/** v0.6.0: 备份数据格式 — { 表名: 行[] }(与 db.exportAll() 一致) */
|
||||
export type BackupData = Record<string, Record<string, unknown>[]>
|
||||
|
||||
const BACKUP_TABLES = ['tabSnapshots', 'settings', 'recentFiles', 'activeTab']
|
||||
|
||||
/**
|
||||
* v0.6.0: 数据备份/恢复 — 基于 sqlark 的 exportAll / importTable。
|
||||
*/
|
||||
export const backupRepository = {
|
||||
async exportAll(): Promise<BackupData | null> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
return (await db.exportAll()) as BackupData
|
||||
} catch (error) {
|
||||
logError('导出备份失败', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async importAll(data: BackupData): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
// B8-fix: 清空 + 导入在单个事务内完成(此前 clear 事务与逐表 importTable
|
||||
// 分离,中途失败会留下半导入状态)
|
||||
await db.transaction(async trx => {
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
await trx.table(tableName).clear()
|
||||
}
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
const rows = data[tableName]
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
await trx.table(tableName).insertMany(rows as Record<string, unknown>[])
|
||||
}
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logError('导入备份失败', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
import { db, type RecentFile } from './schema'
|
||||
import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type RecentFile } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||
if (existing) {
|
||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
||||
const db = await getDb()
|
||||
const tbl = db.table('recentFiles') as Table<RecentFile>
|
||||
const existing = await tbl.select().where({ filePath }).execute()
|
||||
if (existing.length > 0) {
|
||||
await tbl.update({ lastOpened: Date.now() }).where({ filePath }).execute()
|
||||
} else {
|
||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||
await tbl.insert({ filePath, lastOpened: Date.now() })
|
||||
}
|
||||
// L-06: 清理超过 50 条的旧记录
|
||||
const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||
const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
|
||||
if (all.length > 50) {
|
||||
const toDelete = all.slice(50)
|
||||
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
||||
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
|
||||
await tbl
|
||||
.delete()
|
||||
.where({ filePath: { $in: toDelete } })
|
||||
.execute()
|
||||
}
|
||||
await emitChange(db, 'update')
|
||||
} catch (error) {
|
||||
logError('添加最近文件失败', error)
|
||||
}
|
||||
@@ -23,11 +43,13 @@ export const recentFilesRepository = {
|
||||
|
||||
async getAll(limit: number = 20): Promise<string[]> {
|
||||
try {
|
||||
const files: RecentFile[] = await db.recentFiles
|
||||
.orderBy('lastOpened')
|
||||
.reverse()
|
||||
const db = await getDb()
|
||||
const files = (await db
|
||||
.table('recentFiles')
|
||||
.select(['filePath'])
|
||||
.orderBy('lastOpened', 'desc')
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
.execute()) as RecentFile[]
|
||||
return files.map((f: RecentFile) => f.filePath)
|
||||
} catch (error) {
|
||||
logError('读取最近文件失败', error)
|
||||
@@ -37,7 +59,9 @@ export const recentFilesRepository = {
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
try {
|
||||
await db.recentFiles.where('filePath').equals(filePath).delete()
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').delete().where({ filePath }).execute()
|
||||
await emitChange(db, 'delete')
|
||||
} catch (error) {
|
||||
logError('删除最近文件失败', error)
|
||||
}
|
||||
@@ -45,9 +69,11 @@ export const recentFilesRepository = {
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await db.recentFiles.clear()
|
||||
const db = await getDb()
|
||||
await db.table('recentFiles').clear()
|
||||
await emitChange(db, 'delete')
|
||||
} catch (error) {
|
||||
logError('清空最近文件失败', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+275
-19
@@ -1,6 +1,12 @@
|
||||
import Dexie, { type EntityTable } from 'dexie'
|
||||
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { MeToast } from '../lib/toast'
|
||||
|
||||
export interface TabSnapshot {
|
||||
// 注意: 使用 type 别名而非 interface —
|
||||
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>,
|
||||
// type 别名具备隐式索引签名,interface 没有(TS 索引签名规则)
|
||||
|
||||
export type TabSnapshot = {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
@@ -11,37 +17,287 @@ export interface TabSnapshot {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ActiveTabRecord {
|
||||
export type ActiveTabRecord = {
|
||||
id: string // 固定为 'current'
|
||||
activeTabId: string | null
|
||||
}
|
||||
|
||||
export interface SettingsRecord {
|
||||
export type SettingsRecord = {
|
||||
id: string // 固定为 'default'
|
||||
darkMode: boolean
|
||||
themeMode: 'light' | 'dark' | 'warm'
|
||||
viewMode: 'editor' | 'preview' | 'source'
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export interface RecentFile {
|
||||
id?: number
|
||||
export type RecentFile = {
|
||||
// v0.5.0: sqlark 无自增主键,改用 filePath 作主键(业务上天然唯一)
|
||||
filePath: string
|
||||
lastOpened: number
|
||||
}
|
||||
|
||||
const db = new Dexie('MarkLite') as Dexie & {
|
||||
tabSnapshots: EntityTable<TabSnapshot, 'id'>
|
||||
settings: EntityTable<SettingsRecord, 'id'>
|
||||
recentFiles: EntityTable<RecentFile, 'id'>
|
||||
activeTab: EntityTable<ActiveTabRecord, 'id'>
|
||||
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离
|
||||
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端(v0.6.0 起),存储后端改用 KVStore
|
||||
// 引擎(内存索引 + 快照/日志,落盘于 OPFS 目录 DB_NAME)。
|
||||
// 不再考虑向下兼容:启动时直接删除旧 IndexedDB 库(见 deleteLegacyDatabases)。
|
||||
const DB_NAME = 'MarkLiteV2'
|
||||
|
||||
// v0.6.2: OPFS 可用性探测 — Electron/Chromium 提供 navigator.storage.getDirectory;
|
||||
// jsdom 等测试环境无 OPFS,回退 AriaEngine memory 后端(测试数据不持久化)。
|
||||
// kv 后端的 KVStore 底层 medium 同样基于 OPFS。
|
||||
const DISK_ENGINE: 'kv' | 'memory' =
|
||||
typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function'
|
||||
? 'kv'
|
||||
: 'memory'
|
||||
|
||||
/**
|
||||
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
|
||||
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
|
||||
* 使用标准配置 version: 1 即可,不再需要手动解析 IDB 版本号。
|
||||
* 未来 schema 变更: 在 createDatabase 的幂等建表段追加增量变更逻辑。
|
||||
*/
|
||||
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
filePath: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
scrollTop: { type: 'number' },
|
||||
selectionStart: { type: 'number' },
|
||||
selectionEnd: { type: 'number' },
|
||||
isModified: { type: 'boolean' },
|
||||
updatedAt: { type: 'number', index: true },
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
tabSnapshots: 'id, filePath, updatedAt',
|
||||
settings: 'id',
|
||||
recentFiles: '++id, filePath, lastOpened',
|
||||
activeTab: 'id'
|
||||
})
|
||||
const SETTINGS_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
themeMode: { type: 'string' },
|
||||
viewMode: { type: 'string' },
|
||||
sidebarCollapsed: { type: 'boolean' },
|
||||
sidebarWidth: { type: 'number' },
|
||||
}
|
||||
|
||||
export { db }
|
||||
const RECENT_FILES_COLUMNS: Record<string, ColumnDef> = {
|
||||
filePath: { type: 'string', primaryKey: true },
|
||||
lastOpened: { type: 'number', index: true },
|
||||
}
|
||||
|
||||
const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
activeTabId: { type: 'string' },
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.0: 数据损坏自愈 —
|
||||
* 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。
|
||||
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
|
||||
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
|
||||
*
|
||||
* 注意:自愈(删库/重载)只允许在页面生命周期内首次初始化时触发;
|
||||
* 运行中(如应用关闭流程的 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()
|
||||
|
||||
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
|
||||
|
||||
/**
|
||||
* 删除损坏数据库。
|
||||
* v0.6.2: sqlark 0.7.4 的 KVStore 后端(KVStoreEngine)底层 medium 为 OPFSBackend,
|
||||
* 以库名(DB_NAME)为目录名存储在 OPFS 根目录下,删除 = 递归移除该目录。
|
||||
*/
|
||||
function deleteStorageDatabase(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
try {
|
||||
if (!navigator.storage?.getDirectory) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
navigator.storage
|
||||
.getDirectory()
|
||||
.then(root => root.removeEntry(DB_NAME, { recursive: true }))
|
||||
.then(() => resolve())
|
||||
.catch(() => resolve())
|
||||
} catch {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.2: 删除旧版本遗留的 IndexedDB 库 — 不考虑向下兼容。
|
||||
* 覆盖旧 sqlark(0.4.x,库名 aria-MarkLiteV2)与更早 Dexie(MarkLite)。
|
||||
* 枚举 IndexedDB 中所有含 MarkLite 的库删除(失败静默,不阻塞启动)。
|
||||
*/
|
||||
function deleteLegacyIndexedDB(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
const finish = () => resolve()
|
||||
try {
|
||||
if (typeof indexedDB === 'undefined' || typeof indexedDB.deleteDatabase !== 'function') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const candidates: string[] = ['aria-MarkLiteV2', 'MarkLiteV2', 'MarkLite']
|
||||
const deleteOne = (name: string) =>
|
||||
new Promise<void>(r => {
|
||||
try {
|
||||
const req = indexedDB.deleteDatabase(name)
|
||||
req.onsuccess = () => r()
|
||||
req.onerror = () => r()
|
||||
req.onblocked = () => r()
|
||||
} catch {
|
||||
r()
|
||||
}
|
||||
})
|
||||
// 优先枚举全部 IDB 库(Chromium 支持),找不到 API 则回退固定名单
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
indexedDB
|
||||
.databases()
|
||||
.then(dbs => {
|
||||
const names = dbs
|
||||
.map(d => d.name ?? '')
|
||||
.filter(n => n.includes('MarkLite') && !candidates.includes(n))
|
||||
return Promise.all([...candidates, ...names].map(deleteOne))
|
||||
})
|
||||
.then(() => finish())
|
||||
.catch(() => Promise.all(candidates.map(deleteOne)).then(() => finish()))
|
||||
} else {
|
||||
Promise.all(candidates.map(deleteOne)).then(() => finish())
|
||||
}
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 自愈入口:存在重置标记时删除损坏库(保留标记,create 成功后才清除) */
|
||||
async function resetCorruptDatabaseIfNeeded(): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') return
|
||||
await deleteStorageDatabase()
|
||||
try {
|
||||
MeToast?.info('数据库已重置(原数据损坏,无法恢复)')
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
} catch {
|
||||
/* 重置失败不阻塞启动 */
|
||||
}
|
||||
}
|
||||
|
||||
async function createDatabase(): Promise<MetonaSqlark> {
|
||||
const db = await create({
|
||||
name: DB_NAME,
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||
// v0.6.2: sqlark 0.7.4 DiskEngine 移除 indexeddb,选用 kv(自研 KVStore 后端:
|
||||
// 内存索引 + 快照/日志,OPFS 落盘)。无 OPFS 环境(jsdom 测试)回退 memory。
|
||||
diskEngine: DISK_ENGINE, // 底层存储后端(opfs | kv | memory)
|
||||
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
|
||||
onError: (err: Error) => logError('数据库错误', err),
|
||||
})
|
||||
|
||||
// 幂等建表(getTableNames 基于持久化 schema,重启后不会重复建表)
|
||||
const tables = await db.getTableNames()
|
||||
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()
|
||||
// v0.6.2: 清理旧版本 IndexedDB 库(不向下兼容,静默删除)
|
||||
await deleteLegacyIndexedDB()
|
||||
try {
|
||||
const db = await createDatabase()
|
||||
// 创建成功:清除重置标记(自愈完成)
|
||||
try {
|
||||
localStorage.removeItem(RESET_PENDING_KEY)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
return db
|
||||
} catch (err) {
|
||||
logError('数据库打开失败', err)
|
||||
// 启动早期失败:标记 + 重载页面(干净环境删除损坏库重建)— 仅此一次
|
||||
try {
|
||||
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
|
||||
localStorage.setItem(RESET_PENDING_KEY, '1')
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// 运行中(自愈已尝试过):不再删库/重载,直接尝试创建,失败抛给调用方
|
||||
return createDatabase()
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.1: 关闭数据库 — 应用退出前调用,确保 WAL/SSTable 干净落盘,
|
||||
* 避免渲染进程被销毁时 flush 中断留下半写文件导致下次启动损坏。
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
isClosing = true
|
||||
const current = dbPromise
|
||||
dbPromise = null
|
||||
if (!current) return
|
||||
try {
|
||||
const db = await current.catch(() => null)
|
||||
if (db) await db.close()
|
||||
} catch (err) {
|
||||
logError('关闭数据库失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Promise<MetonaSqlark> {
|
||||
if (!dbPromise) {
|
||||
if (isClosing) {
|
||||
// 关闭流程中禁止重开(返回 rejected Promise,调用方 catch 吞掉)
|
||||
return Promise.reject(new Error('数据库正在关闭'))
|
||||
}
|
||||
// 串行化初始化:失败后的重试排队执行,避免并发 createDatabase
|
||||
initChain = initChain.catch(() => {
|
||||
/* 前序失败不影响后续 */
|
||||
})
|
||||
dbPromise = initChain
|
||||
.then(() => initDb())
|
||||
.catch(err => {
|
||||
// v0.6.1: 初始化失败不再静默 — 记录日志并提示用户(否则所有数据持久化静默失效)
|
||||
logError('数据库初始化失败', err)
|
||||
if (!initFailedNotified) {
|
||||
initFailedNotified = true
|
||||
try {
|
||||
const code = (err as { code?: string })?.code ?? 'UNKNOWN'
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
// ARIA_OPEN_ERROR 等包装错误的具体原因在 details 里(原始异常)
|
||||
const details = (err as { details?: { message?: string } })?.details?.message
|
||||
const detailText = details ? ` | 详情: ${details}` : ''
|
||||
setTimeout(() => {
|
||||
MeToast?.error(`数据库初始化失败(${code}):${msg}${detailText}`)
|
||||
}, 0)
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
throw err
|
||||
})
|
||||
// 初始化失败时允许下次重试
|
||||
dbPromise.catch(() => {
|
||||
dbPromise = null
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
@@ -1,34 +1,53 @@
|
||||
import { db, type SettingsRecord } from './schema'
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type SettingsRecord } from './schema'
|
||||
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
/**
|
||||
* B11-fix: 无记录时返回 null(首次启动)——
|
||||
* 此前永远返回完整 DEFAULT_SETTINGS,导致 useSettingsInit 中
|
||||
* "跟随系统主题偏好"分支成为死代码。
|
||||
*/
|
||||
async load(): Promise<Settings | null> {
|
||||
try {
|
||||
const record: SettingsRecord | undefined = await db.settings.get('default')
|
||||
const db = await getDb()
|
||||
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
|
||||
const record = rows[0] as SettingsRecord | undefined
|
||||
if (record) {
|
||||
return {
|
||||
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
||||
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,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logError('加载设置失败', error)
|
||||
return null
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
},
|
||||
|
||||
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
||||
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
|
||||
// M-04: 添加错误处理与日志
|
||||
async save(partial: Partial<Settings>): Promise<void> {
|
||||
try {
|
||||
const current: Settings = await this.load()
|
||||
const db = await getDb()
|
||||
const current: Settings = (await this.load()) ?? DEFAULT_SETTINGS
|
||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||
await db.settings.put(merged)
|
||||
const tbl = db.table('settings') as Table<SettingsRecord>
|
||||
const rows = await tbl.select().where({ id: 'default' }).execute()
|
||||
if (rows.length > 0) {
|
||||
await tbl
|
||||
.update({ ...merged })
|
||||
.where({ id: 'default' })
|
||||
.execute()
|
||||
} else {
|
||||
await tbl.insert(merged)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存设置失败', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { db, type TabSnapshot } from './schema'
|
||||
import { type Table } from '@metona-team/metona-sqlark'
|
||||
import { getDb, type TabSnapshot, type ActiveTabRecord } from './schema'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const tabRepository = {
|
||||
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||
try {
|
||||
await db.transaction('rw', db.tabSnapshots, async () => {
|
||||
await db.tabSnapshots.clear()
|
||||
await db.tabSnapshots.bulkAdd(tabs)
|
||||
const db = await getDb()
|
||||
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
|
||||
await db.transaction(async trx => {
|
||||
await trx.table('tabSnapshots').clear()
|
||||
if (tabs.length > 0) {
|
||||
await trx.table('tabSnapshots').insertMany(tabs)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logError('保存标签快照失败', error)
|
||||
@@ -15,7 +20,9 @@ export const tabRepository = {
|
||||
|
||||
async loadAll(): Promise<TabSnapshot[]> {
|
||||
try {
|
||||
return await db.tabSnapshots.orderBy('updatedAt').toArray()
|
||||
const db = await getDb()
|
||||
const rows = await db.table('tabSnapshots').select().orderBy('updatedAt', 'asc').execute()
|
||||
return rows as TabSnapshot[]
|
||||
} catch (error) {
|
||||
logError('加载标签快照失败', error)
|
||||
return []
|
||||
@@ -24,7 +31,8 @@ export const tabRepository = {
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
try {
|
||||
await db.tabSnapshots.clear()
|
||||
const db = await getDb()
|
||||
await db.table('tabSnapshots').clear()
|
||||
} catch (error) {
|
||||
logError('清空标签快照失败', error)
|
||||
}
|
||||
@@ -32,7 +40,14 @@ export const tabRepository = {
|
||||
|
||||
async saveActiveTabId(tabId: string | null): Promise<void> {
|
||||
try {
|
||||
await db.activeTab.put({ id: 'current', activeTabId: tabId })
|
||||
const db = await getDb()
|
||||
const tbl = db.table('activeTab') as Table<ActiveTabRecord>
|
||||
const rows = await tbl.select().where({ id: 'current' }).execute()
|
||||
if (rows.length > 0) {
|
||||
await tbl.update({ activeTabId: tabId }).where({ id: 'current' }).execute()
|
||||
} else {
|
||||
await tbl.insert({ id: 'current', activeTabId: tabId })
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存活动标签ID失败', error)
|
||||
}
|
||||
@@ -40,11 +55,12 @@ export const tabRepository = {
|
||||
|
||||
async loadActiveTabId(): Promise<string | null> {
|
||||
try {
|
||||
const record = await db.activeTab.get('current')
|
||||
return record?.activeTabId ?? null
|
||||
const db = await getDb()
|
||||
const rows = await db.table('activeTab').select().where({ id: 'current' }).execute()
|
||||
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
|
||||
} catch (error) {
|
||||
logError('加载活动标签ID失败', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeDocStats } from '../useDocStats'
|
||||
|
||||
describe('computeDocStats', () => {
|
||||
it('should return zeros for undefined content', () => {
|
||||
expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should return zeros for null content', () => {
|
||||
expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should return zeros for empty string', () => {
|
||||
expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
|
||||
})
|
||||
|
||||
it('should count single word', () => {
|
||||
const result = computeDocStats('hello')
|
||||
expect(result.words).toBe(1)
|
||||
expect(result.chars).toBe(5)
|
||||
expect(result.charsNoSpace).toBe(5)
|
||||
expect(result.lines).toBe(1)
|
||||
})
|
||||
|
||||
it('should count multiple words', () => {
|
||||
const result = computeDocStats('hello world')
|
||||
expect(result.words).toBe(2)
|
||||
expect(result.chars).toBe(11) // 'hello world' = 11 chars
|
||||
expect(result.charsNoSpace).toBe(10) // without the space
|
||||
})
|
||||
|
||||
it('should count characters without spaces', () => {
|
||||
const result = computeDocStats('a b c')
|
||||
expect(result.chars).toBe(5)
|
||||
expect(result.charsNoSpace).toBe(3)
|
||||
})
|
||||
|
||||
it('should count lines', () => {
|
||||
const result = computeDocStats('line1\nline2\nline3')
|
||||
expect(result.lines).toBe(3)
|
||||
expect(result.words).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle Windows line endings', () => {
|
||||
const result = computeDocStats('line1\r\nline2\r\nline3')
|
||||
expect(result.lines).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle trailing newline', () => {
|
||||
const result = computeDocStats('hello\n')
|
||||
expect(result.lines).toBe(2)
|
||||
expect(result.words).toBe(1)
|
||||
})
|
||||
|
||||
it('should count markdown content correctly', () => {
|
||||
const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
|
||||
const result = computeDocStats(md)
|
||||
expect(result.words).toBe(17)
|
||||
expect(result.lines).toBe(7)
|
||||
expect(result.chars).toBe(md.length)
|
||||
})
|
||||
|
||||
it('should handle whitespace-only content', () => {
|
||||
const result = computeDocStats(' \n \n ')
|
||||
expect(result.words).toBe(0)
|
||||
expect(result.chars).toBe(9)
|
||||
expect(result.charsNoSpace).toBe(0)
|
||||
expect(result.lines).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle content with non-ASCII characters', () => {
|
||||
const result = computeDocStats('中文测试 日本語 한국어')
|
||||
expect(result.words).toBe(3)
|
||||
expect(result.chars).toBe(12)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
/** Debounce delay for auto-save (ms) */
|
||||
const AUTO_SAVE_DELAY = 2000
|
||||
@@ -23,7 +24,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 +55,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()
|
||||
@@ -75,14 +80,24 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
|
||||
isSavingRef.current = true
|
||||
if (mountedRef.current) setIsAutoSaving(true)
|
||||
|
||||
// B1-fix: 记录保存时的内容快照 — 保存期间用户继续输入的话,
|
||||
// 完成后仅当内容仍是快照时才清除 isModified(否则新输入会被误清标记
|
||||
// 导致永不落盘、关闭时也无未保存提示)
|
||||
const savedContent = tabToSave.content
|
||||
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tabToSave.filePath,
|
||||
content: tabToSave.content
|
||||
content: savedContent,
|
||||
})
|
||||
if (result.success && mountedRef.current) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
const latest = useTabStore.getState().tabs.find(t => t.id === tabIdToSave)
|
||||
if (latest && latest.content === savedContent) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
}
|
||||
} else if (mountedRef.current && !result.success && !result.canceled) {
|
||||
showToast(`自动保存失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('自动保存失败', error)
|
||||
|
||||
@@ -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,50 +8,53 @@ import { showToast } from '../lib/toast'
|
||||
export function useDragDrop() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files) return
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files) return
|
||||
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
// Electron adds a `path` property to File objects
|
||||
const electronFile = file as File & { path?: string }
|
||||
const filePath: string = electronFile.path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||
continue
|
||||
}
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
// Electron adds a `path` property to File objects
|
||||
const electronFile = file as File & { path?: string }
|
||||
const filePath: string = electronFile.path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('拖拽读取文件失败', error)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('拖拽读取文件失败', error)
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
}, [createTab])
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
},
|
||||
[createTab],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: DragEvent) => {
|
||||
|
||||
@@ -1,84 +1,142 @@
|
||||
import { useCallback } from 'react'
|
||||
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'
|
||||
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
*/
|
||||
export function useFileOperations() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
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)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
showToast('打开文件失败', 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
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)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const updateTabFilePath = useTabStore(s => s.updateTabFilePath)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
|
||||
const savingGate = useRef(false)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
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 if (result && 'error' in result) {
|
||||
loading.error(`打开失败: ${result.error}`)
|
||||
} else {
|
||||
loading.dismiss()
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
loading.error('打开文件失败')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
|
||||
if (savingGate.current) return
|
||||
savingGate.current = true
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
// B1-fix: 保存前记录内容快照 — 保存期间继续输入的话,完成后
|
||||
// 仅当内容未变化时才清除 isModified,避免新输入被误清标记
|
||||
const savedContent = tab.content
|
||||
// v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
|
||||
const result = await MeToast.promise(
|
||||
window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: savedContent,
|
||||
}),
|
||||
{
|
||||
loading: '保存中...',
|
||||
success: '已保存',
|
||||
error: '保存失败',
|
||||
},
|
||||
)
|
||||
if (result.success) {
|
||||
const latest = useTabStore.getState().tabs.find(t => t.id === tab.id)
|
||||
if (latest && latest.content === savedContent) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
}
|
||||
} else {
|
||||
// IPC 返回 success:false 不 reject,promise 的 error 文案不会触发,需显式提示
|
||||
if (!result.canceled) {
|
||||
showToast(`保存失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
} finally {
|
||||
// 300ms 后释放锁,允许下次保存
|
||||
setTimeout(() => {
|
||||
savingGate.current = false
|
||||
}, 300)
|
||||
}
|
||||
}, [getActiveTab])
|
||||
|
||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await MeToast.promise(
|
||||
window.electronAPI.saveFileAs({ content: tab.content }),
|
||||
{
|
||||
loading: '另存为...',
|
||||
success: '已保存',
|
||||
error: '另存为失败',
|
||||
},
|
||||
)
|
||||
if (result.success && result.filePath) {
|
||||
// B2-fix: 另存为成功后把标签重新绑定到新路径(此前标签仍指向旧文件,
|
||||
// 之后 Ctrl+S/自动保存会写回旧文件)
|
||||
updateTabFilePath(tab.id, result.filePath)
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
} else if (!result.canceled) {
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
}
|
||||
}, [getActiveTab, updateTabFilePath])
|
||||
|
||||
const handleOpenRecent = useCallback(
|
||||
async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
} else {
|
||||
// B13-fix: 打开失败(文件被删除/移动)显式提示,不再静默
|
||||
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开最近文件失败', error)
|
||||
showToast(`无法打开 "${filePath}"`, 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
},
|
||||
[createTab, setLoading],
|
||||
)
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
|
||||
@@ -7,20 +7,20 @@ import { useEditorStore } from '../stores/editorStore'
|
||||
* 使用 Zustand store 状态替代 DOM CustomEvent
|
||||
*/
|
||||
export function useFileWatch() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
|
||||
// B5-fix: 任何打开的标签对应的文件被外部修改都提示(此前只检测活动标签,
|
||||
// 后台标签被外部改后切过去无感知,显示的还是内存旧内容)
|
||||
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
|
||||
const tabs = useTabStore.getState().tabs
|
||||
if (tabs.some(t => t.filePath === filePath)) {
|
||||
setExternallyModified({ filePath })
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [getActiveTab, setExternallyModified])
|
||||
}, [setExternallyModified])
|
||||
}
|
||||
|
||||
@@ -1,51 +1,78 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
/**
|
||||
* 全局键盘快捷键 hook。
|
||||
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
||||
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
||||
*/
|
||||
export function useKeyboard(
|
||||
handleOpenFile: () => void,
|
||||
handleSave: () => void,
|
||||
handleSaveAs: () => void,
|
||||
handleShowSearch: () => void,
|
||||
) {
|
||||
const handleKeydown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||
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 === '1') { e.preventDefault(); setViewMode('editor'); return }
|
||||
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
|
||||
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return }
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||
if (isCtrl && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, mruStack } = tabState
|
||||
if (tabs.length > 1) {
|
||||
// mruStack[0] 是最近一个被切走的标签(即上一个活动标签)
|
||||
if (mruStack.length > 0) {
|
||||
const targetId = mruStack[0]
|
||||
if (tabs.find(t => t.id === targetId)) {
|
||||
tabState.switchToTab(targetId)
|
||||
return
|
||||
}
|
||||
}
|
||||
// MRU 栈为空或不合法时回退到顺序切换
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = e.shiftKey
|
||||
? (idx - 1 + tabs.length) % tabs.length
|
||||
: (idx + 1) % tabs.length
|
||||
tabState.switchToTab(tabs[next].id)
|
||||
if (isCtrl && e.key === 'o') {
|
||||
e.preventDefault()
|
||||
handleOpenFile()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
||||
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault()
|
||||
handleSaveAs()
|
||||
return
|
||||
}
|
||||
// v0.6.2: 多文件搜索
|
||||
if (isCtrl && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
|
||||
e.preventDefault()
|
||||
handleShowSearch()
|
||||
return
|
||||
}
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||
if (isCtrl && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, mruStack } = tabState
|
||||
if (tabs.length > 1) {
|
||||
if (mruStack.length > 0) {
|
||||
const targetId = mruStack[0]
|
||||
if (tabs.find(t => t.id === targetId)) {
|
||||
tabState.switchToTab(targetId)
|
||||
return
|
||||
}
|
||||
}
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length
|
||||
tabState.switchToTab(tabs[next].id)
|
||||
}
|
||||
return
|
||||
}
|
||||
},
|
||||
[handleOpenFile, handleSave, handleSaveAs, handleShowSearch],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
/**
|
||||
* AR-04: 设置 hook
|
||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
||||
* 仅负责视图模式的读取和保存
|
||||
*/
|
||||
export function useSettings() {
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
|
||||
const saveViewMode = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
settingsRepository.save({ viewMode: mode })
|
||||
}, [setViewMode])
|
||||
|
||||
return { viewMode, saveViewMode }
|
||||
}
|
||||
@@ -3,14 +3,15 @@ import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { DEFAULT_SETTINGS } from '../types/settings'
|
||||
|
||||
/**
|
||||
* AR-04: 统一设置加载 hook
|
||||
* 一次性从 IndexedDB 加载所有设置,分发到各 store
|
||||
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
|
||||
* 一次性从 MetonaSqlark 加载所有设置,分发到各 store,
|
||||
* 替代各 hook 各自独立加载设置的模式。
|
||||
*/
|
||||
export function useSettingsInit() {
|
||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
@@ -18,31 +19,32 @@ export function useSettingsInit() {
|
||||
if (isInitialized.current) return
|
||||
isInitialized.current = true
|
||||
|
||||
settingsRepository.load()
|
||||
.then((settings) => {
|
||||
// 主题
|
||||
settingsRepository
|
||||
.load()
|
||||
.then(settings => {
|
||||
// B11-fix: 首次启动(settings 为 null)时主题跟随系统偏好;
|
||||
// 有保存记录则使用保存值
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
|
||||
setDarkMode(darkMode)
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
const themeMode = settings?.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
setThemeMode(themeMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
setViewMode(settings?.viewMode ?? 'editor')
|
||||
|
||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||
const sidebarStore = useSidebarStore.getState()
|
||||
if (!sidebarStore._loaded) {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
isVisible: settings ? !settings.sidebarCollapsed : true,
|
||||
sidebarWidth: settings?.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
|
||||
_loaded: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logError('加载设置失败', error)
|
||||
})
|
||||
}, [setDarkMode, setViewMode])
|
||||
}, [setThemeMode, setViewMode])
|
||||
|
||||
return { isInitialized }
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStatusBarStore, type StatusBarItem } from '../stores/statusBarStore'
|
||||
|
||||
/**
|
||||
* 便捷 hook — 挂载时注册一个状态栏项,卸载时自动清除。
|
||||
*
|
||||
* 适合在 App.tsx 或自定义 hook 中注册需要响应式更新的状态栏项。
|
||||
* 当 item 对象变化时(通过 deps 控制),旧项会被新项替换。
|
||||
*
|
||||
* @param item - 状态栏项(含 Component 函数组件)
|
||||
*/
|
||||
export function useStatusBarItem(item: StatusBarItem | null): void {
|
||||
const register = useStatusBarStore(s => s.register)
|
||||
const unregister = useStatusBarStore(s => s.unregister)
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return
|
||||
register(item)
|
||||
return () => unregister(item.id)
|
||||
}, [item, register, unregister])
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStatusBarStore } from '../stores/statusBarStore'
|
||||
import {
|
||||
FileInfoItem,
|
||||
LoadingItem,
|
||||
DocStatsItem,
|
||||
EncodingItem,
|
||||
LangItem
|
||||
} from '../components/StatusBar/StatusBarItems'
|
||||
|
||||
/**
|
||||
* useDefaultStatusBarItems — 注册所有默认状态栏项(不含 auto-save)。
|
||||
*
|
||||
* auto-save 项由 App.tsx 单独注册,因为它需要从 autoSaveStore
|
||||
* 读取状态并通过 toggleAutoSaveExternal() 触发切换。
|
||||
*/
|
||||
export function useDefaultStatusBarItems() {
|
||||
const register = useStatusBarStore(s => s.register)
|
||||
const unregister = useStatusBarStore(s => s.unregister)
|
||||
|
||||
useEffect(() => {
|
||||
register({ id: 'statusbar.file-info', alignment: 'left', priority: 0, Component: FileInfoItem })
|
||||
register({ id: 'statusbar.loading', alignment: 'left', priority: 1, Component: LoadingItem })
|
||||
register({ id: 'statusbar.doc-stats', alignment: 'right', priority: 100, Component: DocStatsItem })
|
||||
register({ id: 'statusbar.encoding', alignment: 'right', priority: 300, Component: EncodingItem })
|
||||
register({ id: 'statusbar.lang', alignment: 'right', priority: 400, Component: LangItem })
|
||||
|
||||
return () => {
|
||||
unregister('statusbar.file-info')
|
||||
unregister('statusbar.loading')
|
||||
unregister('statusbar.doc-stats')
|
||||
unregister('statusbar.encoding')
|
||||
unregister('statusbar.lang')
|
||||
}
|
||||
}, [register, unregister])
|
||||
}
|
||||
@@ -1,23 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import MeEditor from '@metona-team/metona-editor'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { syncToastTheme } from '../lib/toast'
|
||||
import type { ThemeMode } from '../types/settings'
|
||||
|
||||
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
||||
function hexWithAlpha(hex: string, alpha: number): string {
|
||||
if (!hex || !hex.startsWith('#')) return hex
|
||||
const a = Math.round(alpha * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
||||
}
|
||||
|
||||
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
|
||||
function syncAppColorsToEditor(): void {
|
||||
try {
|
||||
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
|
||||
const vars = MeEditor.themes.exportCSSVars()
|
||||
if (!vars || typeof vars !== 'object') return
|
||||
const root = document.documentElement
|
||||
const set = (name: string, value: string | undefined) => {
|
||||
if (value) root.style.setProperty(name, value)
|
||||
}
|
||||
const accent = vars['--md-accent'] ?? '#1a73e8'
|
||||
// 直接映射 — 文本/边框用纯色
|
||||
set('--bg', vars['--md-bg'])
|
||||
set('--text', vars['--md-text'])
|
||||
set('--border', vars['--md-border'])
|
||||
set('--primary', accent)
|
||||
set('--primary-dark', accent)
|
||||
set('--text-secondary', vars['--md-muted'])
|
||||
set('--text-tertiary', vars['--md-muted'])
|
||||
set('--code-bg', vars['--md-code-bg'])
|
||||
// 背景派生 — 用 accent 的低透明度版本
|
||||
const accentBg = hexWithAlpha(accent, 0.12)
|
||||
set('--primary-light', accentBg)
|
||||
set('--sidebar-active', accentBg)
|
||||
set('--sidebar-bg', vars['--md-bg'])
|
||||
set('--sidebar-border', vars['--md-border'])
|
||||
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
|
||||
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
|
||||
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
|
||||
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||
set('--search-border', vars['--md-border'])
|
||||
// 阴影根据主题适配
|
||||
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
|
||||
root.style.setProperty(
|
||||
'--shadow',
|
||||
isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)',
|
||||
)
|
||||
root.style.setProperty(
|
||||
'--shadow-lg',
|
||||
isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.1)',
|
||||
)
|
||||
} catch {
|
||||
/* 容错 */
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
warm: 'warm',
|
||||
}
|
||||
|
||||
/**
|
||||
* AR-04: 主题 hook
|
||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
||||
* 仅负责主题切换时的同步和保存
|
||||
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||
* 应用颜色自动同步编辑器主题,保持一致。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const darkMode = useEditorStore(s => s.darkMode)
|
||||
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
|
||||
const themeMode = useEditorStore(s => s.themeMode)
|
||||
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||
|
||||
// darkMode 变化时同步 class 并保存
|
||||
const handleCycleTheme = useCallback(() => {
|
||||
const next = cycleTheme()
|
||||
// 同步编辑器全局主题
|
||||
MeEditor.setTheme(next)
|
||||
// 同步 Toast 主题
|
||||
import('../lib/toast').then(({ MeToast }) => {
|
||||
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
|
||||
})
|
||||
// 持久化
|
||||
settingsRepository.save({ themeMode: next })
|
||||
}, [cycleTheme])
|
||||
|
||||
// themeMode 变化时同步应用颜色
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
settingsRepository.save({ darkMode })
|
||||
syncToastTheme(darkMode)
|
||||
}, [darkMode])
|
||||
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||
syncAppColorsToEditor()
|
||||
}, [themeMode])
|
||||
|
||||
return { darkMode, toggleDarkMode }
|
||||
return { themeMode, cycleTheme: handleCycleTheme }
|
||||
}
|
||||
|
||||
@@ -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,100 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||
|
||||
describe('fileUtils', () => {
|
||||
describe('getFileName', () => {
|
||||
it('should extract filename from Unix path', () => {
|
||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should extract filename from Windows path', () => {
|
||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should return the input if it is already a filename', () => {
|
||||
expect(getFileName('file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should handle path with trailing slash', () => {
|
||||
expect(getFileName('/path/to/dir/')).toBe('dir')
|
||||
})
|
||||
|
||||
|
||||
it('should handle path with trailing backslash', () => {
|
||||
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
||||
})
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return lowercase extension', () => {
|
||||
expect(getFileExtension('file.MD')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should return .markdown extension', () => {
|
||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should return .txt extension', () => {
|
||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for no extension', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for hidden files starting with dot', () => {
|
||||
expect(getFileExtension('.gitignore')).toBe('')
|
||||
})
|
||||
|
||||
it('should extract extension from full path', () => {
|
||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should extract extension from Windows path', () => {
|
||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should handle file with multiple dots', () => {
|
||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFile', () => {
|
||||
it('should return true for .md files', () => {
|
||||
expect(isAllowedFile('file.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .markdown files', () => {
|
||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .txt files', () => {
|
||||
expect(isAllowedFile('file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase extensions', () => {
|
||||
expect(isAllowedFile('file.MD')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for .js files', () => {
|
||||
expect(isAllowedFile('file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for .json files', () => {
|
||||
expect(isAllowedFile('file.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for files without extension', () => {
|
||||
expect(isAllowedFile('Makefile')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with full paths', () => {
|
||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||
|
||||
describe('fileUtils', () => {
|
||||
describe('getFileName', () => {
|
||||
it('should extract filename from Unix path', () => {
|
||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should extract filename from Windows path', () => {
|
||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should return the input if it is already a filename', () => {
|
||||
expect(getFileName('file.md')).toBe('file.md')
|
||||
})
|
||||
|
||||
it('should handle path with trailing slash', () => {
|
||||
expect(getFileName('/path/to/dir/')).toBe('dir')
|
||||
})
|
||||
|
||||
|
||||
it('should handle path with trailing backslash', () => {
|
||||
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
||||
})
|
||||
|
||||
it('should handle mixed path separators', () => {
|
||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return lowercase extension', () => {
|
||||
expect(getFileExtension('file.MD')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should return .markdown extension', () => {
|
||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should return .txt extension', () => {
|
||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for no extension', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for hidden files starting with dot', () => {
|
||||
expect(getFileExtension('.gitignore')).toBe('')
|
||||
})
|
||||
|
||||
it('should extract extension from full path', () => {
|
||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||
})
|
||||
|
||||
it('should extract extension from Windows path', () => {
|
||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||
})
|
||||
|
||||
it('should handle file with multiple dots', () => {
|
||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedFile', () => {
|
||||
it('should return true for .md files', () => {
|
||||
expect(isAllowedFile('file.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .markdown files', () => {
|
||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for .txt files', () => {
|
||||
expect(isAllowedFile('file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for uppercase extensions', () => {
|
||||
expect(isAllowedFile('file.MD')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for .js files', () => {
|
||||
expect(isAllowedFile('file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for .json files', () => {
|
||||
expect(isAllowedFile('file.json')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for files without extension', () => {
|
||||
expect(isAllowedFile('Makefile')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with full paths', () => {
|
||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 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 handle undefined filePath', async () => {
|
||||
const result = await renderMarkdown('# No File')
|
||||
expect(result).toContain('No File')
|
||||
it('should not emit img for javascript: image src', async () => {
|
||||
const result = await renderMarkdown(')')
|
||||
expect(result).not.toContain('<img')
|
||||
expect(result).not.toContain('src=')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
||||
const clean = filePath.replace(/[/\\]+$/, '')
|
||||
return clean.split(/[/\\]/).pop() || clean || filePath
|
||||
}
|
||||
|
||||
export function isAllowedFile(filePath: string): boolean {
|
||||
const ext = getFileExtension(filePath)
|
||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||
}
|
||||
|
||||
// L-07: 无扩展名文件正确返回空字符串
|
||||
export function getFileExtension(filePath: string): string {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||
return name.substring(dotIndex).toLowerCase()
|
||||
}
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
||||
const clean = filePath.replace(/[/\\]+$/, '')
|
||||
return clean.split(/[/\\]/).pop() || clean || filePath
|
||||
}
|
||||
|
||||
export function isAllowedFile(filePath: string): boolean {
|
||||
const ext = getFileExtension(filePath)
|
||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||
}
|
||||
|
||||
// L-07: 无扩展名文件正确返回空字符串
|
||||
export function getFileExtension(filePath: string): string {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||
return name.substring(dotIndex).toLowerCase()
|
||||
}
|
||||
|
||||
+99
-140
@@ -1,140 +1,99 @@
|
||||
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 type { Element, Root } from 'hast'
|
||||
|
||||
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
|
||||
function normPath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function resolveRelativePath(base: string, rel: string): string {
|
||||
const parts = normPath(base).split('/')
|
||||
parts.pop() // 移除文件名
|
||||
const relParts = rel.split('/')
|
||||
for (const p of relParts) {
|
||||
if (p === '.' || p === '') continue
|
||||
if (p === '..') {
|
||||
if (parts.length > 0) parts.pop()
|
||||
} else {
|
||||
parts.push(p)
|
||||
}
|
||||
}
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
if (!filePath) return
|
||||
|
||||
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://')) {
|
||||
// 处理Unix风格绝对路径(以/开头)
|
||||
if (src.startsWith('/')) {
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + src
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||
const resolvedPath = resolveRelativePath(dir, src)
|
||||
// E1: 统一规范化后比较,防止 ../ 路径越界
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// PF-01: Markdown处理器LRU缓存
|
||||
const MAX_CACHE_SIZE = 20
|
||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||
|
||||
function buildProcessor(filePath: string | null) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
|
||||
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
},
|
||||
protocols: {
|
||||
...(defaultSchema.protocols ?? {}),
|
||||
src: [
|
||||
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
|
||||
'file:',
|
||||
],
|
||||
},
|
||||
// H-05: 显式 strip 事件处理器属性,纵深防御
|
||||
strip: ['script', 'on*', 'javascript:'],
|
||||
})
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
}
|
||||
|
||||
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
|
||||
const key: string = filePath ?? '__null__'
|
||||
|
||||
if (processorCache.has(key)) {
|
||||
const cached = processorCache.get(key)!
|
||||
processorCache.delete(key)
|
||||
processorCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const processor = buildProcessor(filePath)
|
||||
|
||||
if (processorCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = processorCache.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
processorCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
processorCache.set(key, processor)
|
||||
return processor
|
||||
}
|
||||
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
try {
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
}
|
||||
}
|
||||
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: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
|
||||
function normPath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function resolveRelativePath(base: string, rel: string): string {
|
||||
const parts = normPath(base).split('/')
|
||||
parts.pop() // 移除文件名
|
||||
const relParts = rel.split('/')
|
||||
for (const p of relParts) {
|
||||
if (p === '.' || p === '') continue
|
||||
if (p === '..') {
|
||||
if (parts.length > 0) parts.pop()
|
||||
} else {
|
||||
parts.push(p)
|
||||
}
|
||||
}
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
/** 属性值转义(与内置解析器 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(/[/\\][^/\\]+$/, '')
|
||||
|
||||
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 (raw.startsWith('/')) {
|
||||
return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
|
||||
}
|
||||
// 解析完整路径并检查是否越界
|
||||
// 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
|
||||
// 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
|
||||
const resolvedPath = resolveRelativePath(filePath, raw)
|
||||
const normalizedBase = normPath(dir)
|
||||
// 用 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)}"`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Markdown — 使用 MetonaEditor 内置解析器。
|
||||
*
|
||||
* @param content Markdown 源码
|
||||
* @param filePath 文件路径(用于修复相对路径图片;null 时不做图片修复)
|
||||
*/
|
||||
export function renderMarkdownSync(content: string, filePath?: string | null): string {
|
||||
try {
|
||||
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>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步版本 — 内置解析器为同步实现,此函数仅保持兼容签名。
|
||||
*/
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
return renderMarkdownSync(content, filePath)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import MeToast from 'metona-toast'
|
||||
import MeToast from '@metona-team/metona-toast'
|
||||
import { logError } from './errorHandler'
|
||||
|
||||
/**
|
||||
* MeToast 全局配置
|
||||
*
|
||||
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
||||
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||
*
|
||||
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
|
||||
* v0.6.0: 接入 dedupe 去重插件与 onError 全局错误回调。
|
||||
*/
|
||||
MeToast.configure({
|
||||
position: 'top-right',
|
||||
@@ -17,8 +21,19 @@ MeToast.configure({
|
||||
showProgress: true,
|
||||
draggable: true,
|
||||
locale: 'zh-CN',
|
||||
width: 360,
|
||||
// v0.6.0: 钩子/定时器异常统一走应用错误日志
|
||||
onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
|
||||
})
|
||||
|
||||
// 安装内置插件
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
|
||||
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
|
||||
|
||||
/** 与原 showToast 保持兼容的类型子集 */
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ import './styles/markdown-body.css'
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('editorStore', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
})
|
||||
@@ -24,29 +24,36 @@ describe('editorStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setDarkMode', () => {
|
||||
it('should enable dark mode', () => {
|
||||
useEditorStore.getState().setDarkMode(true)
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
describe('themeMode', () => {
|
||||
it('should default to light', () => {
|
||||
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('should disable dark mode', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().setDarkMode(false)
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
it('should set theme mode', () => {
|
||||
useEditorStore.getState().setThemeMode('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
it('should set warm theme', () => {
|
||||
useEditorStore.getState().setThemeMode('warm')
|
||||
expect(useEditorStore.getState().themeMode).toBe('warm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleDarkMode', () => {
|
||||
it('should toggle from false to true', () => {
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
||||
describe('cycleTheme', () => {
|
||||
it('should cycle light → dark', () => {
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
it('should toggle from true to false', () => {
|
||||
useEditorStore.setState({ darkMode: true })
|
||||
useEditorStore.getState().toggleDarkMode()
|
||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
||||
it('should cycle dark → warm', () => {
|
||||
useEditorStore.setState({ themeMode: 'dark' })
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
|
||||
})
|
||||
|
||||
it('should cycle warm → light', () => {
|
||||
useEditorStore.setState({ themeMode: 'warm' })
|
||||
expect(useEditorStore.getState().cycleTheme()).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,4 +100,17 @@ describe('editorStore', () => {
|
||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,6 +249,32 @@ describe('tabStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabFilePath (B2)', () => {
|
||||
it('should rebind tab to a new file path (save-as)', () => {
|
||||
const { createTab, updateTabFilePath } = useTabStore.getState()
|
||||
const tab = createTab('/old.md', '# Content')
|
||||
|
||||
updateTabFilePath(tab.id, '/new.md')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.filePath).toBe('/new.md')
|
||||
expect(updated.content).toBe('# Content')
|
||||
})
|
||||
|
||||
it('should not affect other tabs', () => {
|
||||
const { createTab, updateTabFilePath } = useTabStore.getState()
|
||||
const tab1 = createTab('/a.md')
|
||||
const tab2 = createTab('/b.md')
|
||||
|
||||
updateTabFilePath(tab1.id, '/c.md')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs.find(t => t.id === tab1.id)?.filePath).toBe('/c.md')
|
||||
expect(state.tabs.find(t => t.id === tab2.id)?.filePath).toBe('/b.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent same-content guard (A3)', () => {
|
||||
it('should not mark as modified when content is unchanged', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
@@ -314,4 +340,4 @@ describe('tabStore', () => {
|
||||
expect(state.tabs.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
import { create } from 'zustand'
|
||||
import type { EditorView } from '@milkdown/prose/view'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||
|
||||
// Module-level getter/setter for the ProseMirror EditorView
|
||||
// Module-level getter for the MetonaEditor instance
|
||||
// Used by OutlinePanel for heading navigation
|
||||
let _getView: (() => EditorView | null) | null = null
|
||||
let _getEditor: (() => MarkdownEditor | null) | null = null
|
||||
|
||||
export function setEditorViewGetter(fn: () => EditorView | null) {
|
||||
_getView = fn
|
||||
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
||||
_getEditor = fn
|
||||
}
|
||||
|
||||
export function getEditorView(): EditorView | null {
|
||||
return _getView ? _getView() : null
|
||||
export function getMetonaEditor(): MarkdownEditor | null {
|
||||
return _getEditor ? _getEditor() : null
|
||||
}
|
||||
|
||||
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
themeMode: ThemeMode
|
||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||
externallyModified: { filePath: string } | null
|
||||
// UX-02: 全局加载状态
|
||||
loadingStates: Record<string, boolean>
|
||||
// v0.6.0: Zen 模式状态(Toolbar 消费)
|
||||
zenMode: boolean
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setDarkMode: (dark: boolean) => void
|
||||
toggleDarkMode: () => void
|
||||
setThemeMode: (mode: ThemeMode) => void
|
||||
cycleTheme: () => ThemeMode
|
||||
setExternallyModified: (info: { filePath: string } | null) => void
|
||||
// UX-02: 加载状态管理
|
||||
setLoading: (key: string, loading: boolean) => void
|
||||
isLoading: (key: string) => boolean
|
||||
setZenMode: (zen: boolean) => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
themeMode: 'light',
|
||||
externallyModified: null,
|
||||
loadingStates: {},
|
||||
zenMode: false,
|
||||
|
||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
|
||||
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
||||
cycleTheme: () => {
|
||||
const current = get().themeMode
|
||||
const idx = THEME_CYCLE.indexOf(current)
|
||||
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
|
||||
set({ themeMode: next })
|
||||
return next
|
||||
},
|
||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||
})),
|
||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||
setLoading: (key: string, loading: boolean) =>
|
||||
set(state => ({
|
||||
loadingStates: { ...state.loadingStates, [key]: loading },
|
||||
})),
|
||||
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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* 状态栏扩展点架构
|
||||
*
|
||||
* 功能模块通过 register/unregister 注入自己的状态栏组件,
|
||||
* StatusBar 只负责按 alignment + priority 排序渲染。
|
||||
* 避免 StatusBar 随功能膨胀,实现类似 VS Code contributions 的注册模式。
|
||||
*
|
||||
* 每个 StatusBarItem 的 Component 是独立的 React 函数组件(无 props),
|
||||
* 内部使用 hooks 读取所需 store 状态。组件在 StatusBar 的渲染中挂载,
|
||||
* 因此 hooks 调用在合法的 React 组件上下文中。
|
||||
*/
|
||||
|
||||
export interface StatusBarItem {
|
||||
id: string
|
||||
alignment: 'left' | 'right'
|
||||
/** 同侧排序权重,数字越小越靠左(left)或越靠中(right) */
|
||||
priority: number
|
||||
Component: ComponentType
|
||||
}
|
||||
|
||||
interface StatusBarState {
|
||||
/** 以 id 为 key,方便覆盖和删除 */
|
||||
items: Record<string, StatusBarItem>
|
||||
register: (item: StatusBarItem) => void
|
||||
unregister: (id: string) => void
|
||||
}
|
||||
|
||||
export const useStatusBarStore = create<StatusBarState>((set, get) => ({
|
||||
items: {},
|
||||
|
||||
register: (item: StatusBarItem) => {
|
||||
const { items } = get()
|
||||
if (items[item.id]?.Component === item.Component) return
|
||||
set({ items: { ...items, [item.id]: item } })
|
||||
},
|
||||
|
||||
unregister: (id: string) => {
|
||||
const { items } = get()
|
||||
if (!(id in items)) return
|
||||
const next = { ...items }
|
||||
delete next[id]
|
||||
set({ items: next })
|
||||
}
|
||||
}))
|
||||
+322
-271
@@ -1,271 +1,322 @@
|
||||
import { create } from 'zustand'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
timer = null
|
||||
}, delay)
|
||||
}) as F & { cancel: () => void }
|
||||
debounced.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
mruStack: string[]
|
||||
_loaded: boolean
|
||||
|
||||
createTab: (filePath?: string | null, content?: string) => Tab
|
||||
closeTab: (tabId: string) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
closeTabsToRight: (tabId: string) => void
|
||||
moveTab: (fromId: string, toIndex: number) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
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
|
||||
loadFromDB: () => Promise<void>
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
|
||||
// PF-08: 模块级防抖保存函数(500ms延迟)
|
||||
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
|
||||
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
const { tabs } = get()
|
||||
if (tabs.length === 0) {
|
||||
await tabRepository.clearAll()
|
||||
return
|
||||
}
|
||||
const snapshots = tabs.map(t => ({
|
||||
id: t.id,
|
||||
filePath: t.filePath,
|
||||
content: t.content,
|
||||
isModified: t.isModified,
|
||||
scrollTop: t.scrollTop,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
}
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => {
|
||||
const actualSave = getActualSaveToDB(get)
|
||||
_debouncedSaveToDB = debounce(actualSave, 500)
|
||||
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- DB load error
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
saveToDB: async () => {
|
||||
if (_debouncedSaveToDB) {
|
||||
_debouncedSaveToDB()
|
||||
}
|
||||
},
|
||||
|
||||
createTab: (filePath: string | null = null, content: string = ''): Tab => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
|
||||
const newTabs = state.tabs.filter(t => t.id !== tabId)
|
||||
const newMru = state.mruStack.filter(id => id !== tabId)
|
||||
|
||||
let newActiveId = state.activeTabId
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null
|
||||
} else {
|
||||
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
|
||||
})
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
set(state => {
|
||||
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)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
|
||||
moveTab: (fromId: string, toIndex: number) => {
|
||||
set(state => {
|
||||
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return state
|
||||
const newTabs = [...state.tabs]
|
||||
const [moved] = newTabs.splice(fromIndex, 1)
|
||||
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
|
||||
// toIndex 相对于原数组的位置在移除后无需调整)
|
||||
newTabs.splice(toIndex, 0, moved)
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
switchToTab: (tabId: string) => {
|
||||
set(state => {
|
||||
if (state.activeTabId === tabId) return state
|
||||
const newMru = state.activeTabId
|
||||
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
|
||||
: state.mruStack
|
||||
return { activeTabId: tabId, mruStack: newMru }
|
||||
})
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId: string, content: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => {
|
||||
if (t.id !== tabId) return t
|
||||
// A3: 内容未变时不变更对象引用(避免误标记 isModified)
|
||||
if (t.content === content) return t
|
||||
return { ...t, content, isModified: true }
|
||||
})
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
|
||||
export function flushSaveToDB(): Promise<void> {
|
||||
_debouncedSaveToDB?.cancel()
|
||||
return getActualSaveToDB(useTabStore.getState)()
|
||||
}
|
||||
import { create } from 'zustand'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
timer = null
|
||||
}, delay)
|
||||
}) as F & { cancel: () => void }
|
||||
debounced.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
mruStack: string[]
|
||||
_loaded: boolean
|
||||
|
||||
createTab: (filePath?: string | null, content?: string) => Tab
|
||||
closeTab: (tabId: string) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
closeTabsToRight: (tabId: string) => void
|
||||
moveTab: (fromId: string, toIndex: number) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
updateTabFilePath: (tabId: string, filePath: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
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
|
||||
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
const { tabs } = get()
|
||||
if (tabs.length === 0) {
|
||||
await tabRepository.clearAll()
|
||||
return
|
||||
}
|
||||
const snapshots = tabs.map(t => ({
|
||||
id: t.id,
|
||||
filePath: t.filePath,
|
||||
content: t.content,
|
||||
isModified: t.isModified,
|
||||
scrollTop: t.scrollTop,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
}
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => {
|
||||
const actualSave = getActualSaveToDB(get)
|
||||
_debouncedSaveToDB = debounce(actualSave, 500)
|
||||
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId(),
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd,
|
||||
}))
|
||||
// v0.6.2: 磁盘 mtime 比对 — 未修改的标签若磁盘内容比快照新
|
||||
// (应用关闭期间被外部修改),恢复时读取磁盘最新内容
|
||||
let refreshed = 0
|
||||
if (typeof window !== 'undefined' && window.electronAPI) {
|
||||
for (const tab of tabs) {
|
||||
if (!tab.filePath || tab.isModified) continue
|
||||
const snap = snapshots.find(s => s.id === tab.id)
|
||||
if (!snap || !snap.updatedAt) continue
|
||||
try {
|
||||
const stats = await window.electronAPI.getFileStats(tab.filePath)
|
||||
if (
|
||||
stats.success &&
|
||||
stats.mtime &&
|
||||
new Date(stats.mtime).getTime() > snap.updatedAt
|
||||
) {
|
||||
const read = await window.electronAPI.readFile(tab.filePath)
|
||||
if (read.success && read.content !== undefined) {
|
||||
tab.content = read.content
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单个文件校验失败不影响整体恢复
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeTabId =
|
||||
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
if (refreshed > 0) {
|
||||
_debouncedSaveToDB?.()
|
||||
try {
|
||||
const { showToast } = await import('../lib/toast')
|
||||
showToast(`${refreshed} 个标签已同步磁盘最新内容`, 'info')
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- DB load error
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
saveToDB: async () => {
|
||||
if (_debouncedSaveToDB) {
|
||||
_debouncedSaveToDB()
|
||||
}
|
||||
},
|
||||
|
||||
createTab: (filePath: string | null = null, content: string = ''): Tab => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id,
|
||||
}))
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
|
||||
const newTabs = state.tabs.filter(t => t.id !== tabId)
|
||||
const newMru = state.mruStack.filter(id => id !== tabId)
|
||||
|
||||
let newActiveId = state.activeTabId
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null
|
||||
} else {
|
||||
const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
|
||||
})
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
set(state => {
|
||||
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)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)),
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
|
||||
moveTab: (fromId: string, toIndex: number) => {
|
||||
set(state => {
|
||||
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return state
|
||||
const newTabs = [...state.tabs]
|
||||
const [moved] = newTabs.splice(fromIndex, 1)
|
||||
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
|
||||
// toIndex 相对于原数组的位置在移除后无需调整)
|
||||
newTabs.splice(toIndex, 0, moved)
|
||||
return { tabs: newTabs }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
switchToTab: (tabId: string) => {
|
||||
set(state => {
|
||||
if (state.activeTabId === tabId) return state
|
||||
const newMru = state.activeTabId
|
||||
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
|
||||
: state.mruStack
|
||||
return { activeTabId: tabId, mruStack: newMru }
|
||||
})
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId: string, content: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => {
|
||||
if (t.id !== tabId) return t
|
||||
// A3: 内容未变时不变更对象引用(避免误标记 isModified)
|
||||
if (t.content === content) return t
|
||||
return { ...t, content, isModified: true }
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, isModified: modified } : t)),
|
||||
}))
|
||||
},
|
||||
|
||||
// B2-fix: 另存为成功后重新绑定标签的文件路径(并触发快照持久化)
|
||||
updateTabFilePath: (tabId: string, filePath: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, filePath } : t)),
|
||||
}))
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (
|
||||
tabId: string,
|
||||
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
|
||||
) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, ...scroll } : t)),
|
||||
}))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
|
||||
export function flushSaveToDB(): Promise<void> {
|
||||
_debouncedSaveToDB?.cancel()
|
||||
return getActualSaveToDB(useTabStore.getState)()
|
||||
}
|
||||
|
||||
+201
-590
@@ -121,234 +121,27 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Editor Toolbar */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor-toolbar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn-sm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-btn-sm:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar-divider-sm {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Milkdown Wrapper */
|
||||
.milkdown-wrapper {
|
||||
/* MetonaEditor Wrapper */
|
||||
.metona-editor-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown {
|
||||
height: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
padding: 12px 16px;
|
||||
outline: none;
|
||||
/* FIX: Allow text selection in the preview pane.
|
||||
global.css sets user-select:none on html/body to prevent
|
||||
selection in UI chrome (toolbar, tabs, sidebar), but this
|
||||
also blocked mouse-drag selection in the Markdown preview.
|
||||
Override back to the browser default for the preview area. */
|
||||
.metona-editor-wrapper .me-preview,
|
||||
.metona-editor-wrapper .me-preview * {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor h1,
|
||||
.milkdown-wrapper .milkdown .editor h2,
|
||||
.milkdown-wrapper .milkdown .editor h3,
|
||||
.milkdown-wrapper .milkdown .editor h4,
|
||||
.milkdown-wrapper .milkdown .editor h5,
|
||||
.milkdown-wrapper .milkdown .editor h6 {
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor h1 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
.milkdown-wrapper .milkdown .editor h2 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.milkdown-wrapper .milkdown .editor h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 12px;
|
||||
margin: 0.5em 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor code {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor pre {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor ul,
|
||||
.milkdown-wrapper .milkdown .editor ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ProseMirror selection */
|
||||
.milkdown-wrapper .milkdown .editor .ProseMirror-selectednode {
|
||||
outline: 2px solid var(--primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.milkdown-wrapper .milkdown .editor ::selection {
|
||||
background: rgba(26, 115, 232, 0.3);
|
||||
}
|
||||
|
||||
:root.dark .milkdown-wrapper .milkdown .editor ::selection {
|
||||
background: rgba(100, 160, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ProseMirror cursor */
|
||||
.milkdown-wrapper .milkdown .editor .ProseMirror-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Dark mode styles for Milkdown */
|
||||
.milkdown-wrapper.milkdown-dark .milkdown {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.milkdown-wrapper.milkdown-dark .milkdown .editor blockquote {
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Preview Panel */
|
||||
#preview-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#preview {
|
||||
padding: 24px 32px;
|
||||
/* Also restore selection in the editor textarea (MetonaEditor
|
||||
explicitly sets user-select:none on it in some themes) */
|
||||
.metona-editor-wrapper textarea {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Source Editor */
|
||||
.source-editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.source-editor-textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
padding: 16px 20px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
color: #1e1e1e;
|
||||
background: #fafafa;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.source-editor-dark .source-editor-textarea {
|
||||
color: #d4d4d4;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
@@ -571,53 +364,21 @@ body {
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
#statusbar {
|
||||
height: var(--statusbar-height);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-divider {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.status-stat {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-auto-save {
|
||||
white-space: nowrap;
|
||||
/* Toolbar auto-save button */
|
||||
.toolbar-autosave {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.2s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
padding: 0;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.status-auto-save:hover {
|
||||
.toolbar-autosave:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.status-auto-save.saving {
|
||||
color: var(--primary, #1a73e8);
|
||||
.toolbar-autosave.saving {
|
||||
color: var(--primary);
|
||||
animation: auto-save-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -990,14 +751,6 @@ body {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* View Modes */
|
||||
#app.mode-preview #editor-panel {
|
||||
display: none;
|
||||
}
|
||||
#app.mode-editor #preview-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -1153,187 +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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Status bar loading indicator */
|
||||
.status-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-loading .loading-spinner-svg {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== UX-07: 通用可访问性增强 ===== */
|
||||
|
||||
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
||||
@@ -1370,6 +942,188 @@ button:focus-visible,
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ===== v0.6.2: 多文件搜索面板 ===== */
|
||||
.search-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
padding-top: 8vh;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
max-height: 76vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-run-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-run-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-option input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-close-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-close-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-summary {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.search-result-file {
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-result-line {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search-result-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ===== ErrorBoundary ===== */
|
||||
.error-boundary-root {
|
||||
display: flex;
|
||||
@@ -1420,149 +1174,6 @@ button:focus-visible,
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ===== Search & Replace Panel ===== */
|
||||
|
||||
.search-replace-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
animation: slideDown 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-count {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* D6: 正则语法错误提示 */
|
||||
.search-input-group .search-count {
|
||||
color: #c5221f;
|
||||
}
|
||||
.search-input-group .search-count:not(:empty) {
|
||||
/* 仅当有正则错误时变红 */
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-btn.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.search-close:hover {
|
||||
background: #e74c3c20;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.replace-btn {
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Search match highlights */
|
||||
.search-match-highlight {
|
||||
background: rgba(255, 213, 0, 0.4);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-match-active {
|
||||
background: rgba(255, 150, 0, 0.6);
|
||||
outline: 1px solid rgba(255, 150, 0, 0.8);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:root.dark .search-match-highlight {
|
||||
background: rgba(255, 213, 0, 0.25);
|
||||
}
|
||||
|
||||
:root.dark .search-match-active {
|
||||
background: rgba(255, 180, 0, 0.4);
|
||||
outline-color: rgba(255, 180, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ===== Outline Panel (Table of Contents) ===== */
|
||||
|
||||
.sidebar-outline-section {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -23,23 +23,6 @@
|
||||
--sidebar-active: var(--primary-light);
|
||||
--search-bg: var(--bg-secondary);
|
||||
--search-border: var(--border);
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--primary: #8ab4f8;
|
||||
--primary-light: #1a3a5c;
|
||||
--primary-dark: #aecbfa;
|
||||
--bg: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-tertiary: #2d2d2d;
|
||||
--text: #d4d4d4;
|
||||
--text-secondary: #9e9e9e;
|
||||
--text-tertiary: #6e6e6e;
|
||||
--border: #3e3e3e;
|
||||
--border-light: #333333;
|
||||
--code-bg: #2d2d2d;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
SaveAsPayload,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult
|
||||
ReadDirTreeResult,
|
||||
SearchInDirPayload,
|
||||
SearchInDirResult,
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
@@ -24,6 +26,12 @@ export interface IpcInvokeMap {
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
'dir:search': [SearchInDirPayload, SearchInDirResult]
|
||||
'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 +52,16 @@ export interface ElectronAPI {
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
searchInDir: (payload: SearchInDirPayload) => Promise<SearchInDirResult>
|
||||
exportData: (
|
||||
content: string,
|
||||
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
|
||||
importData: () => Promise<{
|
||||
success: boolean
|
||||
canceled?: boolean
|
||||
content?: string
|
||||
error?: string
|
||||
}>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||
onDirChanged: (callback: () => void) => Unsubscribe
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||
|
||||
export interface Settings {
|
||||
darkMode: boolean
|
||||
themeMode: ThemeMode
|
||||
viewMode: ViewMode
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
darkMode: false,
|
||||
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.3.13'
|
||||
export const APP_VERSION = 'v0.6.2'
|
||||
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',
|
||||
])
|
||||
|
||||
@@ -15,12 +15,17 @@ export const IPC_CHANNELS = {
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
DIR_SEARCH: 'dir:search',
|
||||
|
||||
// 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]
|
||||
|
||||
@@ -63,3 +63,27 @@ export interface OpenFileError {
|
||||
}
|
||||
|
||||
export type OpenFileResponse = OpenFileResult | OpenFileError | null
|
||||
|
||||
// v0.6.2: 多文件搜索
|
||||
export interface SearchInDirPayload {
|
||||
dirPath: string
|
||||
query: string
|
||||
caseSensitive?: boolean
|
||||
useRegex?: boolean
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
filePath: string
|
||||
/** 1-based 行号 */
|
||||
line: number
|
||||
/** 匹配行内容(截断至 200 字符) */
|
||||
lineText: string
|
||||
}
|
||||
|
||||
export interface SearchInDirResult {
|
||||
success: boolean
|
||||
matches?: SearchMatch[]
|
||||
totalFiles?: number
|
||||
truncated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user