Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20eb39efc5 | ||
|
|
d6ae9e3c8e |
+19
-15
@@ -1,15 +1,19 @@
|
|||||||
# MarkLite npm 配置模板
|
# MarkLite npm 配置模板
|
||||||
# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
|
# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置)
|
||||||
#
|
#
|
||||||
# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
|
# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载:
|
||||||
# registry=https://registry.npmmirror.com
|
# registry=https://registry.npmmirror.com
|
||||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
#
|
#
|
||||||
# 配置说明:
|
# @metona-team 私有 registry(metona-editor / metona-toast / metona-sqlark):
|
||||||
# registry — npm 包下载源
|
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||||
# ELECTRON_MIRROR — Electron 二进制文件下载源
|
# //git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
||||||
# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
|
#
|
||||||
#
|
# 配置说明:
|
||||||
# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
|
# registry — npm 包下载源
|
||||||
# 详见 DEVSETUP.md
|
# ELECTRON_MIRROR — Electron 二进制文件下载源
|
||||||
|
# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源
|
||||||
|
#
|
||||||
|
# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||||
|
# 详见 DEVSETUP.md
|
||||||
|
|||||||
+123
-123
@@ -1,123 +1,123 @@
|
|||||||
# 贡献指南
|
# 贡献指南
|
||||||
|
|
||||||
感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。
|
感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。
|
||||||
|
|
||||||
## 开发环境
|
## 开发环境
|
||||||
|
|
||||||
1. **前置要求**
|
1. **前置要求**
|
||||||
- Node.js >= 18.x
|
- Node.js >= 18.x
|
||||||
- npm >= 9.x
|
- npm >= 9.x
|
||||||
- Windows 10/11 x64
|
- Windows 10/11 x64
|
||||||
|
|
||||||
2. **克隆并安装**
|
2. **克隆并安装**
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||||
cd MarkLite
|
cd MarkLite
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **启动开发**
|
3. **启动开发**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 代码规范
|
## 代码规范
|
||||||
|
|
||||||
- **TypeScript**: 严格模式,所有函数参数需显式类型标注
|
- **TypeScript**: 严格模式,所有函数参数需显式类型标注
|
||||||
- **ESLint**: 提交前确保 `npm run lint` 通过
|
- **ESLint**: 提交前确保 `npm run lint` 通过
|
||||||
- **Prettier**: 提交前确保 `npm run format:check` 通过
|
- **Prettier**: 提交前确保 `npm run format:check` 通过
|
||||||
- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck
|
- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck
|
||||||
|
|
||||||
## 分支策略
|
## 分支策略
|
||||||
|
|
||||||
- `master` — 稳定版本
|
- `master` — 稳定版本
|
||||||
- `dev` — 开发分支
|
- `dev` — 开发分支
|
||||||
- `feature/*` — 功能分支
|
- `feature/*` — 功能分支
|
||||||
- `fix/*` — 修复分支
|
- `fix/*` — 修复分支
|
||||||
|
|
||||||
## 提交规范
|
## 提交规范
|
||||||
|
|
||||||
使用语义化提交信息:
|
使用语义化提交信息:
|
||||||
|
|
||||||
```
|
```
|
||||||
<type>(<scope>): <description>
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
```
|
```
|
||||||
|
|
||||||
类型(type):
|
类型(type):
|
||||||
- `feat`: 新功能
|
- `feat`: 新功能
|
||||||
- `fix`: 修复 Bug
|
- `fix`: 修复 Bug
|
||||||
- `docs`: 文档变更
|
- `docs`: 文档变更
|
||||||
- `style`: 代码格式(不影响逻辑)
|
- `style`: 代码格式(不影响逻辑)
|
||||||
- `refactor`: 重构
|
- `refactor`: 重构
|
||||||
- `perf`: 性能优化
|
- `perf`: 性能优化
|
||||||
- `test`: 测试相关
|
- `test`: 测试相关
|
||||||
- `chore`: 构建/工具链变更
|
- `chore`: 构建/工具链变更
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
```
|
```
|
||||||
feat(editor): 添加代码折叠功能
|
feat(editor): 添加代码折叠功能
|
||||||
fix(tab): 修复关闭标签后焦点丢失问题
|
fix(tab): 修复关闭标签后焦点丢失问题
|
||||||
perf(markdown): 引入 processor LRU 缓存
|
perf(markdown): 引入 processor LRU 缓存
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request 流程
|
## Pull Request 流程
|
||||||
|
|
||||||
1. Fork 仓库并创建功能分支
|
1. Fork 仓库并创建功能分支
|
||||||
2. 确保所有检查通过:
|
2. 确保所有检查通过:
|
||||||
```bash
|
```bash
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run typecheck # TypeScript 类型检查
|
npm run typecheck # TypeScript 类型检查
|
||||||
npm run test # 单元测试
|
npm run test # 单元测试
|
||||||
```
|
```
|
||||||
3. 编写清晰的 PR 描述,说明变更内容和原因
|
3. 编写清晰的 PR 描述,说明变更内容和原因
|
||||||
4. 等待代码审查
|
4. 等待代码审查
|
||||||
|
|
||||||
## 添加测试
|
## 添加测试
|
||||||
|
|
||||||
- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx`
|
- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx`
|
||||||
- 使用 Vitest + React Testing Library
|
- 使用 Vitest + React Testing Library
|
||||||
- 至少覆盖 stores、hooks、lib 目录下的核心模块
|
- 至少覆盖 stores、hooks、lib 目录下的核心模块
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 运行测试
|
# 运行测试
|
||||||
npm run test
|
npm run test
|
||||||
|
|
||||||
# 监听模式
|
# 监听模式
|
||||||
npm run test:watch
|
npm run test:watch
|
||||||
|
|
||||||
# 覆盖率报告
|
# 覆盖率报告
|
||||||
npm run test:coverage
|
npm run test:coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main/ # 主进程 (Node.js)
|
├── main/ # 主进程 (Node.js)
|
||||||
├── preload/ # 预加载脚本
|
├── preload/ # 预加载脚本
|
||||||
├── renderer/ # 渲染进程 (React)
|
├── renderer/ # 渲染进程 (React)
|
||||||
│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等)
|
│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等)
|
||||||
│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore)
|
│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore)
|
||||||
│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等)
|
│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等)
|
||||||
│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast)
|
│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast)
|
||||||
│ ├── db/ # IndexedDB 持久化(schema · repositories)
|
│ ├── db/ # IndexedDB 持久化(schema · repositories)
|
||||||
│ ├── types/ # TypeScript 类型定义
|
│ ├── types/ # TypeScript 类型定义
|
||||||
│ └── styles/ # 全局样式(variables · global · markdown-body)
|
│ └── styles/ # 全局样式(variables · global · markdown-body)
|
||||||
└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量)
|
└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 编辑器架构
|
### 编辑器架构
|
||||||
|
|
||||||
MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心:
|
MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心:
|
||||||
|
|
||||||
- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换
|
- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换
|
||||||
- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮
|
- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮
|
||||||
- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片)
|
- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片)
|
||||||
- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage)
|
- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage)
|
||||||
- **主题同步**:应用暗色模式与编辑器主题双向同步
|
- **主题同步**:应用暗色模式与编辑器主题双向同步
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
||||||
|
|||||||
@@ -1,420 +1,433 @@
|
|||||||
# MarkLite v0.4.3 — 架构设计文档
|
# MarkLite v0.4.3 — 架构设计文档
|
||||||
|
|
||||||
## 1. 项目概述
|
## 1. 项目概述
|
||||||
|
|
||||||
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
|
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
|
||||||
|
|
||||||
### 1.1 核心原则
|
### 1.1 核心原则
|
||||||
|
|
||||||
1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义
|
1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义
|
||||||
2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types)
|
2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types)
|
||||||
3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize
|
3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize
|
||||||
4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦
|
4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦
|
||||||
|
|
||||||
## 2. 技术架构
|
## 2. 技术架构
|
||||||
|
|
||||||
### 2.1 技术栈
|
### 2.1 技术栈
|
||||||
|
|
||||||
| 组件 | 技术 | 版本 | 说明 |
|
| 组件 | 技术 | 版本 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
||||||
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
||||||
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
||||||
| 编辑器 | MetonaEditor | v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
| 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
||||||
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
||||||
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
|
| 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件(AriaEngine) |
|
||||||
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) |
|
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) |
|
||||||
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
|
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
|
||||||
| Toast | @metona-team/metona-toast | v2.0.1 | 通知提示组件 |
|
| Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 |
|
||||||
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
|
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
|
||||||
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
|
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
|
||||||
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
||||||
|
|
||||||
### 2.2 进程架构
|
### 2.2 进程架构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ Main Process (src/main/) 5 文件 │
|
│ Main Process (src/main/) 5 文件 │
|
||||||
│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │
|
│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │
|
||||||
│ ipc-handlers.ts 所有 ipcMain.handle 注册 │
|
│ ipc-handlers.ts 所有 ipcMain.handle 注册 │
|
||||||
│ file-system.ts 文件读写、目录树构建、BOM 剥离 │
|
│ file-system.ts 文件读写、目录树构建、BOM 剥离 │
|
||||||
│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │
|
│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │
|
||||||
│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │
|
│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │
|
||||||
└───────────────────────┬──────────────────────────────────────┘
|
└───────────────────────┬──────────────────────────────────────┘
|
||||||
│ contextBridge (安全隔离)
|
│ contextBridge (安全隔离)
|
||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
│ Preload Script (src/preload/) 1 文件 │
|
│ Preload Script (src/preload/) 1 文件 │
|
||||||
│ index.ts contextBridge 类型安全暴露 │
|
│ index.ts contextBridge 类型安全暴露 │
|
||||||
│ electronAPI 22 个方法/事件的类型安全接口 │
|
│ electronAPI 22 个方法/事件的类型安全接口 │
|
||||||
└───────────────────────┬──────────────────────────────────────┘
|
└───────────────────────┬──────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
│ Renderer Process (src/renderer/) React 18 │
|
│ Renderer Process (src/renderer/) React 18 │
|
||||||
│ │
|
│ │
|
||||||
│ components/ Toolbar · TabBar · Editor · Sidebar │
|
│ components/ Toolbar · TabBar · Editor · Sidebar │
|
||||||
│ FileTree · OutlinePanel · WelcomeScreen │
|
│ FileTree · OutlinePanel · WelcomeScreen │
|
||||||
│ ConfirmDialog · ModifiedBanner │
|
│ ConfirmDialog · ModifiedBanner │
|
||||||
│ DropOverlay · ErrorBoundary · AboutDialog│
|
│ DropOverlay · ErrorBoundary · AboutDialog│
|
||||||
│ LoadingSpinner · Icons │
|
│ LoadingSpinner · Icons │
|
||||||
│ │
|
│ │
|
||||||
│ stores/ (4) tabStore · editorStore · sidebarStore │
|
│ stores/ (4) tabStore · editorStore · sidebarStore │
|
||||||
│ autoSaveStore │
|
│ autoSaveStore │
|
||||||
│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │
|
│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │
|
||||||
│ useDragDrop · useFileWatch · useAutoSave │
|
│ useDragDrop · useFileWatch · useAutoSave │
|
||||||
│ useIpcListeners · useFileOperations ... │
|
│ useIpcListeners · useFileOperations ... │
|
||||||
│ lib/ (4) markdown · fileUtils · errorHandler │
|
│ lib/ (4) markdown · fileUtils · errorHandler │
|
||||||
│ toast · constants │
|
│ toast · constants │
|
||||||
│ db/ (4) schema · tabRepository · settingsRepo │
|
│ db/ (4) schema · tabRepository · settingsRepo │
|
||||||
│ recentFilesRepository │
|
│ recentFilesRepository │
|
||||||
│ types/ (5) tab · file · settings · ipc · index │
|
│ types/ (5) tab · file · settings · ipc · index │
|
||||||
│ styles/ (3) variables · global · markdown-body │
|
│ styles/ (3) variables · global · markdown-body │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ Shared (src/shared/) 3 文件 │
|
│ Shared (src/shared/) 3 文件 │
|
||||||
│ ipc-channels.ts IPC 通道名常量 │
|
│ ipc-channels.ts IPC 通道名常量 │
|
||||||
│ types.ts 共享类型定义 │
|
│ types.ts 共享类型定义 │
|
||||||
│ constants.ts 共享常量 (版本号、文件大小限制等) │
|
│ constants.ts 共享常量 (版本号、文件大小限制等) │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 安全模型
|
### 2.3 安全模型
|
||||||
|
|
||||||
| 层级 | 措施 | 说明 |
|
| 层级 | 措施 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
||||||
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
||||||
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
||||||
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
||||||
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
||||||
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
||||||
| 路径 | `validatePath()` | 防止路径遍历攻击 |
|
| 路径 | `validatePath()` | 防止路径遍历攻击 |
|
||||||
|
|
||||||
## 3. 状态管理架构
|
## 3. 状态管理架构
|
||||||
|
|
||||||
### 3.1 Zustand Stores
|
### 3.1 Zustand Stores
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ App.tsx (根组件) │
|
│ App.tsx (根组件) │
|
||||||
├─────────┬──────────┬──────────┬──────────────────────────┤
|
├─────────┬──────────┬──────────┬──────────────────────────┤
|
||||||
│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │
|
│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │
|
||||||
├─────────┴──────────┴──────────┴──────────────────────────┤
|
├─────────┴──────────┴──────────┴──────────────────────────┤
|
||||||
│ Zustand Stores │
|
│ Zustand Stores │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │
|
│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │
|
||||||
│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │
|
│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │
|
||||||
│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │
|
│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │
|
||||||
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
||||||
│ │ IndexedDB (Dexie.js) │ │
|
│ │ IndexedDB (MetonaSqlark) │ │
|
||||||
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
||||||
│ └───────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 tabStore — 标签页状态
|
### 3.2 tabStore — 标签页状态
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface TabState {
|
interface TabState {
|
||||||
tabs: Tab[] // 所有标签页
|
tabs: Tab[] // 所有标签页
|
||||||
activeTabId: string | null // 当前活动标签 ID
|
activeTabId: string | null // 当前活动标签 ID
|
||||||
mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换)
|
mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换)
|
||||||
|
|
||||||
createTab(filePath?, content?) // 创建标签(同文件不重复打开)
|
createTab(filePath?, content?) // 创建标签(同文件不重复打开)
|
||||||
closeTab(tabId) // 关闭标签(自动切换到相邻标签)
|
closeTab(tabId) // 关闭标签(自动切换到相邻标签)
|
||||||
switchToTab(tabId) // 切换标签
|
switchToTab(tabId) // 切换标签
|
||||||
updateTabContent(tabId, content) // 更新内容(标记修改)
|
updateTabContent(tabId, content) // 更新内容(标记修改)
|
||||||
setModified(tabId, modified) // 设置修改状态
|
setModified(tabId, modified) // 设置修改状态
|
||||||
getActiveTab() // 获取当前标签
|
getActiveTab() // 获取当前标签
|
||||||
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
||||||
saveToDB() // 保存到 IndexedDB(防抖 500ms)
|
saveToDB() // 保存到 IndexedDB(防抖 500ms)
|
||||||
loadFromDB() // 从 IndexedDB 加载
|
loadFromDB() // 从 IndexedDB 加载
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 editorStore — 编辑器状态
|
### 3.3 editorStore — 编辑器状态
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
||||||
darkMode: boolean // 暗色主题
|
darkMode: boolean // 暗色主题
|
||||||
externallyModified: { filePath: string } | null // 外部修改检测状态
|
externallyModified: { filePath: string } | null // 外部修改检测状态
|
||||||
loadingStates: Record<string, boolean> // 全局加载状态
|
loadingStates: Record<string, boolean> // 全局加载状态
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例
|
// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例
|
||||||
function getMetonaEditor(): MarkdownEditor | null
|
function getMetonaEditor(): MarkdownEditor | null
|
||||||
function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void
|
function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 sidebarStore — 侧边栏状态
|
### 3.4 sidebarStore — 侧边栏状态
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
isVisible: boolean // 是否显示
|
isVisible: boolean // 是否显示
|
||||||
rootPath: string | null // 当前打开的文件夹路径
|
rootPath: string | null // 当前打开的文件夹路径
|
||||||
tree: FileNode[] // 目录树数据
|
tree: FileNode[] // 目录树数据
|
||||||
expandedDirs: string[] // 已展开的目录集合
|
expandedDirs: string[] // 已展开的目录集合
|
||||||
sidebarWidth: number // 侧边栏宽度 (180~500)
|
sidebarWidth: number // 侧边栏宽度 (180~500)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 数据持久化 — IndexedDB
|
## 4. 数据持久化 — IndexedDB
|
||||||
|
|
||||||
通过 Dexie.js 封装 IndexedDB:
|
v0.5.0: 由 Dexie.js 迁移至 MetonaSqlark(AriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
|
||||||
|
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
|
||||||
### 4.1 数据库 Schema
|
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
|
||||||
|
|
||||||
```typescript
|
### 4.1 数据库 Schema
|
||||||
const db = new Dexie('MarkLite')
|
|
||||||
|
```typescript
|
||||||
db.version(1).stores({
|
// schema.ts — 懒加载单例
|
||||||
tabSnapshots: 'id, filePath, updatedAt', // 标签页快照
|
let dbPromise: Promise<MetonaSqlark> | null = null
|
||||||
settings: 'id', // 用户设置
|
export function getDb(): Promise<MetonaSqlark> {
|
||||||
recentFiles: '++id, filePath, lastOpened', // 最近打开文件
|
if (!dbPromise) {
|
||||||
activeTab: 'id' // 当前活动标签
|
dbPromise = MetonaSqlark.create({
|
||||||
})
|
name: 'MarkLiteV2',
|
||||||
```
|
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||||
|
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||||
### 4.2 数据模型
|
version: 1,
|
||||||
|
}).then(async (db) => {
|
||||||
| Store | 字段 | 说明 |
|
if (!(await db.getTableNames()).includes('tabSnapshots')) {
|
||||||
|-------|------|------|
|
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
|
||||||
| `tabSnapshots` | id, filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt | 标签页状态快照 |
|
}
|
||||||
| `settings` | id, darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
// settings / recentFiles / activeTab 同理
|
||||||
| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 |
|
return db
|
||||||
| `activeTab` | id, activeTabId | 当前活动标签 ID |
|
})
|
||||||
|
}
|
||||||
## 5. IPC 通信设计
|
return dbPromise
|
||||||
|
}
|
||||||
### 5.1 渲染进程 → 主进程(invoke)
|
```
|
||||||
|
|
||||||
| 通道 | 参数 | 返回值 | 说明 |
|
### 4.2 数据模型
|
||||||
|------|------|--------|------|
|
|
||||||
| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 |
|
| Store | 字段 | 说明 |
|
||||||
| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 |
|
|-------|------|------|
|
||||||
| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 |
|
| `tabSnapshots` | id(PK), filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt(index) | 标签页状态快照 |
|
||||||
| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 |
|
| `settings` | id(PK), themeMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
||||||
| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 |
|
| `recentFiles` | filePath(PK), lastOpened(index) | 最近打开文件列表(v0.5.0 改用 filePath 主键,sqlark 无自增) |
|
||||||
| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 |
|
| `activeTab` | id(PK), activeTabId | 当前活动标签 ID |
|
||||||
| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 |
|
|
||||||
| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 |
|
## 5. IPC 通信设计
|
||||||
| `window:forceClose` | 无 | `void` | 强制关闭窗口 |
|
|
||||||
| `window:cancelClose` | 无 | `void` | 取消关闭 |
|
### 5.1 渲染进程 → 主进程(invoke)
|
||||||
| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 |
|
|
||||||
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
|
| 通道 | 参数 | 返回值 | 说明 |
|
||||||
| `dir:watch` | `dirPath` | `void` | 监听目录变化 |
|
|------|------|--------|------|
|
||||||
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
|
| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 |
|
||||||
|
| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 |
|
||||||
### 5.2 主进程 → 渲染进程(send)
|
| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 |
|
||||||
|
| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 |
|
||||||
| 通道 | 数据 | 说明 |
|
| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 |
|
||||||
|------|------|------|
|
| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 |
|
||||||
| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 |
|
| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 |
|
||||||
| `file:externallyModified` | `filePath` | 文件被外部修改 |
|
| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 |
|
||||||
| `window:confirmClose` | 无 | 请求确认关闭 |
|
| `window:forceClose` | 无 | `void` | 强制关闭窗口 |
|
||||||
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
| `window:cancelClose` | 无 | `void` | 取消关闭 |
|
||||||
|
| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 |
|
||||||
## 6. 编辑器架构 — MetonaEditor v0.1.14
|
| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 |
|
||||||
|
| `dir:watch` | `dirPath` | `void` | 监听目录变化 |
|
||||||
### 6.1 三模式视图
|
| `dir:unwatch` | 无 | `void` | 停止监听目录变化 |
|
||||||
|
|
||||||
- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
|
### 5.2 主进程 → 渲染进程(send)
|
||||||
- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
|
|
||||||
- **预览模式 (preview)**:仅显示渲染后的 HTML
|
| 通道 | 数据 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。
|
| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 |
|
||||||
|
| `file:externallyModified` | `filePath` | 文件被外部修改 |
|
||||||
### 6.2 内置功能
|
| `window:confirmClose` | 无 | 请求确认关闭 |
|
||||||
|
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
||||||
| 功能 | 说明 |
|
|
||||||
|------|------|
|
## 6. 编辑器架构 — MetonaEditor v0.1.14
|
||||||
| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr |
|
|
||||||
| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 |
|
### 6.1 三模式视图
|
||||||
| 历史栈 | undo / redo,防抖合并,可配置上限 |
|
|
||||||
| 主题 | light / dark / auto / warm,CSS 变量驱动 |
|
- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
|
||||||
| 国际化 | zh-CN / en-US 完整翻译 |
|
- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
|
||||||
| 全屏模式 | 编辑器全屏展示 |
|
- **预览模式 (preview)**:仅显示渲染后的 HTML
|
||||||
|
|
||||||
### 6.3 插件体系
|
MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。
|
||||||
|
|
||||||
通过 `plugins` 配置数组安装 MetonaEditor 预设插件:
|
### 6.2 内置功能
|
||||||
|
|
||||||
| 插件 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
|
| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr |
|
||||||
| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
|
| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 |
|
||||||
|
| 历史栈 | undo / redo,防抖合并,可配置上限 |
|
||||||
> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
|
| 主题 | light / dark / auto / warm,CSS 变量驱动 |
|
||||||
|
| 国际化 | zh-CN / en-US 完整翻译 |
|
||||||
### 6.4 渲染管线集成
|
| 全屏模式 | 编辑器全屏展示 |
|
||||||
|
|
||||||
通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现:
|
### 6.3 插件体系
|
||||||
|
|
||||||
- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径
|
通过 `plugins` 配置数组安装 MetonaEditor 预设插件:
|
||||||
- **XSS 防护**:rehype-sanitize 过滤危险标签
|
|
||||||
- **代码高亮**:rehype-highlight 语法高亮
|
| 插件 | 说明 |
|
||||||
- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶
|
|------|------|
|
||||||
|
| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
|
||||||
```
|
| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
|
||||||
Markdown 源码
|
|
||||||
│
|
> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
|
||||||
▼
|
|
||||||
unified 管线(renderMarkdownSync)
|
### 6.4 渲染管线集成
|
||||||
├── remark-parse 解析为 MDAST
|
|
||||||
├── remark-gfm GFM 扩展
|
通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现:
|
||||||
├── remark-rehype 转换为 HAST
|
|
||||||
├── rehype-raw 解析内联 HTML
|
- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径
|
||||||
├── rehype-sanitize 安全过滤
|
- **XSS 防护**:rehype-sanitize 过滤危险标签
|
||||||
├── rehype-fixImages 相对路径 → file://
|
- **代码高亮**:rehype-highlight 语法高亮
|
||||||
├── rehype-highlight 代码高亮
|
- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶
|
||||||
└── rehype-stringify 序列化为 HTML
|
|
||||||
│
|
```
|
||||||
▼
|
Markdown 源码
|
||||||
MetonaEditor 预览区渲染
|
│
|
||||||
```
|
▼
|
||||||
|
unified 管线(renderMarkdownSync)
|
||||||
### 6.5 主题切换
|
├── remark-parse 解析为 MDAST
|
||||||
|
├── remark-gfm GFM 扩展
|
||||||
MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程:
|
├── remark-rehype 转换为 HAST
|
||||||
|
├── rehype-raw 解析内联 HTML
|
||||||
1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage
|
├── rehype-sanitize 安全过滤
|
||||||
2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`)
|
├── rehype-fixImages 相对路径 → file://
|
||||||
3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题
|
├── rehype-highlight 代码高亮
|
||||||
|
└── rehype-stringify 序列化为 HTML
|
||||||
### 6.6 内容同步
|
│
|
||||||
|
▼
|
||||||
- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified`
|
MetonaEditor 预览区渲染
|
||||||
- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange
|
```
|
||||||
- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置
|
|
||||||
|
### 6.5 主题切换
|
||||||
## 7. Markdown 渲染管线
|
|
||||||
|
MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程:
|
||||||
```
|
|
||||||
Markdown 文本
|
1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage
|
||||||
│
|
2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`)
|
||||||
▼
|
3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题
|
||||||
remark-parse 解析为 MDAST
|
|
||||||
│
|
### 6.6 内容同步
|
||||||
▼
|
|
||||||
remark-gfm 扩展 GFM 语法
|
- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified`
|
||||||
│
|
- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange
|
||||||
▼
|
- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置
|
||||||
remark-rehype 转换为 HAST
|
|
||||||
│
|
## 7. Markdown 渲染管线
|
||||||
▼
|
|
||||||
rehype-raw 解析内联 HTML
|
```
|
||||||
│
|
Markdown 文本
|
||||||
▼
|
│
|
||||||
rehype-sanitize 安全过滤
|
▼
|
||||||
│
|
remark-parse 解析为 MDAST
|
||||||
▼
|
│
|
||||||
rehype-fixImages 相对路径图片转 file:// URL
|
▼
|
||||||
│
|
remark-gfm 扩展 GFM 语法
|
||||||
▼
|
│
|
||||||
rehype-highlight 代码语法高亮
|
▼
|
||||||
│
|
remark-rehype 转换为 HAST
|
||||||
▼
|
│
|
||||||
rehype-stringify 序列化为 HTML
|
▼
|
||||||
│
|
rehype-raw 解析内联 HTML
|
||||||
▼
|
│
|
||||||
Renderer (MetonaEditor preview / Preview component)
|
▼
|
||||||
```
|
rehype-sanitize 安全过滤
|
||||||
|
│
|
||||||
## 8. UI 设计
|
▼
|
||||||
|
rehype-fixImages 相对路径图片转 file:// URL
|
||||||
### 8.1 色彩方案
|
│
|
||||||
|
▼
|
||||||
#### 亮色主题
|
rehype-highlight 代码语法高亮
|
||||||
|
│
|
||||||
| 角色 | CSS 变量 | 色值 |
|
▼
|
||||||
|------|----------|------|
|
rehype-stringify 序列化为 HTML
|
||||||
| 主色调 | `--primary` | `#1a73e8` |
|
│
|
||||||
| 背景色 | `--bg` | `#ffffff` |
|
▼
|
||||||
| 次级背景 | `--bg-secondary` | `#f8f9fa` |
|
Renderer (MetonaEditor preview / Preview component)
|
||||||
| 文字色 | `--text` | `#333333` |
|
```
|
||||||
| 边框色 | `--border` | `#e1e4e8` |
|
|
||||||
|
## 8. UI 设计
|
||||||
#### 暗色主题
|
|
||||||
|
### 8.1 色彩方案
|
||||||
| 角色 | CSS 变量 | 色值 |
|
|
||||||
|------|----------|------|
|
#### 亮色主题
|
||||||
| 主色调 | `--primary` | `#8ab4f8` |
|
|
||||||
| 背景色 | `--bg` | `#1e1e1e` |
|
| 角色 | CSS 变量 | 色值 |
|
||||||
| 次级背景 | `--bg-secondary` | `#252526` |
|
|------|----------|------|
|
||||||
| 文字色 | `--text` | `#d4d4d4` |
|
| 主色调 | `--primary` | `#1a73e8` |
|
||||||
| 边框色 | `--border` | `#3e3e3e` |
|
| 背景色 | `--bg` | `#ffffff` |
|
||||||
|
| 次级背景 | `--bg-secondary` | `#f8f9fa` |
|
||||||
### 8.2 布局
|
| 文字色 | `--text` | `#333333` |
|
||||||
|
| 边框色 | `--border` | `#e1e4e8` |
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────────────┐
|
#### 暗色主题
|
||||||
│ MarkLite - filename.md ─ □ ✕ │
|
|
||||||
├──────────────────────────────────────────────────────────────────────────┤
|
| 角色 | CSS 变量 | 色值 |
|
||||||
│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │
|
|------|----------|------|
|
||||||
├──────────────────────────────────────────────────────────────────────────┤
|
| 主色调 | `--primary` | `#8ab4f8` |
|
||||||
│ [file1.md] [file2.md] [未命名] [+] │
|
| 背景色 | `--bg` | `#1e1e1e` |
|
||||||
├──────────┬───────────────────────────────────────────────────────────────┤
|
| 次级背景 | `--bg-secondary` | `#252526` |
|
||||||
│ 资源管理器 │ │
|
| 文字色 | `--text` | `#d4d4d4` |
|
||||||
│ ▼ project │ ┌─────────────────────────────────────────────┐ │
|
| 边框色 | `--border` | `#3e3e3e` |
|
||||||
│ 📁 src │ │ B I S U </> │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │
|
|
||||||
│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │
|
### 8.2 布局
|
||||||
│ 📄 file2│ ├─────────────────────────────────────────────┤ │
|
|
||||||
│ │ │ # Title │ Title │ │
|
```
|
||||||
│ 文档大纲 │ │ │ ─────── │ │
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ · Title │ │ content... │ content... │ │
|
│ MarkLite - filename.md ─ □ ✕ │
|
||||||
├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
|
│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │
|
||||||
└──────────────────────────────────────────────────────────────────────────┘
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
```
|
│ [file1.md] [file2.md] [未命名] [+] │
|
||||||
|
├──────────┬───────────────────────────────────────────────────────────────┤
|
||||||
## 9. 构建与发布
|
│ 资源管理器 │ │
|
||||||
|
│ ▼ project │ ┌─────────────────────────────────────────────┐ │
|
||||||
### 9.1 开发模式
|
│ 📁 src │ │ B I S U </> │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │
|
||||||
|
│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │
|
||||||
```bash
|
│ 📄 file2│ ├─────────────────────────────────────────────┤ │
|
||||||
npm run dev # electron-vite dev(HMR 热更新)
|
│ │ │ # Title │ Title │ │
|
||||||
```
|
│ 文档大纲 │ │ │ ─────── │ │
|
||||||
|
│ · Title │ │ content... │ content... │ │
|
||||||
### 9.2 生产构建
|
├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
|
||||||
|
│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
|
||||||
```bash
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
npm run build # electron-vite build + electron-builder --win
|
```
|
||||||
npm run build:portable # 便携版(免安装)
|
|
||||||
```
|
## 9. 构建与发布
|
||||||
|
|
||||||
### 9.3 打包配置
|
### 9.1 开发模式
|
||||||
|
|
||||||
- 输出格式:NSIS 安装包(.exe)
|
```bash
|
||||||
- 目标平台:Windows x64
|
npm run dev # electron-vite dev(HMR 热更新)
|
||||||
- 应用图标:assets/icon.ico
|
```
|
||||||
- 文件关联:`.md` / `.markdown` / `.txt`
|
|
||||||
- 支持自定义安装目录、桌面/开始菜单快捷方式
|
### 9.2 生产构建
|
||||||
|
|
||||||
## 10. 依赖清单
|
```bash
|
||||||
|
npm run build # electron-vite build + electron-builder --win
|
||||||
### 运行时依赖
|
npm run build:portable # 便携版(免安装)
|
||||||
|
```
|
||||||
| 包名 | 版本 | 用途 |
|
|
||||||
|------|------|------|
|
### 9.3 打包配置
|
||||||
| react / react-dom | ^18.3 | UI 框架 |
|
|
||||||
| zustand | ^5.0 | 状态管理 |
|
- 输出格式:NSIS 安装包(.exe)
|
||||||
| dexie | ^4.0 | IndexedDB 封装 |
|
- 目标平台:Windows x64
|
||||||
| nanoid | ^5.0 | 唯一 ID 生成 |
|
- 应用图标:assets/icon.ico
|
||||||
| @metona-team/metona-editor | 0.1.14 | Markdown 编辑器(零依赖) |
|
- 文件关联:`.md` / `.markdown` / `.txt`
|
||||||
| @metona-team/metona-toast | ^2.0.1 | Toast 通知组件 |
|
- 支持自定义安装目录、桌面/开始菜单快捷方式
|
||||||
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
|
||||||
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
## 10. 依赖清单
|
||||||
|
|
||||||
### 开发依赖
|
### 运行时依赖
|
||||||
|
|
||||||
| 包名 | 版本 | 用途 |
|
| 包名 | 版本 | 用途 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| electron | ^28.0 | 桌面框架 |
|
| react / react-dom | ^18.3 | UI 框架 |
|
||||||
| electron-builder | ^25.0 | 打包工具 |
|
| zustand | ^5.0 | 状态管理 |
|
||||||
| electron-vite | ^3.0 | 构建工具 |
|
| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(IndexedDB) |
|
||||||
| typescript | ^5.6 | 类型系统 |
|
| nanoid | ^5.0 | 唯一 ID 生成 |
|
||||||
| eslint | ^9.0 | 代码检查 |
|
| @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(零依赖) |
|
||||||
|
| @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
|
||||||
|
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
||||||
|
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
||||||
|
|
||||||
|
### 开发依赖
|
||||||
|
|
||||||
|
| 包名 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| electron | ^28.0 | 桌面框架 |
|
||||||
|
| electron-builder | ^25.0 | 打包工具 |
|
||||||
|
| electron-vite | ^3.0 | 构建工具 |
|
||||||
|
| typescript | ^5.6 | 类型系统 |
|
||||||
|
| eslint | ^9.0 | 代码检查 |
|
||||||
|
|||||||
+93
-93
@@ -1,93 +1,93 @@
|
|||||||
# MarkLite v0.4.3 — 开发环境配置指南
|
# MarkLite v0.5.0 — 开发环境配置指南
|
||||||
|
|
||||||
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
||||||
|
|
||||||
## 一、Node 版本管理(nvm)
|
## 一、Node 版本管理(nvm)
|
||||||
|
|
||||||
安装 nvm-windows 后,使用以下命令管理 Node 版本:
|
安装 nvm-windows 后,使用以下命令管理 Node 版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装需要的版本(以 Node 18 为例)
|
# 安装需要的版本(以 Node 18 为例)
|
||||||
nvm install 18
|
nvm install 18
|
||||||
|
|
||||||
# 设为当前终端使用的版本
|
# 设为当前终端使用的版本
|
||||||
nvm use 18
|
nvm use 18
|
||||||
|
|
||||||
# 设为默认版本(新终端自动生效)
|
# 设为默认版本(新终端自动生效)
|
||||||
nvm alias default 18
|
nvm alias default 18
|
||||||
```
|
```
|
||||||
|
|
||||||
## 二、配置镜像源
|
## 二、配置镜像源
|
||||||
|
|
||||||
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
||||||
|
|
||||||
### 项目级 .npmrc(推荐)
|
### 项目级 .npmrc(推荐)
|
||||||
|
|
||||||
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
||||||
|
|
||||||
```
|
```
|
||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
@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/:_auth=<your_token>
|
||||||
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
||||||
|
|
||||||
# Electron 二进制下载镜像
|
# Electron 二进制下载镜像
|
||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
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`。
|
> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。
|
||||||
|
|
||||||
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
||||||
|
|
||||||
## 三、配置文件说明
|
## 三、配置文件说明
|
||||||
|
|
||||||
| 配置项 | 作用 |
|
| 配置项 | 作用 |
|
||||||
|:-------|:-----|
|
|:-------|:-----|
|
||||||
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
||||||
| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
|
| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
|
||||||
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
||||||
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
||||||
|
|
||||||
## 四、开始打包
|
## 四、开始打包
|
||||||
|
|
||||||
> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。
|
> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 清除旧缓存,确保不走之前的错误源
|
# 1. 清除旧缓存,确保不走之前的错误源
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# 2. 安装依赖(如果还没装或 node_modules 不完整)
|
# 2. 安装依赖(如果还没装或 node_modules 不完整)
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. 执行构建
|
# 3. 执行构建
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
构建成功后,安装包会生成在 `dist/` 目录下。
|
构建成功后,安装包会生成在 `dist/` 目录下。
|
||||||
|
|
||||||
## 五、一次性完整操作步骤总结
|
## 五、一次性完整操作步骤总结
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ① 确保 Node 版本正确
|
# ① 确保 Node 版本正确
|
||||||
nvm use 18
|
nvm use 18
|
||||||
|
|
||||||
# ② 在项目根目录创建 .npmrc,写入:
|
# ② 在项目根目录创建 .npmrc,写入:
|
||||||
# registry=https://registry.npmmirror.com
|
# registry=https://registry.npmmirror.com
|
||||||
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||||
# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<token>
|
# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<token>
|
||||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
# ③ 关闭当前终端,新开一个终端,然后执行:
|
# ③ 关闭当前终端,新开一个终端,然后执行:
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 六、常见问题排查
|
## 六、常见问题排查
|
||||||
|
|
||||||
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
||||||
- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。
|
- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。
|
||||||
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
||||||
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/Version-v0.4.3-orange?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/Version-v0.5.0-orange?style=flat-square" alt="Version">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
|
基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。<br>
|
||||||
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。
|
多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -30,16 +30,17 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
||||||
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
||||||
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 |
|
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 |
|
||||||
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 |
|
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 |
|
||||||
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
||||||
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
|
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
|
||||||
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB),编辑器主题同步切换 |
|
| 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
|
||||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||||
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
||||||
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
|
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
|
||||||
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
||||||
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
|
| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
|
||||||
|
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlark(AriaEngine)持久化,关闭后可恢复 |
|
||||||
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
||||||
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
||||||
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
|
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
|
||||||
@@ -134,7 +135,9 @@ npm run test:coverage
|
|||||||
| `Ctrl + H` | 搜索并替换 |
|
| `Ctrl + H` | 搜索并替换 |
|
||||||
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
|
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
|
||||||
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
|
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
|
||||||
|
| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) |
|
||||||
| `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
|
| `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
|
||||||
|
| `?` | 快捷键帮助面板(编辑器内置) |
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
@@ -143,12 +146,12 @@ npm run test:coverage
|
|||||||
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
||||||
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
||||||
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
||||||
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 浮动格式栏 + 插件系统 |
|
||||||
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
||||||
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
|
| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.1 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
|
||||||
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
|
| 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 的语法高亮 |
|
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
|
||||||
| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v2.0.1 | 通知提示组件 |
|
| 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-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
|
||||||
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
|
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
|
||||||
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
||||||
@@ -223,8 +226,8 @@ MarkLite/
|
|||||||
│ │ │ ├── toast.ts # Toast 通知(MetonaToast)
|
│ │ │ ├── toast.ts # Toast 通知(MetonaToast)
|
||||||
│ │ │ └── constants.ts # 常量定义
|
│ │ │ └── constants.ts # 常量定义
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── db/ # IndexedDB 持久化层
|
│ │ ├── db/ # MetonaSqlark 持久化层
|
||||||
│ │ │ ├── schema.ts # Dexie 数据库定义
|
│ │ │ ├── schema.ts # 数据库定义(懒初始化单例)
|
||||||
│ │ │ ├── tabRepository.ts # 标签页 CRUD
|
│ │ │ ├── tabRepository.ts # 标签页 CRUD
|
||||||
│ │ │ ├── settingsRepository.ts # 设置 CRUD
|
│ │ │ ├── settingsRepository.ts # 设置 CRUD
|
||||||
│ │ │ └── recentFilesRepository.ts # 最近文件
|
│ │ │ └── recentFilesRepository.ts # 最近文件
|
||||||
@@ -259,6 +262,7 @@ MarkLite/
|
|||||||
- ✅ 引用块
|
- ✅ 引用块
|
||||||
- ✅ 水平线
|
- ✅ 水平线
|
||||||
- ✅ Emoji 短码 `:smile:` `:rocket:`
|
- ✅ Emoji 短码 `:smile:` `:rocket:`
|
||||||
|
- ✅ Mermaid 图表 ` ```mermaid `
|
||||||
- ✅ HTML 内联元素
|
- ✅ HTML 内联元素
|
||||||
- ✅ GFM(GitHub Flavored Markdown)
|
- ✅ GFM(GitHub Flavored Markdown)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||||
<title>MetonaEditor v0.2.4 — 全功能演示</title>
|
<title>MetonaEditor v0.4.0 — 全功能演示</title>
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
:root{--app-bg:#f5f6f8;--app-text:#1a1a2e;--app-card:#fff;--app-border:#e5e7eb;--app-accent:#3b82f6;--app-accent2:#8b5cf6;--header-bg:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);--header-text:#f1f5f9;--badge-bg:rgba(255,255,255,.12);--badge-text:#e2e8f0}
|
||||||
@@ -45,11 +45,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1><span class="grad">Metona</span>Editor v0.2.4</h1>
|
<h1><span class="grad">Metona</span>Editor v0.4.0</h1>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span class="badge">TypeScript</span>
|
<span class="badge">TypeScript</span>
|
||||||
<span class="badge">零运行时依赖</span>
|
<span class="badge">零运行时依赖</span>
|
||||||
<span class="badge">725 tests</span>
|
<span class="badge">826 tests</span>
|
||||||
<span class="badge">6 个插件</span>
|
<span class="badge">6 个插件</span>
|
||||||
<span class="badge">桌面端优先</span>
|
<span class="badge">桌面端优先</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,18 +80,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>12 个 .ts 源文件,严格模式,完整类型导出</p></div>
|
<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>自研解析器 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>File System Access API 打开 / 保存真实 .md 文件</p></div>
|
||||||
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
|
<div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
|
||||||
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div>
|
<div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,鼠标移到顶部滑入</p></div>
|
||||||
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
<div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
|
||||||
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
<div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
|
||||||
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
<div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
|
||||||
|
<div class="info-card"><div class="icon">⚡</div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
MetonaEditor v0.2.4 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
MetonaEditor v0.4.0 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
@@ -99,18 +101,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
|
|||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var demoMd=[
|
var demoMd=[
|
||||||
'# 🚀 MetonaEditor v0.2.4 全功能演示',
|
'# 🚀 MetonaEditor v0.4.0 全功能演示',
|
||||||
'',
|
'',
|
||||||
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
|
||||||
'> 全模块 TypeScript 严格模式,12 个源文件,684 个测试全部通过。',
|
'> 全模块 TypeScript 严格模式,17 个源文件,826 个测试全部通过。',
|
||||||
'',
|
'',
|
||||||
'## ✨ v0.2.4 新特性',
|
'## ✨ v0.4.0 新特性',
|
||||||
'',
|
'',
|
||||||
'- 浮动格式工具栏:选中文本自动弹出 bold/italic/code/link',
|
'- **Playwright 浏览器冒烟测试**:22 项断言真实 Chromium 验证',
|
||||||
'- 新增 `getSelectedText()` `getCursorPosition()` 等 10+ API',
|
'- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
|
||||||
'- `replaceAll` / `replaceAllRegex` 批量替换',
|
'- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
|
||||||
'- `selectionChange` / `cursorMove` 事件',
|
|
||||||
'- 德语(de)翻译 · 6 种语言支持',
|
|
||||||
'',
|
'',
|
||||||
'## 📐 文本样式',
|
'## 📐 文本样式',
|
||||||
'',
|
'',
|
||||||
@@ -118,7 +118,7 @@ var demoMd=[
|
|||||||
'- ***粗斜体*** / ___另一种粗斜体___',
|
'- ***粗斜体*** / ___另一种粗斜体___',
|
||||||
'- *斜体* / _另一种斜体_',
|
'- *斜体* / _另一种斜体_',
|
||||||
'- ~~删除线~~ / ==高亮标记==',
|
'- ~~删除线~~ / ==高亮标记==',
|
||||||
'- 链接内格式: [**粗体链接**](https://example.com)',
|
'- <u>下划线</u>(Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
|
||||||
'- 化学:H~2~O / 数学:x^2^',
|
'- 化学:H~2~O / 数学:x^2^',
|
||||||
'- 代码:`const x = 42;`',
|
'- 代码:`const x = 42;`',
|
||||||
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
'- Emoji::smile: :rocket: :fire: :rainbow: :coffee:',
|
||||||
@@ -139,7 +139,10 @@ var demoMd=[
|
|||||||
'',
|
'',
|
||||||
'| 版本 | 日期 | 测试 | 主题 |',
|
'| 版本 | 日期 | 测试 | 主题 |',
|
||||||
'| :--- | :---: | ---: | --- |',
|
'| :--- | :---: | ---: | --- |',
|
||||||
'| v0.2.4 | 2026-07 | 725 | 浮动工具栏+新API |',
|
'| 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.2 | 2026-07 | 694 | 法语+表格格式化 |',
|
||||||
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
|
||||||
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
|
||||||
@@ -185,6 +188,23 @@ var demoMd=[
|
|||||||
'editor.exec("bold").exec("h1").focus();',
|
'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',
|
||||||
|
'# 安装与启动',
|
||||||
|
'npm install @metona-team/metona-editor',
|
||||||
|
'npm run dev',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
'## 📌 脚注',
|
'## 📌 脚注',
|
||||||
'',
|
'',
|
||||||
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
|
'这是一个带脚注的句子[^1],另一个脚注[^note]。',
|
||||||
@@ -204,6 +224,7 @@ var editor = MeEditor.create('#editor',{
|
|||||||
mode: 'split', height: 620,
|
mode: 'split', height: 620,
|
||||||
lineNumbers: true, autoBrackets: true, wordCount: true,
|
lineNumbers: true, autoBrackets: true, wordCount: true,
|
||||||
theme: 'auto', locale: 'zh-CN', outline: false,
|
theme: 'auto', locale: 'zh-CN', outline: false,
|
||||||
|
highlight: MeEditor.highlight,
|
||||||
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
|
plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
|
||||||
onLinkClick: function(uri, text, ed){
|
onLinkClick: function(uri, text, ed){
|
||||||
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
|
ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
||||||
<title>MetonaEditor v0.2.4 · 文档</title>
|
<title>MetonaEditor v0.4.0 · 文档</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
:root{--bg:#0f1117;--bg-soft:#161922;--card:#1c2029;--card-hover:#232834;--text:#e6e8eb;--muted:#9ca3af;--accent:#3b82f6;--accent-2:#8b5cf6;--accent-soft:rgba(59,130,246,.12);--border:rgba(255,255,255,.08);--gradient:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 50%,#ec4899 100%)}
|
||||||
@@ -65,7 +65,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
|
|
||||||
<header class="docs-header">
|
<header class="docs-header">
|
||||||
<h1><span class="grad">API 文档</span></h1>
|
<h1><span class="grad">API 文档</span></h1>
|
||||||
<p class="sub">MetonaEditor v0.2.4 · TypeScript · 完整配置、API 与使用指南</p>
|
<p class="sub">MetonaEditor v0.4.0 · TypeScript · 完整配置、API 与使用指南</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -78,6 +78,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<a href="#toolbar">工具栏</a>
|
<a href="#toolbar">工具栏</a>
|
||||||
<div class="toc-group">实例 API</div>
|
<div class="toc-group">实例 API</div>
|
||||||
<a href="#api-content">内容操作</a>
|
<a href="#api-content">内容操作</a>
|
||||||
|
<a href="#api-cursor">光标与选区</a>
|
||||||
<a href="#api-exec">命令执行</a>
|
<a href="#api-exec">命令执行</a>
|
||||||
<a href="#api-history">历史栈</a>
|
<a href="#api-history">历史栈</a>
|
||||||
<a href="#api-mode">模式 / 全屏 / Zen</a>
|
<a href="#api-mode">模式 / 全屏 / Zen</a>
|
||||||
@@ -89,6 +90,8 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<a href="#api-destroy">生命周期</a>
|
<a href="#api-destroy">生命周期</a>
|
||||||
<div class="toc-group">静态 API</div>
|
<div class="toc-group">静态 API</div>
|
||||||
<a href="#static-api">全局函数</a>
|
<a href="#static-api">全局函数</a>
|
||||||
|
<div class="toc-group">语法高亮</div>
|
||||||
|
<a href="#highlight">内置高亮器</a>
|
||||||
<div class="toc-group">解析器</div>
|
<div class="toc-group">解析器</div>
|
||||||
<a href="#parser-syntax">语法参考</a>
|
<a href="#parser-syntax">语法参考</a>
|
||||||
<a href="#parser-api">解析器 API</a>
|
<a href="#parser-api">解析器 API</a>
|
||||||
@@ -130,6 +133,7 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
<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>
|
<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>
|
<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>
|
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>
|
<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>
|
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>
|
||||||
@@ -142,6 +146,8 @@ footer a{color:var(--accent);text-decoration:none}
|
|||||||
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span>
|
||||||
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> autoBrackets<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||||
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
zenMode<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> wordWrap<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
|
||||||
|
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>
|
<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>
|
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>
|
||||||
@@ -194,9 +200,23 @@ editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class=
|
|||||||
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">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">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">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>
|
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>
|
||||||
|
|
||||||
|
<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>
|
<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>
|
<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>
|
<table>
|
||||||
@@ -212,6 +232,7 @@ editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c
|
|||||||
<tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</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>image</code></td><td></td><td>—</td></tr>
|
||||||
<tr><td><code>table</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>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>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>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr>
|
||||||
@@ -227,11 +248,12 @@ editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c
|
|||||||
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>
|
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>
|
||||||
|
|
||||||
<section id="api-mode"><h3>模式 / 全屏 / Zen</h3>
|
<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>
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
|
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-fn">exitFullscreen</span><span class="c-punc">();</span>
|
||||||
editor<span class="c-punc">.</span><span class="c-fn">toggleZen</span><span class="c-punc">();</span> <span class="c-fn">isZen</span><span class="c-punc">();</span>
|
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">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></pre>
|
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>
|
||||||
|
|
||||||
<section id="api-stats"><h3>统计与状态</h3>
|
<section id="api-stats"><h3>统计与状态</h3>
|
||||||
@@ -251,6 +273,7 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
|
|||||||
<tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
|
<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>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>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>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>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>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
|
||||||
@@ -258,10 +281,14 @@ editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span cl
|
|||||||
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</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>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>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>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>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>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>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>
|
</table>
|
||||||
<h4>全局钩子</h4>
|
<h4>全局钩子</h4>
|
||||||
<table>
|
<table>
|
||||||
@@ -288,6 +315,7 @@ editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span c
|
|||||||
<section id="api-destroy"><h3>生命周期 / Toast</h3>
|
<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>
|
<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>
|
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>
|
</section>
|
||||||
|
|
||||||
<!-- 静态 API -->
|
<!-- 静态 API -->
|
||||||
@@ -310,7 +338,29 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span cla
|
|||||||
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>
|
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-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></pre>
|
<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>
|
||||||
|
|
||||||
<!-- 解析器语法 -->
|
<!-- 解析器语法 -->
|
||||||
@@ -323,6 +373,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<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>粗斜体 <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>*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>删除线 / 高亮</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>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>`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>```lang</code></td><td><pre><code></td></tr>
|
||||||
@@ -334,6 +385,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><li class="me-task-item"></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>---</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>表格</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>链接 <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>图片</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>[text][ref]</code> + <code>[ref]: url</code></td><td><a> 引用式</td></tr>
|
||||||
@@ -346,7 +398,7 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span cl
|
|||||||
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<h4>XSS 防护</h4>
|
<h4>XSS 防护</h4>
|
||||||
<p>所有文本经 <code>escapeHTML</code> 转义,URL 过滤 <code>javascript:</code> / <code>vbscript:</code> / <code>file:</code> / 非图片 <code>data:</code>,提供 <code>sanitize</code> 钩子。</p>
|
<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>
|
||||||
|
|
||||||
<section id="parser-api"><h2>解析器 API</h2>
|
<section id="parser-api"><h2>解析器 API</h2>
|
||||||
@@ -381,12 +433,12 @@ tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class=
|
|||||||
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>
|
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>
|
<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>
|
<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></pre>
|
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>
|
<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>
|
<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 替换</span></pre>
|
<span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
|
||||||
<h4>imagePaste</h4>
|
<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-com">// 粘贴图片自动转 base64</span></pre>
|
<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>
|
<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>
|
<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>
|
<h4>fileSystem</h4>
|
||||||
@@ -405,6 +457,8 @@ themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><s
|
|||||||
|
|
||||||
<span class="c-com">// 外部跟随</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">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>
|
<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>
|
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>
|
||||||
@@ -433,7 +487,7 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
MetonaEditor v0.2.4 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
MetonaEditor v0.4.0 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,763 @@
|
|||||||
|
<!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.4.1</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.4.1</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.4.1 在线演示
|
||||||
|
-- 已预置 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('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</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.1: 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.1: 切换存储引擎(重建数据库实例)
|
||||||
|
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.4.1)
|
||||||
|
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
|
||||||
|
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter)
|
||||||
|
-- 基础 CRUD 与 Memory 引擎完全兼容
|
||||||
|
|
||||||
|
-- 当前引擎确认
|
||||||
|
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB)
|
||||||
|
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: Write-Ahead Log 保证崩溃恢复 + 批量组提交
|
||||||
|
-- • MVCC: 版本链 + 快照隔离
|
||||||
|
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
|
||||||
|
-- • Bloom Filter: FNV-1a + Murmur 双哈希
|
||||||
|
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
|
||||||
|
-- • 外键级联: CASCADE / SET NULL / RESTRICT (v0.4.1)
|
||||||
|
|
||||||
|
-- 生产环境 API(与演示页右上角切换等价)
|
||||||
|
-- const db = await MetonaSqlark.create({
|
||||||
|
-- name: 'my-app', mode: 'aria'
|
||||||
|
-- });`,
|
||||||
|
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.1: 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);`,
|
||||||
|
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.1 demo ready');
|
||||||
|
setTimeout(runQuery, 300);
|
||||||
|
}).catch(err => {
|
||||||
|
renderError('初始化失败: ' + err.message);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,850 @@
|
|||||||
|
<!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.4.1</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="#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">'indexeddb'</span>, <span class="c">// 'indexeddb' | 'opfs'(仅 disk/hybrid 生效)</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>删除级联 🆕</td></tr>
|
||||||
|
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联 🆕</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>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>);</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 引擎:快照回滚 | IndexedDB 引擎:延迟写入</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>);</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 — 复用已有实例,避免重复 open IndexedDB</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>
|
||||||
|
|
||||||
|
<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[]</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>'indexeddb'|'opfs'</code></td><td><code>'indexeddb'</code></td><td>磁盘引擎类型</td></tr>
|
||||||
|
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</code></td><td>数据库版本号</td></tr>
|
||||||
|
<tr><td><code>plugins</code></td><td><code>MetonaPlugin[]</code></td><td><code>[]</code></td><td>初始插件列表</td></tr>
|
||||||
|
<tr><td><code>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>
|
||||||
|
</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>IndexedDBEngine</code></td><td>disk</td><td>✅ IDB</td><td>IDB 索引</td><td>延迟写入</td><td>通用持久化,兼容性最好</td></tr>
|
||||||
|
<tr><td><code>OPFSEngine</code></td><td>disk</td><td>✅ OPFS</td><td>哈希</td><td>快照回滚</td><td>现代浏览器,文件级存储</td></tr>
|
||||||
|
<tr><td><code>HybridEngine</code></td><td>hybrid</td><td>✅ Write-Through</td><td>哈希</td><td>双引擎代理</td><td>生产推荐,读写均走内存</td></tr>
|
||||||
|
<tr 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>
|
||||||
|
|
||||||
|
<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.1 Aria 级联与演示页引擎切换</strong> — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。</p>
|
||||||
|
|
||||||
|
<h3>存储模式对比</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>特性</th><th>Memory</th><th>Disk(IDB)</th><th>Disk(OPFS)</th><th>Hybrid</th><th>Aria</th></tr>
|
||||||
|
<tr><td>持久化</td><td>❌</td><td>✅ IDB</td><td>✅ OPFS</td><td>✅ 双写</td><td>✅ 后端决定</td></tr>
|
||||||
|
<tr><td>事务</td><td>✅ 快照</td><td>✅ 原子</td><td>✅ 快照</td><td>✅ 双引擎</td><td>✅ MVCC</td></tr>
|
||||||
|
<tr><td>索引</td><td>Hash</td><td>Hash</td><td>Hash</td><td>Hash</td><td>LSM二级</td></tr>
|
||||||
|
<tr><td>上限</td><td>内存</td><td>~2GB</td><td>磁盘</td><td>~2GB</td><td>内存</td></tr>
|
||||||
|
<tr><td>浏览器</td><td>全部</td><td>全部</td><td>Chromium</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>Slotted Page 格式</strong></td><td>4KB 固定页面,Slot Directory + Tuple 二进制序列化</td></tr>
|
||||||
|
<tr><td><strong>Buffer Pool</strong></td><td>LRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB)</td></tr>
|
||||||
|
<tr><td><strong>WAL 日志</strong></td><td>Write-Ahead Log 保证崩溃恢复,支持 full/batch/none 三种同步模式(full 模式真正同步 ✅ v0.2.5),16MB 阈值自动 checkpoint</td></tr>
|
||||||
|
<tr><td><strong>MVCC 事务</strong></td><td>快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC,读写路径接入版本链 ✅ v0.2.5</td></tr>
|
||||||
|
<tr><td><strong>Bloom Filter</strong></td><td>快速判定 key 不存在,减少无效磁盘 I/O,SSTableReader 二分查找统一 ✅ v0.2.5</td></tr>
|
||||||
|
<tr><td><strong>LZ4 压缩</strong></td><td>可选页面级压缩,空间效率提升</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>使用方式</h3>
|
||||||
|
<pre><span class="c">// 激活 AriaEngine</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">'indexeddb'</span>, <span class="c">// 底层存储后端(indexeddb | opfs | memory)</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">pageSize</span>: <span class="n">4096</span>, <span class="c">// 页面大小</span>
|
||||||
|
<span class="s">bufferPoolPages</span>: <span class="n">256</span>, <span class="c">// 缓存页数</span>
|
||||||
|
<span class="s">memtableSizeThreshold</span>: <span class="n">4194304</span>, <span class="c">// MemTable 刷盘阈值 4MB</span>
|
||||||
|
<span class="s">walEnabled</span>: <span class="k">true</span>, <span class="c">// 启用 WAL</span>
|
||||||
|
<span class="s">walSyncMode</span>: <span class="s">'batch'</span>, <span class="c">// 'full' | 'batch' | 'none'</span>
|
||||||
|
<span class="s">storageBackend</span>: <span class="s">'indexeddb'</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>'batch'</code></td><td>WAL 同步策略(full 模式真正同步 ✅ v0.2.5)</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>'indexeddb'|'opfs'|'memory'</code></td><td><code>'indexeddb'</code></td><td>存储后端类型</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>
|
||||||
|
</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>
|
||||||
+215
-114
@@ -7,51 +7,78 @@
|
|||||||
<title>在线演示 — MetonaToast</title>
|
<title>在线演示 — MetonaToast</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1121; --surface: #151d33; --border: #1e2d4d;
|
--bg: #070b14; --surface: rgba(255,255,255,0.03); --surface2: rgba(255,255,255,0.05);
|
||||||
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
|
--border: rgba(255,255,255,0.08); --text: #e8ecf4; --muted: #8b94a7;
|
||||||
|
--accent: #a5b4fc; --grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
}
|
}
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { 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.6; min-height:100vh; }
|
body {
|
||||||
a { color:var(--accent2); text-decoration:none; }
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
|
||||||
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.85em; }
|
background:var(--bg); color:var(--text); line-height:1.65; min-height:100vh;
|
||||||
.nav { position:sticky; top:0; z-index:100; background:rgba(11,17,33,0.88); backdrop-filter:blur(14px); border-bottom:1px solid var(--border); }
|
background-image:
|
||||||
.nav-inner { max-width:1300px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; padding:0 20px; height:56px; }
|
radial-gradient(ellipse 70% 45% at 50% -10%, rgba(99,102,241,0.12), transparent),
|
||||||
.nav-logo { font-size:18px; font-weight:700; background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
|
radial-gradient(ellipse 50% 40% at 90% 20%, rgba(168,85,247,0.07), transparent);
|
||||||
.nav-links { display:flex; gap:20px; }
|
}
|
||||||
.nav-links a { color:var(--muted); font-size:13px; font-weight:500; transition:.2s; }
|
a { color:var(--accent); text-decoration:none; }
|
||||||
.nav-links a:hover { color:var(--accent2); }
|
code { background:rgba(99,102,241,0.15); color:#a5b4fc; padding:1px 6px; border-radius:5px; font-size:.85em; }
|
||||||
.hero { text-align:center; padding:40px 20px 30px; border-bottom:1px solid var(--border); background:radial-gradient(ellipse at 50% 0%,rgba(99,102,241,0.12) 0%,transparent 50%); }
|
.grad-text { background:var(--grad); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color:transparent; }
|
||||||
.hero h1 { font-size:36px; font-weight:800; }
|
|
||||||
.hero h1 span { background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
|
.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; }
|
.hero p { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||||
|
|
||||||
.container { max-width:1300px; margin:0 auto; padding:24px 20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:18px; }
|
.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; }
|
.card {
|
||||||
.card h2 { font-size:15px; margin-bottom:2px; }
|
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
|
||||||
.card > p { color:var(--muted); font-size:11px; margin-bottom:12px; }
|
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-row { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
.btn { padding:8px 14px; border:none; border-radius:7px; font-size:12px; font-weight:500; cursor:pointer; transition:.15s; color:#fff; }
|
.btn {
|
||||||
.btn:hover { transform:translateY(-1px); filter:brightness(1.15); }
|
padding:8px 14px; border:none; border-radius:8px; font-size:12px; font-weight:600;
|
||||||
.btn-sm { padding:5px 10px; font-size:11px; border-radius:5px; }
|
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 { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||||||
.btn-outline:hover { border-color:var(--accent); background:rgba(99,102,241,0.06); }
|
.btn-outline:hover { border-color:rgba(129,140,248,.5); background:rgba(129,140,248,.08); }
|
||||||
.btn-tiny { padding:4px 9px; font-size:10px; border-radius:4px; }
|
|
||||||
.c-s { background:#10b981; } .c-e { background:#ef4444; } .c-w { background:#f59e0b; color:#1e293b; }
|
.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-i { background:#3b82f6; } .c-p { background:#6366f1; } .c-pp { background:#8b5cf6; }
|
||||||
.c-pk { background:#ec4899; } .c-t { background:#14b8a6; } .c-o { background:#f97316; }
|
.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-sl { background:#475569; } .c-r { background:#f43f5e; } .c-g { background:#22c55e; }
|
||||||
.label { font-size:10px; text-transform:uppercase; letter-spacing:0.8px; color:var(--muted); margin-bottom:10px; font-weight:600; }
|
.c-grad { background:var(--grad); }
|
||||||
.demo-section { border-top:1px solid var(--border); padding-top:8px; margin-top:8px; }
|
.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); }
|
footer { text-align:center; padding:30px 20px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }
|
||||||
@media (max-width:768px) { .container { grid-template-columns:1fr; } }
|
|
||||||
|
@media (max-width:820px) { .container { grid-template-columns:1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="nav-inner">
|
<div class="nav-inner">
|
||||||
<div class="nav-logo">MetonaToast</div>
|
<div class="nav-logo"><span class="grad-text">MetonaToast</span></div>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="index.html">首页</a>
|
<a href="index.html">首页</a>
|
||||||
<a href="docs.html">文档</a>
|
<a href="docs.html">文档</a>
|
||||||
@@ -62,7 +89,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<h1><span>在线演示</span></h1>
|
<h1><span class="grad-text">在线演示</span></h1>
|
||||||
<p>覆盖项目全部功能的交互演示 — 点击即可体验</p>
|
<p>覆盖项目全部功能的交互演示 — 点击即可体验</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -81,10 +108,10 @@
|
|||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<div class="btn-row">
|
<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.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.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.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('',{icon:false})">无图标</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="MeToast.info('无按钮',{closeButton: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>
|
<button class="btn btn-outline btn-sm" onclick="demoCustomIcon()">自定义图标</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,16 +119,17 @@
|
|||||||
|
|
||||||
<!-- 2. Loading 链式 -->
|
<!-- 2. Loading 链式 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🔄 Loading 链式转换</div>
|
<div class="label">🔄 Loading 链式转换 <span style="color:#34d399">id 稳定</span></div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-s" onclick="demoLoading()">提交成功</button>
|
<button class="btn c-s" onclick="demoLoading('success')">提交成功</button>
|
||||||
<button class="btn c-e" onclick="demoLoadingFail()">提交失败</button>
|
<button class="btn c-e" onclick="demoLoading('error')">提交失败</button>
|
||||||
<button class="btn c-w" onclick="demoLoadingWarn()">转为警告</button>
|
<button class="btn c-w" onclick="demoLoading('warning')">转为警告</button>
|
||||||
<button class="btn c-i" onclick="demoLoadingUpdate()">原地更新</button>
|
<button class="btn c-i" onclick="demoLoading('update')">原地更新</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="status-line" id="loading-status">转换后返回的 toast 与 loading 是同一个实例(id 不变)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. Promise + confirm + prompt -->
|
<!-- 3. 对话框 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">💬 对话框</div>
|
<div class="label">💬 对话框</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -116,8 +144,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">📊 进度 & 倒计时</div>
|
<div class="label">📊 进度 & 倒计时</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-o" onclick="demoProgress()">进度条(水平)</button>
|
<button class="btn c-o" onclick="demoProgress('horizontal')">进度条(水平)</button>
|
||||||
<button class="btn c-g" onclick="demoProgressV()">进度条(垂直)</button>
|
<button class="btn c-g" onclick="demoProgress('vertical')">进度条(垂直)</button>
|
||||||
<button class="btn c-pk" onclick="demoCountdown()">倒计时 5s</button>
|
<button class="btn c-pk" onclick="demoCountdown()">倒计时 5s</button>
|
||||||
<button class="btn c-r" onclick="demoCountdownPause()">倒计时(可暂停)</button>
|
<button class="btn c-r" onclick="demoCountdownPause()">倒计时(可暂停)</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +164,7 @@
|
|||||||
|
|
||||||
<!-- 6. Action Toast -->
|
<!-- 6. Action Toast -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">⚡ Action Toast</div>
|
<div class="label">⚡ Action Toast <span style="color:#34d399">close:false 不关闭</span></div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
|
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
|
||||||
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
|
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
|
||||||
@@ -146,7 +174,7 @@
|
|||||||
|
|
||||||
<!-- 7. 分组管理 -->
|
<!-- 7. 分组管理 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">📁 分组管理</div>
|
<div class="label">📁 分组管理</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
|
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
|
||||||
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
|
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
|
||||||
@@ -157,7 +185,7 @@
|
|||||||
|
|
||||||
<!-- 8. 动画效果 -->
|
<!-- 8. 动画效果 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🎭 11种动画效果</div>
|
<div class="label">🎭 11 种动画效果</div>
|
||||||
<div class="btn-row">
|
<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('slide')">slide</button>
|
||||||
<button class="btn btn-outline btn-tiny" onclick="anim('fade')">fade</button>
|
<button class="btn btn-outline btn-tiny" onclick="anim('fade')">fade</button>
|
||||||
@@ -171,9 +199,14 @@
|
|||||||
<button class="btn btn-outline btn-tiny" onclick="anim('slideLeft')">slideLeft</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>
|
<button class="btn btn-outline btn-tiny" onclick="anim('slideRight')">slideRight</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-grad" onclick="demoCustomAnim()">✨ 注册自定义动画</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 9. 6种位置 -->
|
<!-- 9. 位置 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">📍 全部位置</div>
|
<div class="label">📍 全部位置</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -186,7 +219,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 10. 主题切换 + 自定义 -->
|
<!-- 10. 主题 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🎨 主题切换 & 自定义</div>
|
<div class="label">🎨 主题切换 & 自定义</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -194,24 +227,43 @@
|
|||||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('dark')">🌙 Dark</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('auto')">🔄 Auto</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="switchTheme('warm')">🔥 Warm</button>
|
<button class="btn btn-outline btn-sm" onclick="switchTheme('warm')">🔥 Warm</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick="demoRegisterTheme()">注册自定义主题</button>
|
<button class="btn btn-outline btn-sm" onclick="demoRegisterTheme()">注册自定义</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 11. 插件系统 -->
|
<!-- 11. 插件 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🔌 插件系统</div>
|
<div class="label">🔌 插件系统 <span style="color:#34d399">4 款预设</span></div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-outline btn-sm" onclick="MeToast.use('keyboard');MeToast.info('已安装 — 按 ESC 关闭所有 Toast')">keyboard ESC关闭</button>
|
<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('已安装 — 配置将持久化到 localStorage')">persistence 持久化</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('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>
|
<button class="btn btn-outline btn-sm" onclick="demoCustomPlugin()">自定义插件</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-t btn-sm" onclick="demoDedupe()">去重演示</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 12. 高级特性 -->
|
<!-- 12. 钩子系统 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🔥 高级特性 <span style="color:#34d399;font-size:10px;">resetTimer · onUpdate · render · notify</span></div>
|
<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">
|
<div class="btn-row">
|
||||||
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
|
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
|
||||||
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
|
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
|
||||||
@@ -220,7 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 13. Toast 管理 -->
|
<!-- 14. Toast 管理 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🔧 Toast 管理</div>
|
<div class="label">🔧 Toast 管理</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -230,7 +282,7 @@
|
|||||||
<button class="btn c-sl" onclick="MeToast.pauseAll();MeToast.info('已暂停全部')">暂停全部</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-sl" onclick="MeToast.resumeAll();MeToast.info('已恢复全部')">恢复全部</button>
|
||||||
<button class="btn c-e" onclick="MeToast.dismiss()">关闭全部</button>
|
<button class="btn c-e" onclick="MeToast.dismiss()">关闭全部</button>
|
||||||
<button class="btn c-r" onclick="MeToast.destroy();MeToast.info('已销毁')">销毁</button>
|
<button class="btn c-r" onclick="MeToast.destroy();MeToast.init({});MeToast.info('已销毁并重建')">销毁重建</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -238,32 +290,34 @@
|
|||||||
<button class="btn btn-outline btn-tiny" onclick="demoGetAll()">getAll</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="demoClearByPos()">clear(位置)</button>
|
||||||
<button class="btn btn-outline btn-tiny" onclick="demoGetConfig()">查看配置</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 14. 错误回调 -->
|
<!-- 15. onError -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🛡️ onError 错误捕获 <span style="color:#34d399;font-size:10px;">v0.2.1</span></div>
|
<div class="label">🛡️ onError 错误捕获</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
|
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
|
||||||
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
|
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 15. 交互特性 -->
|
<!-- 16. 交互特性 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">✋ 交互特性</div>
|
<div class="label">✋ 交互特性</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn c-i" onclick="demoDraggable()">拖拽关闭</button>
|
<button class="btn c-i" onclick="demoDraggable()">拖拽关闭</button>
|
||||||
<button class="btn c-p" onclick="demoPauseHover()">悬停暂停</button>
|
<button class="btn c-p" onclick="demoDragThreshold()">拖拽阈值 300px</button>
|
||||||
<button class="btn c-w" onclick="demoCloseOnClick()">点击关闭</button>
|
<button class="btn c-w" onclick="demoPauseHover()">悬停暂停</button>
|
||||||
<button class="btn c-pp" onclick="demoWidth()">自定义宽度</button>
|
<button class="btn c-pp" onclick="demoCloseOnClick()">点击关闭</button>
|
||||||
<button class="btn c-t" onclick="demoClassName()">自定义className</button>
|
<button class="btn c-t" onclick="demoWidth()">自定义宽度</button>
|
||||||
|
<button class="btn c-sl" onclick="demoClassName()">自定义 className</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 16. 国际化切换 -->
|
<!-- 17. 国际化 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">🌍 国际化 & 格式化</div>
|
<div class="label">🌍 国际化 & 格式化</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
@@ -273,16 +327,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 17. 107种预设图标 -->
|
<!-- 18. 图标网格 -->
|
||||||
<div class="card" style="grid-column:1/-1;">
|
<div class="card" style="grid-column:1/-1;">
|
||||||
<div class="label">🎨 107种预设图标 — 点击任意图标发送Toast</div>
|
<div class="label">🎨 107 种预设图标 — 点击任意图标发送 Toast</div>
|
||||||
<div class="btn-row" id="icon-grid" style="max-height:300px;overflow-y:auto;padding:4px 0;"></div>
|
<div class="btn-row" id="icon-grid" style="max-height:320px;overflow-y:auto;padding:4px 0;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
MetonaToast v0.2.1 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
|
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>
|
</footer>
|
||||||
|
|
||||||
<script src="../dist/metona-toast.js"></script>
|
<script src="../dist/metona-toast.js"></script>
|
||||||
@@ -290,27 +347,41 @@
|
|||||||
MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true });
|
MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true });
|
||||||
|
|
||||||
// === Loading ===
|
// === Loading ===
|
||||||
function demoLoading() { const l=MeToast.loading('正在提交...'); setTimeout(()=>l.success('提交成功!'),2000); }
|
function demoLoading(mode) {
|
||||||
function demoLoadingFail() { const l=MeToast.loading('正在处理...'); setTimeout(()=>l.error('处理失败'),2000); }
|
const l = MeToast.loading('正在处理...');
|
||||||
function demoLoadingWarn() { const l=MeToast.loading('检查中...'); setTimeout(()=>l.warning('发现潜在问题'),2000); }
|
const showId = () => document.getElementById('loading-status').textContent = 'loading id: ' + l.id;
|
||||||
function demoLoadingUpdate() { const l=MeToast.loading('处理中 0%'); let i=1; const t=setInterval(()=>{if(i>3){clearInterval(t);l.success('完成!');return;} l.update({message:'处理中 '+i*33+'%'}); i++;},600); }
|
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 ===
|
// === Promise / confirm / prompt ===
|
||||||
async function demoPromise() {
|
async function demoPromise() {
|
||||||
const p=new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000));
|
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(_){}
|
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 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 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('密码已输入'); }
|
async function demoPromptPassword() { const v=await MeToast.prompt('请输入密码',{placeholder:'密码',inputType:'password',submitText:'登录'}); if(v) MeToast.info('密码已输入'); }
|
||||||
|
|
||||||
// === Progress & Countdown ===
|
// === Progress & Countdown ===
|
||||||
function demoProgress() { const p=MeToast.progress('上传中...',{progressColor:'#3b82f6'}); let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('上传完成!');}else p.setProgress(v);},400); }
|
function demoProgress(dir) {
|
||||||
function demoProgressV() { const p=MeToast.progress('上传中...',{progressColor:'#10b981',progressDirection:'vertical'}); let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('完成!');}else p.setProgress(v);},400); }
|
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 demoCountdown() { MeToast.countdown('{seconds} 秒后执行',5,{onComplete:()=>MeToast.success('已执行')}); }
|
||||||
function demoCountdownPause() {
|
function demoCountdownPause() {
|
||||||
const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')});
|
const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')});
|
||||||
setTimeout(()=>{c.pause();MeToast.warning('已暂停3秒后恢复',{duration:3000})},4000);
|
setTimeout(()=>{c.pause();MeToast.warning('已暂停 3 秒后恢复',{duration:3000})},4000);
|
||||||
setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000);
|
setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,14 +392,15 @@
|
|||||||
setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500);
|
setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500);
|
||||||
}
|
}
|
||||||
function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); }
|
function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); }
|
||||||
function demoStackMixed() {
|
function demoStackMixed() { MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150}); }
|
||||||
MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Action ===
|
// === Action ===
|
||||||
function demoActionDelete() { MeToast.action('文件 data.json 已删除',[{text:'撤销',onClick:()=>MeToast.success('已撤销'),color:'#3b82f6'}]); }
|
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 demoActionMulti() { MeToast.action('邮件已发送',[{text:'查看',onClick:()=>MeToast.info('打开邮件详情'),color:'#3b82f6'},{text:'撤回',onClick:()=>MeToast.warning('已撤回'),color:'#f59e0b'}]); }
|
||||||
function demoActionNoClose() { MeToast.action('完成后点击关闭',[{text:'我知道了',onClick:()=>{},color:'#10b981'}]); }
|
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 ===
|
// === Group ===
|
||||||
const group = MeToast.group('demo-group');
|
const group = MeToast.group('demo-group');
|
||||||
@@ -342,32 +414,71 @@
|
|||||||
|
|
||||||
// === Animation ===
|
// === Animation ===
|
||||||
function anim(name) { MeToast.success('动画: '+name,{animation:name,duration:3000,showProgress:false}); }
|
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 ===
|
// === Theme ===
|
||||||
function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); }
|
function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); }
|
||||||
function demoRegisterTheme() {
|
function demoRegisterTheme() {
|
||||||
try {
|
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.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.themes.switchTheme('sunset'); MeToast.configure({theme:'sunset'});
|
||||||
MeToast.configure({theme:'sunset'});
|
|
||||||
MeToast.success('已切换到 sunset 主题',{theme:'sunset'});
|
MeToast.success('已切换到 sunset 主题',{theme:'sunset'});
|
||||||
} catch(_) { MeToast.warning('主题已注册,已切换'); }
|
} catch(_) { MeToast.warning('主题已注册,已切换'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Plugins ===
|
// === Plugins ===
|
||||||
function demoCustomPlugin() {
|
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.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('自定义插件已安装(消息后追加计数)');
|
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() {
|
function demoResetTimer() {
|
||||||
const t=MeToast.info('这条消息不会自动消失(resetTimer)',{duration:3000,resetTimerOnUpdate:true,closeButton:true});
|
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+' — 每次更新重置3秒'});},1000);
|
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); setTimeout(()=>t.update({message:'再次更新!'}),2000); }
|
function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); }
|
||||||
function demoCustomRender() {
|
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 by thzxx</span></div>',message:'自定义渲染内容',duration:5000});
|
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() {
|
function demoNotify() {
|
||||||
if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;}
|
if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;}
|
||||||
@@ -382,48 +493,38 @@
|
|||||||
}
|
}
|
||||||
function demoGetAll() { const all=MeToast.getAll(); MeToast.info('当前共 '+all.size+' 条 toast'); }
|
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 demoClearByPos() { MeToast.error('右下角临时消息',{position:'bottom-right',duration:3000}); setTimeout(()=>{MeToast.clear('bottom-right');MeToast.info('右下角已清除');},500); }
|
||||||
function demoGetConfig() { try{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});}catch(e){MeToast.error('无 getConfig 方法');} }
|
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); }
|
||||||
// === Interaction ===
|
|
||||||
function demoDraggable() { MeToast.info('← 试试拖动我来关闭',{draggable:true,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>'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === onError ===
|
// === onError ===
|
||||||
function demoOnError() {
|
function demoOnError() {
|
||||||
MeToast.configure({
|
MeToast.configure({ onError: function(info) { MeToast.error('⚠️ 捕获: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' }); } });
|
||||||
onError: function(info) {
|
MeToast.info('已启用 onError 监控', { duration: 3000 });
|
||||||
MeToast.error('⚠️ 捕获错误: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
MeToast.info('已启用 onError 监控(钩子/定时器异常将被捕获)', { duration: 3000 });
|
|
||||||
// 触发一个钩子错误来演示
|
|
||||||
var badFn = function() { throw new Error('demo-error'); };
|
var badFn = function() { throw new Error('demo-error'); };
|
||||||
Toast.on('afterShow', badFn);
|
Toast.on('afterShow', badFn);
|
||||||
MeToast.success('这条消息会触发错误', { duration: 2000 });
|
MeToast.success('这条会触发错误回调', { duration: 2000 });
|
||||||
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
|
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
|
||||||
}
|
}
|
||||||
function demoOnErrorReset() {
|
function demoOnErrorReset() { MeToast.configure({ onError: null }); MeToast.info('onError 已重置'); }
|
||||||
MeToast.configure({ onError: null });
|
|
||||||
MeToast.info('onError 已重置为 null');
|
// === 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 ===
|
// === i18n ===
|
||||||
function switchLocale(loc) {
|
function switchLocale(loc) {
|
||||||
try{MeToast.i18n.switchLocale(loc);MeToast.configure({locale:loc});MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English',{theme:'dark'});}
|
MeToast.i18n.switchLocale(loc); MeToast.configure({locale:loc});
|
||||||
catch(e){MeToast.info('已切换语言');}
|
MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English');
|
||||||
}
|
}
|
||||||
function demoFormat() {
|
function demoFormat() {
|
||||||
try{
|
MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000});
|
||||||
MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000});
|
|
||||||
}catch(e){MeToast.info('格式化: 1234567 → 1,234,567');}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 图标网格 ===
|
// === 图标网格 ===
|
||||||
|
|||||||
+354
-348
@@ -7,448 +7,442 @@
|
|||||||
<title>API 文档 — MetonaToast</title>
|
<title>API 文档 — MetonaToast</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1121; --surface: #1a2332; --border: #1e2d4d;
|
--bg: #070b14; --surface: rgba(255,255,255,0.03); --border: rgba(255,255,255,0.08);
|
||||||
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
|
--text: #e8ecf4; --muted: #8b94a7; --accent: #a5b4fc; --accent2: #c7d2fe;
|
||||||
--radius: 10px;
|
--grad: linear-gradient(135deg, #818cf8, #c084fc, #f472b6);
|
||||||
|
--radius: 12px;
|
||||||
}
|
}
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* { 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.7; display:flex; min-height:100vh; }
|
html { scroll-behavior:smooth; }
|
||||||
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.9em; white-space:nowrap; }
|
body {
|
||||||
pre { background:#0a0f1a; border:1px solid var(--border); border-radius:8px; padding:14px 18px; overflow-x:auto; font:13px/1.7 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace; color:#cbd5e1; margin:10px 0; }
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
|
||||||
pre .kw { color:#c084fc; } pre .fn { color:#60a5fa; } pre .s { color:#34d399; } pre .cm { color:#475569; }
|
background:var(--bg); color:var(--text); line-height:1.75; display:flex; min-height:100vh;
|
||||||
a { color:var(--accent2); text-decoration:none; }
|
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:260px; min-width:260px; background:var(--surface); border-right:1px solid var(--border); padding:24px 0; position:sticky; top:0; height:100vh; overflow-y:auto; }
|
/* ===== 侧边栏 ===== */
|
||||||
.sidebar h2 { font-size:15px; padding:0 24px; margin-bottom:16px; color:var(--accent2); }
|
.sidebar {
|
||||||
.sidebar a { display:block; padding:7px 24px; font-size:13px; color:var(--muted); transition:.15s; border-left:2px solid transparent; }
|
width:264px; min-width:264px; background:rgba(10,15,26,.8);
|
||||||
.sidebar a:hover, .sidebar a.active { color:var(--accent2); border-left-color:var(--accent); background:rgba(99,102,241,0.06); }
|
border-right:1px solid var(--border); padding:22px 0;
|
||||||
.sidebar .section { font-size:11px; text-transform:uppercase; letter-spacing:1px; padding:16px 24px 6px; color:var(--muted); font-weight:600; }
|
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:40px 48px; max-width:950px; }
|
/* ===== 主内容 ===== */
|
||||||
.main h1 { font-size:30px; font-weight:700; margin-bottom:8px; }
|
.main { flex:1; padding:44px 52px; max-width:980px; min-width:0; }
|
||||||
.main h2 { font-size:22px; font-weight:700; margin:44px 0 14px; padding-top:22px; border-top:1px solid var(--border); }
|
.main h1 { font-size:32px; font-weight:800; margin-bottom:6px; letter-spacing:-.5px; }
|
||||||
.main h3 { font-size:17px; font-weight:600; margin:28px 0 10px; color:var(--accent2); }
|
.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 { margin:8px 0; color:var(--muted); font-size:14px; }
|
||||||
.main table { width:100%; border-collapse:collapse; margin:14px 0 20px; font-size:13px; }
|
.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, .main td { text-align:left; padding:9px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||||
.main th { color:var(--muted); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
|
.main th { color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.8px; }
|
||||||
.main td:first-child { color:var(--accent2); font-family:monospace; font-size:12px; white-space:nowrap; }
|
.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:11px; color:var(--muted); }
|
.main td:nth-child(3) { font-family:monospace; font-size:11.5px; color:var(--muted); }
|
||||||
.badge { display:inline-block; padding:1px 7px; border-radius:3px; font-size:10px; font-weight:700; margin-left:6px; vertical-align:middle; }
|
.badge {
|
||||||
.badge-new { background:rgba(16,185,129,0.2); color:#34d399; }
|
display:inline-block; padding:1px 8px; border-radius:4px; font-size:10px;
|
||||||
.hint { font-size:12px; color:var(--muted); margin-top:2px; }
|
font-weight:700; margin-left:6px; vertical-align:middle;
|
||||||
.return-box { background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:8px; padding:14px 18px; margin:12px 0; }
|
}
|
||||||
.return-box h4 { font-size:12px; color:#34d399; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.5px; }
|
.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; }
|
||||||
|
|
||||||
@media (max-width:768px) {
|
.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; }
|
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 { width:100%; min-width:0; height:auto; position:static; border-right:none; border-bottom:1px solid var(--border); padding:12px 16px; }
|
||||||
.sidebar a { display:inline-block; padding:4px 10px; border-left:none; }
|
.sidebar .section, .sidebar .back { padding:4px 8px; }
|
||||||
.main { padding:24px 20px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<h2>📘 MetonaToast 文档</h2>
|
<div class="sidebar-logo"><span class="grad-text">MetonaToast 文档</span></div>
|
||||||
<a href="index.html">← 返回首页</a>
|
<a class="back" href="index.html">← 返回首页</a>
|
||||||
<a href="demo.html">🎮 在线演示</a>
|
<a class="back" href="demo.html">🎮 在线演示</a>
|
||||||
<div class="section">基础通知</div>
|
<div class="section">基础通知</div>
|
||||||
<a href="#show">show()</a>
|
<a class="snav" href="#show">show()</a>
|
||||||
<a href="#success">success()</a>
|
<a class="snav" href="#success">success()</a>
|
||||||
<a href="#error">error()</a>
|
<a class="snav" href="#error">error()</a>
|
||||||
<a href="#warning">warning()</a>
|
<a class="snav" href="#warning">warning()</a>
|
||||||
<a href="#info">info()</a>
|
<a class="snav" href="#info">info()</a>
|
||||||
<a href="#loading">loading()</a>
|
<a class="snav" href="#loading">loading()</a>
|
||||||
<div class="section">高级功能</div>
|
<div class="section">高级功能</div>
|
||||||
<a href="#promise">promise()</a>
|
<a class="snav" href="#promise">promise()</a>
|
||||||
<a href="#confirm">confirm()</a>
|
<a class="snav" href="#confirm">confirm()</a>
|
||||||
<a href="#prompt">prompt()</a>
|
<a class="snav" href="#prompt">prompt()</a>
|
||||||
<a href="#progress">progress()</a>
|
<a class="snav" href="#progress">progress()</a>
|
||||||
<a href="#countdown">countdown()</a>
|
<a class="snav" href="#countdown">countdown()</a>
|
||||||
<a href="#queue">queue()</a>
|
<a class="snav" href="#queue">queue()</a>
|
||||||
<a href="#stack">stack()</a>
|
<a class="snav" href="#stack">stack()</a>
|
||||||
<a href="#action">action()</a>
|
<a class="snav" href="#action">action()</a>
|
||||||
<div class="section">管理方法</div>
|
<div class="section">管理方法</div>
|
||||||
<a href="#group">group()</a>
|
<a class="snav" href="#group">group()</a>
|
||||||
<a href="#dismiss">dismiss()</a>
|
<a class="snav" href="#dismiss">dismiss()</a>
|
||||||
<a href="#clear">clear()</a>
|
<a class="snav" href="#clear">clear()</a>
|
||||||
<a href="#configure">configure()</a>
|
<a class="snav" href="#updatePosition">updatePosition()</a>
|
||||||
<a href="#use">use()</a>
|
<a class="snav" href="#remove">remove() / removeToast()</a>
|
||||||
<a href="#destroy">destroy()</a>
|
<a class="snav" href="#configure">configure()</a>
|
||||||
<div class="section">配置参考</div>
|
<a class="snav" href="#use">use()</a>
|
||||||
<a href="#config">全部配置项</a>
|
<a class="snav" href="#destroy">destroy()</a>
|
||||||
<a href="#callbacks">回调函数</a>
|
<div class="section">扩展</div>
|
||||||
<a href="#animations">动画列表</a>
|
<a class="snav" href="#hooks">钩子系统</a>
|
||||||
<a href="#themes">主题参考</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>
|
</nav>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
<h1>API 文档</h1>
|
<h1>API 文档</h1>
|
||||||
<p>MetonaToast v0.2.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
|
<p class="h1-desc">MetonaToast v0.5.0 完整 API 参考。所有基础通知方法支持字符串与对象两种调用形式,均可传入任何<a href="#config">配置项</a>作为可选参数。</p>
|
||||||
|
|
||||||
<!-- ===== 基础通知 ===== -->
|
<!-- ===== 基础通知 ===== -->
|
||||||
<h2 id="show">show(message, opts?)</h2>
|
<h2 id="show">show(message, opts?)</h2>
|
||||||
<p>显示默认类型的 Toast 通知,无特定颜色和图标。</p>
|
<p>显示默认类型 Toast,无特定颜色与图标。</p>
|
||||||
<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">show.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串,或包含 title/message 等属性的配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置对象,覆盖 <a href="#config">全部配置项</a>。仅当 message 为字符串时有效</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre><span class="cm">// 字符串形式</span>
|
<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>);
|
||||||
MeToast.<span class="fn">show</span>(<span class="s">'自定义'</span>, { <span class="fn">duration</span>: 2000, <span class="fn">position</span>: <span class="s">'bottom-center'</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">// 对象形式(所有 opts 作为一级属性)</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>: 3000 });</pre>
|
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>
|
<h2 id="success">success(message, opts?)</h2>
|
||||||
<p>显示成功通知。绿色对勾图标 <code>#10b981</code>,type = success。</p>
|
<p>绿色对勾图标 <code>#10b981</code>,type = success。</p>
|
||||||
<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">success.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 success</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">success</span>(<span class="s">'保存成功!'</span>);
|
<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> });
|
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">'MeToast和Met等价'</span>);</pre>
|
Met.<span class="fn">success</span>(<span class="s">'Met 与 MeToast 等价'</span>);</pre></div>
|
||||||
|
|
||||||
<h2 id="error">error(message, opts?)</h2>
|
<h2 id="error">error(message, opts?)</h2>
|
||||||
<p>显示错误通知。红色叉号图标 <code>#ef4444</code>,type = error。aria-live 设为 assertive。</p>
|
<p>红色叉号图标 <code>#ef4444</code>,type = error。aria-live = assertive。</p>
|
||||||
<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">error.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 error</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">error</span>(<span class="s">'网络错误,请重试'</span>);
|
<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>
|
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>
|
<h2 id="warning">warning(message, opts?)</h2>
|
||||||
<p>显示警告通知。黄色三角图标 <code>#f59e0b</code>,type = warning。</p>
|
<p>黄色三角图标 <code>#f59e0b</code>,type = warning。</p>
|
||||||
<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">warning.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre></div>
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 warning</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre>
|
|
||||||
|
|
||||||
<h2 id="info">info(message, opts?)</h2>
|
<h2 id="info">info(message, opts?)</h2>
|
||||||
<p>显示信息通知。蓝色圆形图标 <code>#3b82f6</code>,type = info。</p>
|
<p>蓝色圆形图标 <code>#3b82f6</code>,type = info。</p>
|
||||||
<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">info.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre></div>
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 info</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre>
|
|
||||||
|
|
||||||
<h2 id="loading">loading(message, opts?)</h2>
|
<h2 id="loading">loading(message, opts?)</h2>
|
||||||
<p>显示加载状态。type = loading,duration 强制为 0(不自动关闭),closeButton 和 showProgress 强制为 false。返回 LoadingControl 对象。</p>
|
<p>type = loading,duration 强制 0(不自动关闭)。返回 LoadingControl。</p>
|
||||||
<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">loading.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>加载提示文本或配置对象</td></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre><span class="kw">const</span> loading = MeToast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
|
<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>), 2000);</pre>
|
<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">
|
<div class="return-box">
|
||||||
<h4>返回 LoadingControl</h4>
|
<h4>LoadingControl(链式转换 id 稳定)</h4>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
|
<tr><th>方法</th><th>说明</th></tr>
|
||||||
<tr><td>success</td><td>(msg, opts?) => ToastInstance</td><td>关闭加载 toast,原地替换为 success 类型</td></tr>
|
<tr><td>success/error/info/warning</td><td>原地更新为对应类型(同一实例,id 不变),duration 自动恢复默认值</td></tr>
|
||||||
<tr><td>error</td><td>(msg, opts?) => ToastInstance</td><td>替换为 error 类型</td></tr>
|
<tr><td>update(partial)</td><td>原地更新内容,不改变类型</td></tr>
|
||||||
<tr><td>info</td><td>(msg, opts?) => ToastInstance</td><td>替换为 info 类型</td></tr>
|
<tr><td>dismiss()</td><td>直接关闭,不转换</td></tr>
|
||||||
<tr><td>warning</td><td>(msg, opts?) => ToastInstance</td><td>替换为 warning 类型</td></tr>
|
|
||||||
<tr><td>update</td><td>(partial) => MeToast</td><td>不关闭加载 toast,原地更新其内容(支持 resetTimerOnUpdate)</td></tr>
|
|
||||||
<tr><td>dismiss</td><td>()</td><td>直接关闭加载 toast 不替换</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== 高级功能 ===== -->
|
<!-- ===== 高级功能 ===== -->
|
||||||
<h2 id="promise">promise(promise, opts)</h2>
|
<h2 id="promise">promise(promise, opts)</h2>
|
||||||
<p>监听 Promise 生命周期。自动显示 loading → 根据 resolve/reject 自动切换 success/error。返回原 Promise,支持 await 获取结果。</p>
|
<p>监听 Promise 生命周期,自动 loading → resolve/reject 切换 success/error,返回原 Promise。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<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>promise</td><td>Promise</td><td>—</td><td>要监听的 Promise。非 Promise 打印错误并返回 rejected Promise</td></tr>
|
||||||
<tr><td>opts.loading</td><td>string</td><td>"加载中..."</td><td>加载中显示的文本</td></tr>
|
<tr><td>opts.loading / success / error</td><td>string</td><td>加载中... / 操作成功 / 操作失败</td><td>各阶段文本</td></tr>
|
||||||
<tr><td>opts.success</td><td>string</td><td>"操作成功"</td><td>resolve 后显示的文本</td></tr>
|
|
||||||
<tr><td>opts.error</td><td>string</td><td>"操作失败"</td><td>reject 后显示的文本</td></tr>
|
|
||||||
</table>
|
</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> {
|
<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="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">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="fn">success</span>: <span class="s">'加载完成!'</span>,
|
|
||||||
<span class="fn">error</span>: <span class="s">'加载失败'</span>,
|
|
||||||
});
|
});
|
||||||
} <span class="kw">catch</span> (<span class="fn">e</span>) { <span class="cm">/* promise reject 会继续抛出 */</span> }</pre>
|
} <span class="kw">catch</span> (e) { <span class="cm">/* reject 会继续抛出 */</span> }</pre></div>
|
||||||
|
|
||||||
<h2 id="confirm">confirm(message, opts?)</h2>
|
<h2 id="confirm">confirm(message, opts?)</h2>
|
||||||
<p>确认对话框。返回 <code>Promise<boolean></code>。内置 10 秒安全超时,超时自动 resolve(false)。</p>
|
<p>确认对话框,返回 <code>Promise<boolean></code>,内置 10 秒安全超时自动 resolve(false)。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||||
<tr><td>message</td><td>string</td><td>—</td><td>对话框消息。非字符串会打印错误并 resolve(false)</td></tr>
|
<tr><td>confirmText / confirmColor</td><td>确认 / #10b981</td><td>确认按钮文字与颜色</td></tr>
|
||||||
<tr><td>opts.confirmText</td><td>string</td><td>"确认"</td><td>确认按钮文字</td></tr>
|
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮文字与颜色</td></tr>
|
||||||
<tr><td>opts.confirmColor</td><td>string</td><td>"#10b981"</td><td>确认按钮背景色</td></tr>
|
<tr><td>type</td><td>warning</td><td>Toast 类型</td></tr>
|
||||||
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
|
|
||||||
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
|
|
||||||
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型(影响图标和颜色)</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<pre><span class="kw">const</span> ok = <span class="kw">await</span> MeToast.<span class="fn">confirm</span>(<span class="s">'确定删除?'</span>, {
|
<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>
|
||||||
<span class="fn">confirmText</span>: <span class="s">'删除'</span>,
|
<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="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>
|
||||||
<span class="fn">cancelText</span>: <span class="s">'保留'</span>,
|
|
||||||
});
|
|
||||||
<span class="kw">if</span> (ok) MeToast.<span class="fn">success</span>(<span class="s">'已删除'</span>);</pre>
|
|
||||||
|
|
||||||
<h2 id="prompt">prompt(message, opts?)</h2>
|
<h2 id="prompt">prompt(message, opts?)</h2>
|
||||||
<p>输入对话框。返回 <code>Promise<string|null></code>。按 Enter 或点击提交按钮返回输入值,取消返回 null。内置 10 秒安全超时。</p>
|
<p>输入对话框,返回 <code>Promise<string|null></code>。Enter / 提交返回输入值,取消返回 null,10 秒超时。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||||
<tr><td>message</td><td>string</td><td>—</td><td>对话框消息。非字符串会打印错误并 resolve(null)</td></tr>
|
<tr><td>placeholder / defaultValue</td><td>""</td><td>输入框占位与默认值</td></tr>
|
||||||
<tr><td>opts.placeholder</td><td>string</td><td>""</td><td>输入框占位文字</td></tr>
|
<tr><td>inputType</td><td>text</td><td>input 的 type(password/email/number 等)</td></tr>
|
||||||
<tr><td>opts.defaultValue</td><td>string</td><td>""</td><td>输入框默认值</td></tr>
|
<tr><td>submitText / submitColor</td><td>确认 / #3b82f6</td><td>提交按钮</td></tr>
|
||||||
<tr><td>opts.inputType</td><td>string</td><td>"text"</td><td>input 标签 type 属性(如 password/email/number)</td></tr>
|
<tr><td>cancelText / cancelColor</td><td>取消 / #6b7280</td><td>取消按钮</td></tr>
|
||||||
<tr><td>opts.submitText</td><td>string</td><td>"确认"</td><td>提交按钮文字</td></tr>
|
|
||||||
<tr><td>opts.submitColor</td><td>string</td><td>"#3b82f6"</td><td>提交按钮背景色</td></tr>
|
|
||||||
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
|
|
||||||
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
|
|
||||||
<tr><td>opts.type</td><td>string</td><td>"info"</td><td>Toast 类型</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<pre><span class="kw">const</span> name = <span class="kw">await</span> MeToast.<span class="fn">prompt</span>(<span class="s">'请输入姓名'</span>, {
|
<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>
|
||||||
<span class="fn">placeholder</span>: <span class="s">'请输入...'</span>,
|
<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="fn">defaultValue</span>: <span class="s">'张三'</span>,
|
<span class="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre></div>
|
||||||
<span class="fn">submitText</span>: <span class="s">'确定'</span>,
|
|
||||||
});
|
|
||||||
<span class="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre>
|
|
||||||
|
|
||||||
<h2 id="progress">progress(message, opts?)</h2>
|
<h2 id="progress">progress(message, opts?)</h2>
|
||||||
<p>进度条通知。不自动关闭。返回 ProgressControl 对象以手动更新进度。</p>
|
<p>进度条通知,不自动关闭。返回 ProgressControl。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>方法</th><th>说明</th></tr>
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>进度提示文本或配置对象</td></tr>
|
<tr><td>setProgress(percent)</td><td>设置进度 0~100,自动 clamp</td></tr>
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。type 默认 info</td></tr>
|
<tr><td>complete(message?)</td><td>跳 100%,300ms 后转 success,1s 后自动关闭</td></tr>
|
||||||
<tr><td>opts.progressColor</td><td>string</td><td>"#3b82f6"</td><td>进度条填充颜色</td></tr>
|
<tr><td>error(message?)</td><td>转 error,2s 后自动关闭</td></tr>
|
||||||
|
<tr><td>dismiss()</td><td>直接关闭</td></tr>
|
||||||
</table>
|
</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> });
|
<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>(45); <span class="cm">// → 45%</span>
|
p.<span class="fn">setProgress</span>(<span class="num">45</span>);
|
||||||
p.<span class="fn">setProgress</span>(90); <span class="cm">// → 90%</span>
|
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>);</pre></div>
|
||||||
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>); <span class="cm">// → 100% → success</span>
|
|
||||||
<span class="cm">// 或</span>
|
|
||||||
p.<span class="fn">error</span>(<span class="s">'上传失败'</span>);
|
|
||||||
p.<span class="fn">dismiss</span>();</pre>
|
|
||||||
<div class="return-box">
|
|
||||||
<h4>返回 ProgressControl</h4>
|
|
||||||
<table>
|
|
||||||
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
|
|
||||||
<tr><td>setProgress</td><td>(percent: number)</td><td>设置进度 0~100,自动 clamp。更新进度条宽度和百分比文字</td></tr>
|
|
||||||
<tr><td>complete</td><td>(message?: string)</td><td>跳到 100%,300ms 后替换为 success toast,1s 后自动关闭</td></tr>
|
|
||||||
<tr><td>error</td><td>(message?: string)</td><td>替换为 error toast,2s 后自动关闭</td></tr>
|
|
||||||
<tr><td>dismiss</td><td>()</td><td>直接关闭</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
|
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
|
||||||
<p>倒计时 Toast。<code>{seconds}</code> 占位符每秒自动替换为剩余秒数。</p>
|
<p>倒计时 Toast,<code>{seconds}</code> 占位符每秒自动替换。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||||
<tr><td>message</td><td>string</td><td>—</td><td>消息文本。支持 <code>{seconds}</code> 占位符。非字符串打印错误并返回空控制对象</td></tr>
|
<tr><td>seconds</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
|
||||||
<tr><td>seconds</td><td>number</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
|
<tr><td>opts.onComplete</td><td>—</td><td>归零回调</td></tr>
|
||||||
<tr><td>opts.onComplete</td><td>function</td><td>—</td><td>倒计时归零时的回调</td></tr>
|
|
||||||
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<pre>MeToast.<span class="fn">countdown</span>(<span class="s">'{seconds} 秒后执行'</span>, 5, {
|
<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>
|
||||||
<span class="fn">onComplete</span>: () => MeToast.<span class="fn">success</span>(<span class="s">'已执行'</span>),
|
<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>) });
|
||||||
});</pre>
|
<span class="cm">// 返回 { cancel(), pause(), resume() }</span></pre></div>
|
||||||
<div class="return-box">
|
|
||||||
<h4>返回 CountdownControl</h4>
|
|
||||||
<table>
|
|
||||||
<tr><th>方法</th><th>说明</th></tr>
|
|
||||||
<tr><td>cancel()</td><td>清除计时器并关闭 toast</td></tr>
|
|
||||||
<tr><td>pause()</td><td>暂停倒计时</td></tr>
|
|
||||||
<tr><td>resume()</td><td>恢复倒计时</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id="queue">queue(messages, opts?)</h2>
|
<h2 id="queue">queue(messages, opts?)</h2>
|
||||||
<p>顺序逐个显示消息队列。前一条关闭后延时显示下一条。返回 QueueControl(thenable + cancel)。</p>
|
<p>顺序逐个显示消息队列,返回 thenable + cancel。支持 await。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
||||||
<tr><td>messages</td><td>Array<string|object></td><td>—</td><td>消息数组。可为字符串或带 message/type/duration/onClose 的对象。非数组打印错误并返回空控制对象</td></tr>
|
<tr><td>opts.delay</td><td>1000</td><td>每条关闭后到下一条的间隔(ms)</td></tr>
|
||||||
<tr><td>opts.delay</td><td>number</td><td>1000</td><td>每条消息关闭后到显示下一条的间隔(ms)</td></tr>
|
<tr><td>opts.duration</td><td>3000</td><td>每条显示时长,可被消息级 duration 覆盖</td></tr>
|
||||||
<tr><td>opts.duration</td><td>number</td><td>3000</td><td>每条消息显示时长(ms),可被消息级 duration 覆盖</td></tr>
|
|
||||||
<tr><td>opts.onClose</td><td>function</td><td>—</td><td>全部队列完成后的回调。消息级 onClose 和队列级 onClose 都会依次调用</td></tr>
|
|
||||||
<tr><td>opts.type</td><td>string</td><td>—</td><td>默认类型</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
<pre><span class="kw">const</span> q = MeToast.<span class="fn">queue</span>([
|
<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>
|
||||||
<span class="s">'步骤一'</span>,
|
<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> });
|
||||||
{ <span class="fn">message</span>: <span class="s">'步骤二'</span>, <span class="fn">duration</span>: 5000, <span class="fn">type</span>: <span class="s">'warning'</span> },
|
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
|
||||||
<span class="s">'步骤三'</span>,
|
<span class="kw">await</span> q; <span class="cm">// 等待完成</span></pre></div>
|
||||||
], { <span class="fn">delay</span>: 800, <span class="fn">duration</span>: 2000, <span class="fn">type</span>: <span class="s">'info'</span> });
|
|
||||||
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
|
|
||||||
<span class="kw">await</span> q; <span class="cm">// 等待完成(thenable 支持 await)</span></pre>
|
|
||||||
<div class="return-box">
|
|
||||||
<h4>返回 QueueControl (thenable)</h4>
|
|
||||||
<table>
|
|
||||||
<tr><th>方法</th><th>说明</th></tr>
|
|
||||||
<tr><td>.then(fn, rj)</td><td>Promise.then 代理,支持 await</td></tr>
|
|
||||||
<tr><td>.catch(rj)</td><td>Promise.catch 代理</td></tr>
|
|
||||||
<tr><td>.cancel()</td><td>设置取消标志,不再显示下一条消息</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id="stack">stack(messages, opts?)</h2>
|
<h2 id="stack">stack(messages, opts?)</h2>
|
||||||
<p>同时错峰显示多条消息。每条间隔 stagger 毫秒依次出现,全部叠加在屏幕上。</p>
|
<p>同时错峰显示多条消息。</p>
|
||||||
<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">stack.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<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>
|
||||||
<tr><td>messages</td><td>Array<string|object></td><td>—</td><td>消息数组。可为字符串或带 message/type/duration 的对象。非数组打印错误</td></tr>
|
|
||||||
<tr><td>opts.stagger</td><td>number</td><td>100</td><td>每条消息之间的显示间隔(ms)</td></tr>
|
|
||||||
<tr><td>opts.type</td><td>string</td><td>—</td><td>默认类型</td></tr>
|
|
||||||
</table>
|
|
||||||
<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>: 150,
|
|
||||||
<span class="fn">type</span>: <span class="s">'info'</span>,
|
|
||||||
});</pre>
|
|
||||||
|
|
||||||
<h2 id="action">action(message, actions, opts?)</h2>
|
<h2 id="action">action(message, actions, opts?)</h2>
|
||||||
<p>Action Toast:内嵌操作按钮。默认 duration=0 不自动关闭,closeButton=true。</p>
|
<p>内嵌操作按钮。默认 duration=0 不自动关闭。点击按钮不会误触 closeOnClick(已隔离冒泡)。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>ActionButton</th><th>默认</th><th>说明</th></tr>
|
||||||
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
<tr><td>text</td><td>—(必填)</td><td>按钮文字</td></tr>
|
||||||
<tr><td>actions</td><td>ActionButton[]</td><td>[]</td><td>按钮数组。每个按钮可配 text/onClick/color/style/close</td></tr>
|
<tr><td>onClick</td><td>—(必填)</td><td>点击回调,参数为当前 toast</td></tr>
|
||||||
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。duration 默认 0,closeButton 默认 true</td></tr>
|
<tr><td>color / style</td><td>#6366f1</td><td>背景色 / 内联样式(style.background 优先)</td></tr>
|
||||||
</table>
|
<tr><td>close</td><td>true</td><td>点击后是否关闭。false 可多次点击</td></tr>
|
||||||
<table>
|
|
||||||
<tr><th>ActionButton 字段</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
|
||||||
<tr><td>text</td><td>string</td><td>—(必填)</td><td>按钮显示文字</td></tr>
|
|
||||||
<tr><td>onClick</td><td>(toast) => void</td><td>—(必填)</td><td>点击回调函数,参数为当前 toast 实例</td></tr>
|
|
||||||
<tr><td>color</td><td>string</td><td>"#6366f1"</td><td>按钮背景色</td></tr>
|
|
||||||
<tr><td>style</td><td>object</td><td>{}</td><td>按钮内联样式,可覆盖 color(style.background 优先)</td></tr>
|
|
||||||
<tr><td>close</td><td>boolean</td><td>true</td><td>点击后是否自动关闭 toast。设为 false 可多次点击</td></tr>
|
|
||||||
</table>
|
</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>, [
|
<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">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>: (t) => <span class="fn">openFile</span>(), <span class="fn">color</span>: <span class="s">'#10b981'</span>, <span class="fn">close</span>: false },
|
{ <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>
|
]);</pre></div>
|
||||||
|
|
||||||
<!-- ===== 管理方法 ===== -->
|
<!-- ===== 管理方法 ===== -->
|
||||||
<h2 id="group">group(name)</h2>
|
<h2 id="group">group(name)</h2>
|
||||||
<p>创建 Toast 分组,返回 GroupAPI 对象。该对象所有方法自动传入 <code>group: name</code>,按组管理。</p>
|
<p>创建分组,返回 GroupAPI。所有方法自动注入 <code>group: name</code>。</p>
|
||||||
<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">group.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
|
||||||
<tr><td>name</td><td>string</td><td>分组名称。GroupAPI 和 dismissGroup 通过此名称关联</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre><span class="kw">const</span> orders = MeToast.<span class="fn">group</span>(<span class="s">'orders'</span>);
|
<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">success</span>(<span class="s">'订单已创建'</span>);
|
||||||
orders.<span class="fn">error</span>(<span class="s">'支付失败'</span>, { <span class="fn">duration</span>: 5000 });
|
orders.<span class="fn">count</span>(); <span class="cm">// 该组数量</span>
|
||||||
orders.<span class="fn">count</span>(); <span class="cm">// 该组当前 toast 数量</span>
|
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭整组</span>
|
||||||
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭该组全部 toast</span>
|
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre></div>
|
||||||
<span class="cm">// 也支持主对象关闭</span>
|
|
||||||
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre>
|
|
||||||
<div class="return-box">
|
|
||||||
<h4>返回 GroupAPI</h4>
|
|
||||||
<table>
|
|
||||||
<tr><th>方法</th><th>说明</th></tr>
|
|
||||||
<tr><td>show(msg, opts?)</td><td>等价 MeToast.show,自动注入 group</td></tr>
|
|
||||||
<tr><td>success(msg, opts?)</td><td>等价 MeToast.success</td></tr>
|
|
||||||
<tr><td>error(msg, opts?)</td><td>等价 MeToast.error</td></tr>
|
|
||||||
<tr><td>warning(msg, opts?)</td><td>等价 MeToast.warning</td></tr>
|
|
||||||
<tr><td>info(msg, opts?)</td><td>等价 MeToast.info</td></tr>
|
|
||||||
<tr><td>loading(msg, opts?)</td><td>等价 MeToast.loading</td></tr>
|
|
||||||
<tr><td>action(msg, actions, opts?)</td><td>等价 MeToast.action</td></tr>
|
|
||||||
<tr><td>dismiss()</td><td>关闭该组所有 toast</td></tr>
|
|
||||||
<tr><td>count()</td><td>返回该组当前 toast 数量</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 id="dismiss">dismiss(id?)</h2>
|
<h2 id="dismiss">dismiss(id?)</h2>
|
||||||
<p>关闭 Toast。无参数则关闭全部。</p>
|
<p>关闭 Toast。无参数关闭全部,传入 id 关闭指定。</p>
|
||||||
<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">dismiss.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 全部</span>
|
||||||
<tr><td>id</td><td>string</td><td>可选。Toast 的 id,不传则关闭所有</td></tr>
|
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 指定</span></pre></div>
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 关闭所有</span>
|
|
||||||
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 关闭指定</span></pre>
|
|
||||||
|
|
||||||
<h2 id="clear">clear(position?)</h2>
|
<h2 id="clear">clear(position?)</h2>
|
||||||
<p>按位置清除 Toast。不传参数清除所有位置。</p>
|
<p>按位置清除。</p>
|
||||||
<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">clear.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
|
||||||
<tr><td>position</td><td>string</td><td>可选。位置如 'top-right',不传则清除全部</td></tr>
|
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre></div>
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
|
<h2 id="updatePosition">updatePosition(position) <span class="badge badge-v5">v0.3</span></h2>
|
||||||
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre>
|
<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>
|
<h2 id="configure">configure(opts)</h2>
|
||||||
<p>全局配置,影响后续所有 Toast。theme 和 locale 变化会触发相应副作用(应用主题 CSS / 切换语言)。</p>
|
<p>全局配置,影响后续所有 Toast。theme/locale 变化触发副作用。</p>
|
||||||
<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">configure.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
|
||||||
<tr><td>opts</td><td>object</td><td>包含任意 <a href="#config">配置项</a> 的对象</td></tr>
|
|
||||||
</table>
|
|
||||||
<pre>MeToast.<span class="fn">configure</span>({
|
<pre>MeToast.<span class="fn">configure</span>({
|
||||||
<span class="fn">position</span>: <span class="s">'top-right'</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">duration</span>: 4000,
|
<span class="fn">animation</span>: <span class="s">'slide'</span>, <span class="fn">locale</span>: <span class="s">'zh-CN'</span>,
|
||||||
<span class="fn">theme</span>: <span class="s">'dark'</span>,
|
});</pre></div>
|
||||||
<span class="fn">animation</span>: <span class="s">'slide'</span>,
|
|
||||||
<span class="fn">locale</span>: <span class="s">'zh-CN'</span>,
|
|
||||||
});</pre>
|
|
||||||
|
|
||||||
<h2 id="use">use(plugin)</h2>
|
<h2 id="use">use(plugin)</h2>
|
||||||
<p>安装插件。支持字符串(内置预设名)或插件对象。已被拒绝注册的无效插件会打印错误。</p>
|
<p>安装插件:字符串预设名或插件对象。</p>
|
||||||
<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">use.js</span></div>
|
||||||
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有</span>
|
||||||
<tr><td>plugin</td><td>string | object</td><td>预设名 'keyboard'/'persistence'/'accessibility' 或自定义插件对象 { name, version?, install, uninstall? }</td></tr>
|
MeToast.<span class="fn">use</span>(<span class="s">'persistence'</span>); <span class="cm">// 配置持久化 localStorage</span>
|
||||||
</table>
|
MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <span class="cm">// 屏幕阅读器朗读</span>
|
||||||
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有 Toast</span>
|
MeToast.<span class="fn">use</span>(<span class="s">'dedupe'</span>); <span class="cm">// 相同 type+message 自动去重</span></pre></div>
|
||||||
MeToast.<span class="fn">use</span>(<span class="s">'persistence'</span>); <span class="cm">// 配置自动保存到 localStorage</span>
|
<p class="hint">dedupe:检测已有相同 toast,更新它并阻止重复弹出(beforeShow 钩子实现,支持 uninstall 卸载钩子)。</p>
|
||||||
MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <span class="cm">// 屏幕阅读器实时朗读 toast 内容</span></pre>
|
|
||||||
<p class="hint">内置插件详情:keyboard 监听 keydown Escape 键;persistence 将配置写入 localStorage 并在页面加载时恢复;accessibility 在 afterShow/afterUpdate 钩子中通过 aria-live 区域朗读 toast 内容。</p>
|
|
||||||
|
|
||||||
<h2 id="destroy">destroy()</h2>
|
<h2 id="destroy">destroy()</h2>
|
||||||
<p>完全销毁。关闭所有 Toast、移除所有 DOM 容器和注入的样式标签、清空内存缓存。</p>
|
<p>完全销毁:关闭所有 Toast、移除全部 DOM 容器与注入样式、清空内存缓存与钩子。支持重复调用,<code>init()</code> 可恢复。</p>
|
||||||
<pre>MeToast.<span class="fn">destroy</span>();</pre>
|
<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>
|
<h2 id="config">全部配置项</h2>
|
||||||
<p>以下配置可用于 <code>configure()</code>、<code>init()</code> 或单个 Toast 方法的 opts 参数。</p>
|
<p>可用于 <code>configure()</code>、<code>init()</code> 或单个 Toast 方法的 opts。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
<tr><td>position</td><td>string</td><td>'top-right'</td><td>位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right</td></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>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>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>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>offset</td><td>number</td><td>24</td><td>容器距屏幕边缘(px)</td></tr>
|
||||||
<tr><td>pauseOnHover</td><td>boolean</td><td>true</td><td>鼠标悬停时暂停 duration 倒计时和进度条</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>点击 Toast 任意位置关闭。关闭按钮始终触发关闭</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>允许拖拽关闭。拖拽超过 120px 触发关闭</td></tr>
|
<tr><td>draggable</td><td>boolean</td><td>true</td><td>允许拖拽关闭</td></tr>
|
||||||
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示 duration 倒计时进度条</td></tr>
|
<tr><td>dragThreshold</td><td>number</td><td>120</td><td>拖拽关闭阈值(px)</td></tr>
|
||||||
<tr><td>progressDirection</td><td>string</td><td>'horizontal'</td><td>进度条方向:horizontal(底部水平) / vertical(右侧垂直)</td></tr>
|
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示倒计时进度条</td></tr>
|
||||||
<tr><td>icon</td><td>boolean</td><td>true</td><td>显示类型对应图标(success→对勾等)</td></tr>
|
<tr><td>progressDirection</td><td>string</td><td>'horizontal'</td><td>horizontal / vertical</td></tr>
|
||||||
<tr><td>closeButton</td><td>boolean</td><td>true</td><td>显示右上角关闭 × 按钮</td></tr>
|
<tr><td>icon</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>closeButton</td><td>boolean</td><td>true</td><td>显示关闭 × 按钮</td></tr>
|
||||||
<tr><td>animation</td><td>string</td><td>'slide'</td><td>入场动画名称,支持 CSS 动画列表见下</td></tr>
|
<tr><td>theme</td><td>string</td><td>'auto'</td><td>light / dark / auto / warm / 自定义名</td></tr>
|
||||||
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 CSS z-index</td></tr>
|
<tr><td>animation</td><td>string</td><td>'slide'</td><td>11 种内置或自定义动画名</td></tr>
|
||||||
<tr><td>width</td><td>number|string</td><td>360</td><td>Toast 宽度。数字表示 px</td></tr>
|
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 z-index</td></tr>
|
||||||
<tr><td>className</td><td>string</td><td>''</td><td>附加到 Toast 元素上的 CSS 类名</td></tr>
|
<tr><td>width</td><td>number|string</td><td>360</td><td>宽度,数字表示 px</td></tr>
|
||||||
<tr><td>style</td><td>object</td><td>{}</td><td>附加到 Toast 元素上的内联样式对象</td></tr>
|
<tr><td>className</td><td>string</td><td>''</td><td>附加 CSS 类名</td></tr>
|
||||||
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码(zh-CN / en-US 等)</td></tr>
|
<tr><td>style</td><td>object</td><td>{}</td><td>附加内联样式</td></tr>
|
||||||
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
|
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码</td></tr>
|
||||||
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
|
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>update() 时重置倒计时</td></tr>
|
||||||
<tr><td>render</td><td>function</td><td>—</td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
|
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时发系统通知</td></tr>
|
||||||
<tr><td>onError</td><td>function</td><td>—</td><td>全局错误回调 <code>({ hook, source, error, toast }) => void</code>,钩子异常或定时器错误时触发</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>
|
</table>
|
||||||
|
|
||||||
<h2 id="callbacks">回调函数</h2>
|
<h2 id="callbacks">回调函数</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
|
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
|
||||||
<tr><td>onShow</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 创建并播放入场动画后</td></tr>
|
<tr><td>onBeforeShow</td><td>(toast) => boolean | void</td><td>DOM 创建前,返回 false 阻止显示</td></tr>
|
||||||
<tr><td>onClose</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 被移除后(离场动画完成时)</td></tr>
|
<tr><td>onShow</td><td>(toast) => void</td><td>创建并播放入场动画后</td></tr>
|
||||||
<tr><td>onClick</td><td>(toast: ToastInstance) => void</td><td>Toast 被点击时(closeOnClick 为 true 时还会自动关闭)</td></tr>
|
<tr><td>onClose</td><td>(toast) => void</td><td>DOM 移除后(离场完成)</td></tr>
|
||||||
<tr><td>onUpdate</td><td>(toast: ToastInstance) => void</td><td>Toast 内容通过 update() 更新后</td></tr>
|
<tr><td>onClick</td><td>(toast) => void</td><td>点击时(closeOnClick=true 时还会关闭)</td></tr>
|
||||||
<tr><td>onError</td><td>({ hook, source, error, toast }) => void</td><td>钩子回调或定时器发生异常时(全局配置,适用于接入错误监控)</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>
|
</table>
|
||||||
|
|
||||||
<h2 id="animations">动画列表</h2>
|
<h2 id="animations">动画列表</h2>
|
||||||
<p>配置 <code>animation</code> 的值,支持以下 CSS 动画。未在此列表中的值将 fallback 到 slide。</p>
|
<p>未注册的动画名自动 fallback 到 slide。</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>名称</th><th>效果</th><th>时长</th></tr>
|
<tr><th>名称</th><th>效果</th><th>时长</th></tr>
|
||||||
<tr><td>slide</td><td>从右侧滑入 + 回弹</td><td>400ms</td></tr>
|
<tr><td>slide</td><td>从右侧滑入 + 回弹</td><td>400ms</td></tr>
|
||||||
@@ -458,20 +452,13 @@ MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <sp
|
|||||||
<tr><td>flip</td><td>3D 翻转入场 + 回摆</td><td>500ms</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>rotate</td><td>旋转摇摆进入</td><td>500ms</td></tr>
|
||||||
<tr><td>zoom</td><td>从中心爆发式弹出</td><td>500ms</td></tr>
|
<tr><td>zoom</td><td>从中心爆发式弹出</td><td>500ms</td></tr>
|
||||||
<tr><td>slideUp</td><td>从下方弹入</td><td>400ms</td></tr>
|
<tr><td>slideUp / slideDown</td><td>从下方 / 上方弹入</td><td>400ms</td></tr>
|
||||||
<tr><td>slideDown</td><td>从上方弹入</td><td>400ms</td></tr>
|
<tr><td>slideLeft / slideRight</td><td>从左侧 / 右侧滑入</td><td>400ms</td></tr>
|
||||||
<tr><td>slideLeft</td><td>从左侧滑入</td><td>400ms</td></tr>
|
|
||||||
<tr><td>slideRight</td><td>从右侧滑入</td><td>400ms</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2 id="themes">主题参考</h2>
|
<h2 id="themes">主题参考</h2>
|
||||||
<pre><span class="cm">// 内置主题</span>
|
<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>
|
||||||
light <span class="cm">— 白色玻璃质感</span>
|
<pre><span class="cm">// 内置主题:light · dark · auto(跟随系统)· warm</span>
|
||||||
dark <span class="cm">— 深色玻璃质感</span>
|
|
||||||
auto <span class="cm">— 跟随系统主题设置</span>
|
|
||||||
warm <span class="cm">— 暖色调</span>
|
|
||||||
|
|
||||||
<span class="cm">// 注册自定义主题</span>
|
|
||||||
MeToast.themes.<span class="fn">registerTheme</span>(<span class="s">'ocean'</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">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">text</span>: <span class="s">'#0c4a6e'</span>,
|
||||||
@@ -481,19 +468,38 @@ MeToast.themes.<span class="fn">registerTheme</span>(<span class="s">'ocean'</sp
|
|||||||
<span class="fn">progressBg</span>: <span class="s">'rgba(14,165,233,0.1)'</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>,
|
<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>
|
MeToast.themes.<span class="fn">switchTheme</span>(<span class="s">'ocean'</span>);</pre></div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="../dist/metona-toast.js"></script>
|
<script src="../dist/metona-toast.js"></script>
|
||||||
<script>
|
<script>
|
||||||
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
|
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
|
||||||
document.querySelectorAll('.sidebar a[href^="#"]').forEach(a => {
|
|
||||||
a.addEventListener('click', () => {
|
// 侧边栏滚动高亮
|
||||||
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
const links = document.querySelectorAll('.sidebar a.snav');
|
||||||
a.classList.add('active');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,9 +28,10 @@
|
|||||||
"author": "MarkLite",
|
"author": "MarkLite",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metona-team/metona-editor": "0.2.4",
|
"@metona-team/metona-editor": "^0.4.0",
|
||||||
"@metona-team/metona-toast": "0.2.1",
|
"@metona-team/metona-sqlark": "^0.4.1",
|
||||||
"dexie": "^4.0.11",
|
"@metona-team/metona-toast": "^0.5.0",
|
||||||
|
"mermaid": "^10.9.6",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
+64
-64
@@ -1,64 +1,64 @@
|
|||||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||||
|
|
||||||
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
|
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
|
||||||
const api: ElectronAPI = {
|
const api: ElectronAPI = {
|
||||||
// File operations
|
// File operations
|
||||||
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
|
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
|
||||||
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
|
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
|
||||||
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
|
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
|
||||||
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
|
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
|
||||||
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
|
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
|
||||||
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
|
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
|
||||||
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
|
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
|
||||||
|
|
||||||
// Tab management
|
// Tab management
|
||||||
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
|
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
|
||||||
|
|
||||||
// Window control
|
// Window control
|
||||||
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
|
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
|
||||||
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
|
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
|
||||||
|
|
||||||
// Shell — 仅允许 http/https 协议
|
// Shell — 仅允许 http/https 协议
|
||||||
openExternal: (url: string) => {
|
openExternal: (url: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
shell.openExternal(url)
|
shell.openExternal(url)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 无效 URL,忽略
|
// 无效 URL,忽略
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// File Tree (Sidebar)
|
// File Tree (Sidebar)
|
||||||
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
|
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
|
||||||
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
||||||
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
||||||
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
||||||
|
|
||||||
// Events from main process — 返回取消订阅函数
|
// Events from main process — 返回取消订阅函数
|
||||||
onFileOpenInTab: (callback) => {
|
onFileOpenInTab: (callback) => {
|
||||||
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
|
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
|
||||||
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
|
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)
|
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
|
||||||
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
|
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()
|
const handler = () => callback()
|
||||||
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
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()
|
const handler = () => callback()
|
||||||
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
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)
|
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||||
|
|||||||
Vendored
+16
-16
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* DX-02: Preload 类型安全声明
|
* DX-02: Preload 类型安全声明
|
||||||
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
|
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
|
||||||
* preload/index.ts 引入该类型做编译期契约检查。
|
* preload/index.ts 引入该类型做编译期契约检查。
|
||||||
* 本文件仅保留全局 Window 增强声明。
|
* 本文件仅保留全局 Window 增强声明。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ElectronAPI } from '../renderer/types/ipc'
|
import type { ElectronAPI } from '../renderer/types/ipc'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI: ElectronAPI
|
electronAPI: ElectronAPI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
Vendored
+14
-14
@@ -1,14 +1,14 @@
|
|||||||
declare module '*.png' {
|
declare module '*.png' {
|
||||||
const src: string
|
const src: string
|
||||||
export default src
|
export default src
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.svg' {
|
declare module '*.svg' {
|
||||||
const src: string
|
const src: string
|
||||||
export default src
|
export default src
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.ico' {
|
declare module '*.ico' {
|
||||||
const src: string
|
const src: string
|
||||||
export default src
|
export default src
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
|||||||
<span>搜索替换</span>
|
<span>搜索替换</span>
|
||||||
<span>文件树</span>
|
<span>文件树</span>
|
||||||
<span>文档大纲</span>
|
<span>文档大纲</span>
|
||||||
|
<span>浮动格式栏</span>
|
||||||
<span>状态持久化</span>
|
<span>状态持久化</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +45,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
|||||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||||
</a>
|
</a>
|
||||||
<p>基于 Electron + React + TypeScript 构建</p>
|
<p>基于 Electron + React + TypeScript 构建</p>
|
||||||
|
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</p>
|
||||||
<p className="about-copyright">© 2026 thzxx</p>
|
<p className="about-copyright">© 2026 thzxx</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useMemo, useRef } from 'react'
|
||||||
import MeEditor from '@metona-team/metona-editor'
|
import MeEditor from '@metona-team/metona-editor'
|
||||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||||
|
import mermaid from 'mermaid'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
|
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
|
||||||
import { settingsRepository } from '../../db/settingsRepository'
|
import { settingsRepository } from '../../db/settingsRepository'
|
||||||
import { renderMarkdownSync } from '../../lib/markdown'
|
import { renderMarkdownSync } from '../../lib/markdown'
|
||||||
import type { ThemeMode } from '../../types/settings'
|
import type { ThemeMode } from '../../types/settings'
|
||||||
|
|
||||||
// v0.2.4: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
|
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
|
||||||
type EditMode = 'edit' | 'split' | 'preview'
|
type EditMode = 'edit' | 'split' | 'preview'
|
||||||
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
|
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
|
||||||
|
|
||||||
|
// v0.4.5: Mermaid 初始化(全局一次性配置)
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'default' })
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
themeMode: ThemeMode
|
themeMode: ThemeMode
|
||||||
onAppSave?: () => void
|
onAppSave?: () => void
|
||||||
@@ -42,8 +46,14 @@ const EDITOR_PLUGINS: string[] = [
|
|||||||
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
||||||
*/
|
*/
|
||||||
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
|
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||||||
|
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||||||
|
const activeTab = useMemo(
|
||||||
|
() => tabs.find(t => t.id === activeTabId) ?? null,
|
||||||
|
[tabs, activeTabId]
|
||||||
|
)
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
const setModified = useTabStore(s => s.setModified)
|
const setModified = useTabStore(s => s.setModified)
|
||||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||||
@@ -86,6 +96,8 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
|||||||
autoBrackets: true,
|
autoBrackets: true,
|
||||||
readOnly: viewMode === 'preview',
|
readOnly: viewMode === 'preview',
|
||||||
plugins: EDITOR_PLUGINS,
|
plugins: EDITOR_PLUGINS,
|
||||||
|
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
|
||||||
|
floatingToolbar: true,
|
||||||
// v0.2.4 新增配置项
|
// v0.2.4 新增配置项
|
||||||
syncScroll: true,
|
syncScroll: true,
|
||||||
wordWrap: true,
|
wordWrap: true,
|
||||||
@@ -147,6 +159,14 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
|||||||
setMetonaEditorGetter(() => editor)
|
setMetonaEditorGetter(() => editor)
|
||||||
currentContentRef.current = activeTab?.content ?? ''
|
currentContentRef.current = activeTab?.content ?? ''
|
||||||
|
|
||||||
|
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
|
||||||
|
const renderMermaid = () => {
|
||||||
|
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ }
|
||||||
|
}
|
||||||
|
editor.on('afterRender', renderMermaid)
|
||||||
|
// 首次渲染后延迟触发一次
|
||||||
|
setTimeout(renderMermaid, 300)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
editor.destroy()
|
editor.destroy()
|
||||||
editorRef.current = null
|
editorRef.current = null
|
||||||
|
|||||||
+176
-176
@@ -1,176 +1,176 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import appIconUrl from '../assets/icon.png'
|
import appIconUrl from '../assets/icon.png'
|
||||||
|
|
||||||
// 统一图标 Props
|
// 统一图标 Props
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
size?: number
|
size?: number
|
||||||
className?: string
|
className?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps: Partial<IconProps> = { size: 18 }
|
const defaultProps: Partial<IconProps> = { size: 18 }
|
||||||
|
|
||||||
// ===== 应用图标 =====
|
// ===== 应用图标 =====
|
||||||
export function AppIcon({ size = 80 }: IconProps) {
|
export function AppIcon({ size = 80 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 工具栏图标 =====
|
// ===== 工具栏图标 =====
|
||||||
export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
export function FolderOpen({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/>
|
<path d="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="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"/>
|
<path d="M12 11h4" strokeDasharray="2 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Save({ size = defaultProps.size }: IconProps) {
|
export function Save({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
<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="17 21 17 13 7 13 7 21"/>
|
||||||
<polyline points="7 3 7 8 15 8"/>
|
<polyline points="7 3 7 8 15 8"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Moon({ size = defaultProps.size }: IconProps) {
|
export function Moon({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
<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"/>
|
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sun({ size = defaultProps.size }: IconProps) {
|
export function Sun({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="5"/>
|
<circle cx="12" cy="12" r="5"/>
|
||||||
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/>
|
<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="1" x2="12" y2="3"/>
|
||||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
<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="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||||
<line x1="21" y1="12" x2="23" 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="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 工具栏右侧图标 =====
|
// ===== 工具栏右侧图标 =====
|
||||||
export function Gitee({ size = defaultProps.size }: IconProps) {
|
export function Gitee({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
<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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Info({ size = defaultProps.size }: IconProps) {
|
export function Info({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 标签栏图标 =====
|
// ===== 标签栏图标 =====
|
||||||
export function Close({ size = 10 }: IconProps) {
|
export function Close({ size = 10 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Plus({ size = 14 }: IconProps) {
|
export function Plus({ size = 14 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 侧边栏图标 =====
|
// ===== 侧边栏图标 =====
|
||||||
export function Folder({ size = 14 }: IconProps) {
|
export function Folder({ size = 14 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<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"/>
|
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function File({ size = 14 }: IconProps) {
|
export function File({ size = 14 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
|
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
|
||||||
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
|
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChevronRight({ size = 10 }: IconProps) {
|
export function ChevronRight({ size = 10 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="9 18 15 12 9 6"/>
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderPlus({ size = 14 }: IconProps) {
|
export function FolderPlus({ size = 14 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<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="12" y1="11" x2="12" y2="17"/>
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 拖拽覆盖层图标 =====
|
// ===== 拖拽覆盖层图标 =====
|
||||||
export function UploadCloud({ size = 64 }: IconProps) {
|
export function UploadCloud({ size = 64 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/>
|
<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="12 16 12 8"/>
|
||||||
<polyline points="8 12 12 8 16 12"/>
|
<polyline points="8 12 12 8 16 12"/>
|
||||||
<line x1="12" y1="16" x2="12" y2="22"/>
|
<line x1="12" y1="16" x2="12" y2="22"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 欢迎屏幕图标 =====
|
// ===== 欢迎屏幕图标 =====
|
||||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<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="12" y1="11" x2="12" y2="17"/>
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WelcomeNew({ size = 20 }: IconProps) {
|
export function WelcomeNew({ size = 20 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
import React, { memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
import type { Heading } from './outlineUtils'
|
import type { Heading } from './outlineUtils'
|
||||||
|
|
||||||
// --- Component ---
|
// --- Component ---
|
||||||
|
|
||||||
interface OutlinePanelProps {
|
interface OutlinePanelProps {
|
||||||
headings: Heading[]
|
headings: Heading[]
|
||||||
onNavigate: (heading: Heading, index: number) => void
|
onNavigate: (heading: Heading, index: number) => void
|
||||||
activeHeadingIndex: number | null
|
activeHeadingIndex: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OutlineItemProps {
|
interface OutlineItemProps {
|
||||||
heading: Heading
|
heading: Heading
|
||||||
index: number
|
index: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
onNavigate: (heading: Heading, index: number) => void
|
onNavigate: (heading: Heading, index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const OutlineItem = memo(function OutlineItem({
|
const OutlineItem = memo(function OutlineItem({
|
||||||
heading,
|
heading,
|
||||||
index,
|
index,
|
||||||
isActive,
|
isActive,
|
||||||
onNavigate
|
onNavigate
|
||||||
}: OutlineItemProps) {
|
}: OutlineItemProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||||
onClick={() => onNavigate(heading, index)}
|
onClick={() => onNavigate(heading, index)}
|
||||||
title={heading.text}
|
title={heading.text}
|
||||||
aria-label={`跳转到标题:${heading.text}`}
|
aria-label={`跳转到标题:${heading.text}`}
|
||||||
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
||||||
>
|
>
|
||||||
<span className="outline-level-dot" />
|
<span className="outline-level-dot" />
|
||||||
<span className="outline-item-text">{heading.text}</span>
|
<span className="outline-item-text">{heading.text}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const OutlinePanel = memo(function OutlinePanel({
|
export const OutlinePanel = memo(function OutlinePanel({
|
||||||
headings,
|
headings,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
activeHeadingIndex
|
activeHeadingIndex
|
||||||
}: OutlinePanelProps) {
|
}: OutlinePanelProps) {
|
||||||
if (headings.length === 0) {
|
if (headings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||||
<div className="outline-header">文档大纲</div>
|
<div className="outline-header">文档大纲</div>
|
||||||
<div className="outline-empty">当前文档无标题</div>
|
<div className="outline-empty">当前文档无标题</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||||
<div className="outline-header">文档大纲</div>
|
<div className="outline-header">文档大纲</div>
|
||||||
<div className="outline-list" role="list" aria-label="标题列表">
|
<div className="outline-list" role="list" aria-label="标题列表">
|
||||||
{headings.map((h, i) => (
|
{headings.map((h, i) => (
|
||||||
<OutlineItem
|
<OutlineItem
|
||||||
key={`${h.text}-${i}`}
|
key={`${h.text}-${i}`}
|
||||||
heading={h}
|
heading={h}
|
||||||
index={i}
|
index={i}
|
||||||
isActive={i === activeHeadingIndex}
|
isActive={i === activeHeadingIndex}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
OutlinePanel.displayName = 'OutlinePanel'
|
OutlinePanel.displayName = 'OutlinePanel'
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { OutlinePanel } from './OutlinePanel'
|
export { OutlinePanel } from './OutlinePanel'
|
||||||
export { parseHeadings } from './outlineUtils'
|
export { parseHeadings } from './outlineUtils'
|
||||||
export type { Heading } from './outlineUtils'
|
export type { Heading } from './outlineUtils'
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
export interface Heading {
|
export interface Heading {
|
||||||
level: number
|
level: number
|
||||||
text: string
|
text: string
|
||||||
/** Position in document (character offset from markdown source) */
|
/** Position in document (character offset from markdown source) */
|
||||||
pos: number
|
pos: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse headings from raw markdown content using regex.
|
* Parse headings from raw markdown content using regex.
|
||||||
*/
|
*/
|
||||||
export function parseHeadings(markdown: string): Heading[] {
|
export function parseHeadings(markdown: string): Heading[] {
|
||||||
const headings: Heading[] = []
|
const headings: Heading[] = []
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
// Reset regex state
|
// Reset regex state
|
||||||
HEADING_RE.lastIndex = 0
|
HEADING_RE.lastIndex = 0
|
||||||
|
|
||||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||||
const level = match[1].length
|
const level = match[1].length
|
||||||
const text = match[2].trim()
|
const text = match[2].trim()
|
||||||
headings.push({ level, text, pos: match.index })
|
headings.push({ level, text, pos: match.index })
|
||||||
}
|
}
|
||||||
|
|
||||||
return headings
|
return headings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||||
import type { ThemeMode } from '../../types/settings'
|
import type { ThemeMode } from '../../types/settings'
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
themeMode: ThemeMode
|
themeMode: ThemeMode
|
||||||
onCycleTheme: () => void
|
onCycleTheme: () => void
|
||||||
onShowAbout: () => void
|
onShowAbout: () => void
|
||||||
isAutoSaving: boolean
|
isAutoSaving: boolean
|
||||||
autoSaveEnabled: boolean
|
autoSaveEnabled: boolean
|
||||||
onToggleAutoSave: () => void
|
onToggleAutoSave: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_LABELS: Record<ThemeMode, string> = {
|
const THEME_LABELS: Record<ThemeMode, string> = {
|
||||||
light: '亮色',
|
light: '亮色',
|
||||||
dark: '暗色',
|
dark: '暗色',
|
||||||
warm: '暖色',
|
warm: '暖色',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
|
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
|
||||||
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
||||||
*/
|
*/
|
||||||
export const Toolbar = React.memo(function Toolbar({
|
export const Toolbar = React.memo(function Toolbar({
|
||||||
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
|
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
|
||||||
isAutoSaving, autoSaveEnabled, onToggleAutoSave
|
isAutoSaving, autoSaveEnabled, onToggleAutoSave
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
|
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||||
<div className="toolbar-left" role="group" 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} />
|
<FolderOpen size={18} />
|
||||||
<span>打开</span>
|
<span>打开</span>
|
||||||
</button>
|
</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} />
|
<Save size={18} />
|
||||||
<span>保存</span>
|
<span>保存</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-divider" role="separator" />
|
<div className="toolbar-divider" role="separator" />
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||||
onClick={onToggleAutoSave}
|
onClick={onToggleAutoSave}
|
||||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||||
>
|
>
|
||||||
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-right" role="group" aria-label="设置">
|
<div className="toolbar-right" role="group" aria-label="设置">
|
||||||
<button
|
<button
|
||||||
className="toolbar-btn"
|
className="toolbar-btn"
|
||||||
onClick={onCycleTheme}
|
onClick={onCycleTheme}
|
||||||
title={`当前 ${nextLabel} — 点击切换`}
|
title={`当前 ${nextLabel} — 点击切换`}
|
||||||
aria-label={`当前${nextLabel}主题,点击切换`}
|
aria-label={`当前${nextLabel}主题,点击切换`}
|
||||||
>
|
>
|
||||||
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
Toolbar.displayName = 'Toolbar'
|
Toolbar.displayName = 'Toolbar'
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { db, type RecentFile } from './schema'
|
import { type Table } from '@metona-team/metona-sqlark'
|
||||||
|
import { getDb, type RecentFile } from './schema'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export const recentFilesRepository = {
|
export const recentFilesRepository = {
|
||||||
async add(filePath: string): Promise<void> {
|
async add(filePath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first()
|
const db = await getDb()
|
||||||
if (existing) {
|
const tbl = db.table('recentFiles') as Table<RecentFile>
|
||||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
const existing = await tbl.select().where({ filePath }).execute()
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await tbl.update({ lastOpened: Date.now() }).where({ filePath }).execute()
|
||||||
} else {
|
} else {
|
||||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
await tbl.insert({ filePath, lastOpened: Date.now() })
|
||||||
}
|
}
|
||||||
// L-06: 清理超过 50 条的旧记录
|
// 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) {
|
if (all.length > 50) {
|
||||||
const toDelete = all.slice(50)
|
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
|
||||||
await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!))
|
await tbl.delete().where({ filePath: { $in: toDelete } }).execute()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('添加最近文件失败', error)
|
logError('添加最近文件失败', error)
|
||||||
@@ -23,11 +26,13 @@ export const recentFilesRepository = {
|
|||||||
|
|
||||||
async getAll(limit: number = 20): Promise<string[]> {
|
async getAll(limit: number = 20): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const files: RecentFile[] = await db.recentFiles
|
const db = await getDb()
|
||||||
.orderBy('lastOpened')
|
const files = (await db
|
||||||
.reverse()
|
.table('recentFiles')
|
||||||
|
.select(['filePath'])
|
||||||
|
.orderBy('lastOpened', 'desc')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.toArray()
|
.execute()) as RecentFile[]
|
||||||
return files.map((f: RecentFile) => f.filePath)
|
return files.map((f: RecentFile) => f.filePath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('读取最近文件失败', error)
|
logError('读取最近文件失败', error)
|
||||||
@@ -37,7 +42,8 @@ export const recentFilesRepository = {
|
|||||||
|
|
||||||
async remove(filePath: string): Promise<void> {
|
async remove(filePath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.recentFiles.where('filePath').equals(filePath).delete()
|
const db = await getDb()
|
||||||
|
await db.table('recentFiles').delete().where({ filePath }).execute()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('删除最近文件失败', error)
|
logError('删除最近文件失败', error)
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,8 @@ export const recentFilesRepository = {
|
|||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.recentFiles.clear()
|
const db = await getDb()
|
||||||
|
await db.table('recentFiles').clear()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('清空最近文件失败', error)
|
logError('清空最近文件失败', error)
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-18
@@ -1,6 +1,11 @@
|
|||||||
import Dexie, { type EntityTable } from 'dexie'
|
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
|
||||||
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export interface TabSnapshot {
|
// 注意: 使用 type 别名而非 interface —
|
||||||
|
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>,
|
||||||
|
// type 别名具备隐式索引签名,interface 没有(TS 索引签名规则)
|
||||||
|
|
||||||
|
export type TabSnapshot = {
|
||||||
id: string
|
id: string
|
||||||
filePath: string | null
|
filePath: string | null
|
||||||
content: string
|
content: string
|
||||||
@@ -11,12 +16,12 @@ export interface TabSnapshot {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveTabRecord {
|
export type ActiveTabRecord = {
|
||||||
id: string // 固定为 'current'
|
id: string // 固定为 'current'
|
||||||
activeTabId: string | null
|
activeTabId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsRecord {
|
export type SettingsRecord = {
|
||||||
id: string // 固定为 'default'
|
id: string // 固定为 'default'
|
||||||
themeMode: 'light' | 'dark' | 'warm'
|
themeMode: 'light' | 'dark' | 'warm'
|
||||||
viewMode: 'editor' | 'preview' | 'source'
|
viewMode: 'editor' | 'preview' | 'source'
|
||||||
@@ -24,24 +29,78 @@ export interface SettingsRecord {
|
|||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentFile {
|
export type RecentFile = {
|
||||||
id?: number
|
// v0.5.0: sqlark 无自增主键,改用 filePath 作主键(业务上天然唯一)
|
||||||
filePath: string
|
filePath: string
|
||||||
lastOpened: number
|
lastOpened: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Dexie('MarkLite') as Dexie & {
|
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
|
||||||
tabSnapshots: EntityTable<TabSnapshot, 'id'>
|
// v0.5.0: 存储引擎选用 AriaEngine(自研 LSM-Tree + WAL + MVCC,对标 SQLite)
|
||||||
settings: EntityTable<SettingsRecord, 'id'>
|
const DB_NAME = 'MarkLiteV2'
|
||||||
recentFiles: EntityTable<RecentFile, 'id'>
|
const DB_VERSION = 1
|
||||||
activeTab: EntityTable<ActiveTabRecord, 'id'>
|
|
||||||
|
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({
|
const SETTINGS_COLUMNS: Record<string, ColumnDef> = {
|
||||||
tabSnapshots: 'id, filePath, updatedAt',
|
id: { type: 'string', primaryKey: true },
|
||||||
settings: 'id',
|
themeMode: { type: 'string' },
|
||||||
recentFiles: '++id, filePath, lastOpened',
|
viewMode: { type: 'string' },
|
||||||
activeTab: 'id'
|
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.5.0: MetonaSqlark 初始化 — 懒加载单例。
|
||||||
|
* create() 为异步,无法像 Dexie 那样模块顶层同步实例化;
|
||||||
|
* 首次调用时创建,之后复用同一 Promise。
|
||||||
|
*/
|
||||||
|
let dbPromise: Promise<MetonaSqlark> | null = null
|
||||||
|
|
||||||
|
async function defineTablesIfNeeded(db: MetonaSqlark): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): Promise<MetonaSqlark> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = (async () => {
|
||||||
|
const db = await create({
|
||||||
|
name: DB_NAME,
|
||||||
|
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||||
|
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||||
|
version: DB_VERSION,
|
||||||
|
onError: (err: Error) => logError('数据库错误', err),
|
||||||
|
})
|
||||||
|
await defineTablesIfNeeded(db)
|
||||||
|
return db
|
||||||
|
})()
|
||||||
|
// 初始化失败时允许下次重试
|
||||||
|
dbPromise.catch(() => {
|
||||||
|
dbPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
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 { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export const settingsRepository = {
|
export const settingsRepository = {
|
||||||
async load(): Promise<Settings> {
|
async load(): Promise<Settings> {
|
||||||
try {
|
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) {
|
if (record) {
|
||||||
return {
|
return {
|
||||||
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
||||||
@@ -20,13 +27,20 @@ export const settingsRepository = {
|
|||||||
return { ...DEFAULT_SETTINGS }
|
return { ...DEFAULT_SETTINGS }
|
||||||
},
|
},
|
||||||
|
|
||||||
// C-01: 先 load 再 merge 再 put,避免部分字段丢失
|
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
|
||||||
// M-04: 添加错误处理与日志
|
// M-04: 添加错误处理与日志
|
||||||
async save(partial: Partial<Settings>): Promise<void> {
|
async save(partial: Partial<Settings>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const db = await getDb()
|
||||||
const current: Settings = await this.load()
|
const current: Settings = await this.load()
|
||||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
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) {
|
} catch (error) {
|
||||||
logError('保存设置失败', 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'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
export const tabRepository = {
|
export const tabRepository = {
|
||||||
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.transaction('rw', db.tabSnapshots, async () => {
|
const db = await getDb()
|
||||||
await db.tabSnapshots.clear()
|
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
|
||||||
await db.tabSnapshots.bulkAdd(tabs)
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.table('tabSnapshots').clear()
|
||||||
|
if (tabs.length > 0) {
|
||||||
|
await trx.table('tabSnapshots').insertMany(tabs)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('保存标签快照失败', error)
|
logError('保存标签快照失败', error)
|
||||||
@@ -15,7 +20,13 @@ export const tabRepository = {
|
|||||||
|
|
||||||
async loadAll(): Promise<TabSnapshot[]> {
|
async loadAll(): Promise<TabSnapshot[]> {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
logError('加载标签快照失败', error)
|
logError('加载标签快照失败', error)
|
||||||
return []
|
return []
|
||||||
@@ -24,7 +35,8 @@ export const tabRepository = {
|
|||||||
|
|
||||||
async clearAll(): Promise<void> {
|
async clearAll(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.tabSnapshots.clear()
|
const db = await getDb()
|
||||||
|
await db.table('tabSnapshots').clear()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('清空标签快照失败', error)
|
logError('清空标签快照失败', error)
|
||||||
}
|
}
|
||||||
@@ -32,7 +44,14 @@ export const tabRepository = {
|
|||||||
|
|
||||||
async saveActiveTabId(tabId: string | null): Promise<void> {
|
async saveActiveTabId(tabId: string | null): Promise<void> {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
logError('保存活动标签ID失败', error)
|
logError('保存活动标签ID失败', error)
|
||||||
}
|
}
|
||||||
@@ -40,11 +59,16 @@ export const tabRepository = {
|
|||||||
|
|
||||||
async loadActiveTabId(): Promise<string | null> {
|
async loadActiveTabId(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const record = await db.activeTab.get('current')
|
const db = await getDb()
|
||||||
return record?.activeTabId ?? null
|
const rows = await db
|
||||||
|
.table('activeTab')
|
||||||
|
.select()
|
||||||
|
.where({ id: 'current' })
|
||||||
|
.execute()
|
||||||
|
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('加载活动标签ID失败', error)
|
logError('加载活动标签ID失败', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,79 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D4: 基于视口的活跃标题追踪 hook
|
* D4: 基于视口的活跃标题追踪 hook
|
||||||
* 在 preview 模式下监听目标容器的滚动事件,
|
* 在 preview 模式下监听目标容器的滚动事件,
|
||||||
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
|
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
|
||||||
*
|
*
|
||||||
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
|
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
|
||||||
*
|
*
|
||||||
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
|
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
|
||||||
* @param headings - 解析出的标题列表
|
* @param headings - 解析出的标题列表
|
||||||
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
|
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
|
||||||
*/
|
*/
|
||||||
export function useActiveHeading(
|
export function useActiveHeading(
|
||||||
containerRef: React.RefObject<HTMLElement | null>,
|
containerRef: React.RefObject<HTMLElement | null>,
|
||||||
headings: { level: number; text: string }[]
|
headings: { level: number; text: string }[]
|
||||||
): number | null {
|
): number | null {
|
||||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container || headings.length === 0) {
|
if (!container || headings.length === 0) {
|
||||||
setActiveIndex(null)
|
setActiveIndex(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集容器内所有 h1-h6 元素的 offsetTop
|
// 收集容器内所有 h1-h6 元素的 offsetTop
|
||||||
const headingElements = Array.from(
|
const headingElements = Array.from(
|
||||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||||
) as HTMLElement[]
|
) as HTMLElement[]
|
||||||
|
|
||||||
if (headingElements.length === 0) {
|
if (headingElements.length === 0) {
|
||||||
setActiveIndex(null)
|
setActiveIndex(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTop = container.scrollTop
|
const scrollTop = container.scrollTop
|
||||||
const containerHeight = container.clientHeight
|
const containerHeight = container.clientHeight
|
||||||
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
|
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
|
||||||
|
|
||||||
let bestIndex: number | null = null
|
let bestIndex: number | null = null
|
||||||
|
|
||||||
for (let i = 0; i < headingElements.length; i++) {
|
for (let i = 0; i < headingElements.length; i++) {
|
||||||
const el = headingElements[i]
|
const el = headingElements[i]
|
||||||
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window)
|
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window)
|
||||||
const top = el.offsetTop - (container.offsetTop || 0)
|
const top = el.offsetTop - (container.offsetTop || 0)
|
||||||
if (top <= threshold) {
|
if (top <= threshold) {
|
||||||
// 找到 headings 中匹配的索引
|
// 找到 headings 中匹配的索引
|
||||||
const text = el.textContent?.trim() ?? ''
|
const text = el.textContent?.trim() ?? ''
|
||||||
const matchIdx = headings.findIndex(
|
const matchIdx = headings.findIndex(
|
||||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
||||||
)
|
)
|
||||||
if (matchIdx >= 0) bestIndex = matchIdx
|
if (matchIdx >= 0) bestIndex = matchIdx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveIndex(bestIndex)
|
setActiveIndex(bestIndex)
|
||||||
}, [containerRef, headings])
|
}, [containerRef, headings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container || headings.length === 0) {
|
if (!container || headings.length === 0) {
|
||||||
setActiveIndex(null)
|
setActiveIndex(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听滚动事件
|
// 监听滚动事件
|
||||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
|
||||||
// 初始计算
|
// 初始计算
|
||||||
handleScroll()
|
handleScroll()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('scroll', handleScroll)
|
container.removeEventListener('scroll', handleScroll)
|
||||||
}
|
}
|
||||||
}, [containerRef, headings, handleScroll])
|
}, [containerRef, headings, handleScroll])
|
||||||
|
|
||||||
return activeIndex
|
return activeIndex
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-116
@@ -1,116 +1,116 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
/** Debounce delay for auto-save (ms) */
|
/** Debounce delay for auto-save (ms) */
|
||||||
const AUTO_SAVE_DELAY = 2000
|
const AUTO_SAVE_DELAY = 2000
|
||||||
|
|
||||||
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
|
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
|
||||||
let _toggleAutoSave: (() => void) | null = null
|
let _toggleAutoSave: (() => void) | null = null
|
||||||
export function toggleAutoSaveExternal(): void {
|
export function toggleAutoSaveExternal(): void {
|
||||||
_toggleAutoSave?.()
|
_toggleAutoSave?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
|
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
|
||||||
* and saves automatically after a pause in editing for file-backed tabs.
|
* and saves automatically after a pause in editing for file-backed tabs.
|
||||||
*
|
*
|
||||||
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
|
||||||
* re-render on every keystroke — the effect is triggered once and runs
|
* re-render on every keystroke — the effect is triggered once and runs
|
||||||
* reactively via the store subscription.
|
* reactively via the store subscription.
|
||||||
*
|
*
|
||||||
* Captures the tabId at debounce start so the timeout always saves the
|
* Captures the tabId at debounce start so the timeout always saves the
|
||||||
* correct tab even if the user switches tabs during the debounce window.
|
* 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 [isAutoSaving, setIsAutoSaving] = useState(false)
|
||||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isSavingRef = useRef(false)
|
const isSavingRef = useRef(false)
|
||||||
const enabledRef = useRef(true)
|
const enabledRef = useRef(true)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
const toggleAutoSave = useCallback(() => {
|
const toggleAutoSave = useCallback(() => {
|
||||||
setAutoSaveEnabled(prev => {
|
setAutoSaveEnabled(prev => {
|
||||||
const next = !prev
|
const next = !prev
|
||||||
enabledRef.current = next
|
enabledRef.current = next
|
||||||
if (!next && timerRef.current) {
|
if (!next && timerRef.current) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
timerRef.current = null
|
timerRef.current = null
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 暴露给外部(状态栏按钮)
|
// 暴露给外部(状态栏按钮)
|
||||||
_toggleAutoSave = toggleAutoSave
|
_toggleAutoSave = toggleAutoSave
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
|
||||||
// Subscribe to Zustand store — fires on every state change
|
// Subscribe to Zustand store — fires on every state change
|
||||||
const unsub = useTabStore.subscribe((state) => {
|
const unsub = useTabStore.subscribe((state) => {
|
||||||
if (!enabledRef.current) return
|
if (!enabledRef.current) return
|
||||||
|
|
||||||
const tab = state.getActiveTab()
|
const tab = state.getActiveTab()
|
||||||
if (!tab || !tab.filePath || !tab.isModified) return
|
if (!tab || !tab.filePath || !tab.isModified) return
|
||||||
|
|
||||||
// Capture the tabId so the timeout saves the correct tab
|
// Capture the tabId so the timeout saves the correct tab
|
||||||
const tabIdToSave = tab.id
|
const tabIdToSave = tab.id
|
||||||
|
|
||||||
// Debounce: clear previous timer, start new one
|
// Debounce: clear previous timer, start new one
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
timerRef.current = setTimeout(async () => {
|
timerRef.current = setTimeout(async () => {
|
||||||
if (isSavingRef.current) return
|
if (isSavingRef.current) return
|
||||||
|
|
||||||
// Re-read latest state; verify the captured tab still exists and is modified
|
// Re-read latest state; verify the captured tab still exists and is modified
|
||||||
const currentState = useTabStore.getState()
|
const currentState = useTabStore.getState()
|
||||||
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
|
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
|
||||||
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
|
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
|
||||||
|
|
||||||
isSavingRef.current = true
|
isSavingRef.current = true
|
||||||
if (mountedRef.current) setIsAutoSaving(true)
|
if (mountedRef.current) setIsAutoSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const result = await window.electronAPI.saveFile({
|
const result = await window.electronAPI.saveFile({
|
||||||
filePath: tabToSave.filePath,
|
filePath: tabToSave.filePath,
|
||||||
content: tabToSave.content
|
content: tabToSave.content
|
||||||
})
|
})
|
||||||
if (result.success && mountedRef.current) {
|
if (result.success && mountedRef.current) {
|
||||||
currentState.setModified(tabToSave.id, false)
|
currentState.setModified(tabToSave.id, false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('自动保存失败', error)
|
logError('自动保存失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) setIsAutoSaving(false)
|
if (mountedRef.current) setIsAutoSaving(false)
|
||||||
isSavingRef.current = false
|
isSavingRef.current = false
|
||||||
}
|
}
|
||||||
}, AUTO_SAVE_DELAY)
|
}, AUTO_SAVE_DELAY)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
unsub()
|
unsub()
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
timerRef.current = null
|
timerRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
|
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useAutoSaveStore.getState().setState({ autoSaveEnabled })
|
useAutoSaveStore.getState().setState({ autoSaveEnabled })
|
||||||
}, [autoSaveEnabled])
|
}, [autoSaveEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useAutoSaveStore.getState().setState({ isAutoSaving })
|
useAutoSaveStore.getState().setState({ isAutoSaving })
|
||||||
}, [isAutoSaving])
|
}, [isAutoSaving])
|
||||||
|
|
||||||
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
|
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,74 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { isAllowedFile } from '../lib/fileUtils'
|
import { isAllowedFile } from '../lib/fileUtils'
|
||||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
import { showToast } from '../lib/toast'
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
export function useDragDrop() {
|
export function useDragDrop() {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const files = e.dataTransfer?.files
|
const files = e.dataTransfer?.files
|
||||||
if (!files) return
|
if (!files) return
|
||||||
|
|
||||||
let rejected = 0
|
let rejected = 0
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Electron adds a `path` property to File objects
|
// Electron adds a `path` property to File objects
|
||||||
const electronFile = file as File & { path?: string }
|
const electronFile = file as File & { path?: string }
|
||||||
const filePath: string = electronFile.path || file.name
|
const filePath: string = electronFile.path || file.name
|
||||||
if (!isAllowedFile(filePath)) {
|
if (!isAllowedFile(filePath)) {
|
||||||
rejected++
|
rejected++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.readFile(filePath)
|
const result = await window.electronAPI.readFile(filePath)
|
||||||
if (result.success && result.content !== undefined) {
|
if (result.success && result.content !== undefined) {
|
||||||
createTab(filePath, result.content)
|
createTab(filePath, result.content)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('拖拽读取文件失败', error)
|
logError('拖拽读取文件失败', error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
reader.onload = (ev: ProgressEvent<FileReader>) => {
|
||||||
const content = ev.target?.result as string
|
const content = ev.target?.result as string
|
||||||
createTab(file.name, content)
|
createTab(file.name, content)
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rejected > 0) {
|
if (rejected > 0) {
|
||||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||||
}
|
}
|
||||||
}, [createTab])
|
}, [createTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevent = (e: DragEvent) => {
|
const prevent = (e: DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('dragenter', prevent)
|
document.addEventListener('dragenter', prevent)
|
||||||
document.addEventListener('dragleave', prevent)
|
document.addEventListener('dragleave', prevent)
|
||||||
document.addEventListener('dragover', prevent)
|
document.addEventListener('dragover', prevent)
|
||||||
document.addEventListener('drop', handleDrop)
|
document.addEventListener('drop', handleDrop)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('dragenter', prevent)
|
document.removeEventListener('dragenter', prevent)
|
||||||
document.removeEventListener('dragleave', prevent)
|
document.removeEventListener('dragleave', prevent)
|
||||||
document.removeEventListener('dragover', prevent)
|
document.removeEventListener('dragover', prevent)
|
||||||
document.removeEventListener('drop', handleDrop)
|
document.removeEventListener('drop', handleDrop)
|
||||||
}
|
}
|
||||||
}, [handleDrop])
|
}, [handleDrop])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useSidebarStore } from '../stores/sidebarStore'
|
import { useSidebarStore } from '../stores/sidebarStore'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
|
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
|
||||||
* UX-02: 添加目录加载 loading 状态
|
* UX-02: 添加目录加载 loading 状态
|
||||||
*/
|
*/
|
||||||
export function useFolderOperations() {
|
export function useFolderOperations() {
|
||||||
const rootPath = useSidebarStore(s => s.rootPath)
|
const rootPath = useSidebarStore(s => s.rootPath)
|
||||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||||
const setTree = useSidebarStore(s => s.setTree)
|
const setTree = useSidebarStore(s => s.setTree)
|
||||||
const expandDirs = useSidebarStore(s => s.expandDirs)
|
const expandDirs = useSidebarStore(s => s.expandDirs)
|
||||||
const setLoading = useEditorStore(s => s.setLoading)
|
const setLoading = useEditorStore(s => s.setLoading)
|
||||||
|
|
||||||
const handleOpenFolder = useCallback(async () => {
|
const handleOpenFolder = useCallback(async () => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const dirPath = await window.electronAPI.openFolderDialog()
|
const dirPath = await window.electronAPI.openFolderDialog()
|
||||||
if (!dirPath) return
|
if (!dirPath) return
|
||||||
setRootPath(dirPath)
|
setRootPath(dirPath)
|
||||||
expandDirs([dirPath])
|
expandDirs([dirPath])
|
||||||
setLoading('dir-load', true)
|
setLoading('dir-load', true)
|
||||||
try {
|
try {
|
||||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||||
if (dirTree.success && dirTree.tree) {
|
if (dirTree.success && dirTree.tree) {
|
||||||
setTree(dirTree.tree)
|
setTree(dirTree.tree)
|
||||||
window.electronAPI.watchDir(dirPath)
|
window.electronAPI.watchDir(dirPath)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading('dir-load', false)
|
setLoading('dir-load', false)
|
||||||
}
|
}
|
||||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||||
|
|
||||||
const refreshTree = useCallback(async () => {
|
const refreshTree = useCallback(async () => {
|
||||||
if (!rootPath || !window.electronAPI) return
|
if (!rootPath || !window.electronAPI) return
|
||||||
setLoading('dir-load', true)
|
setLoading('dir-load', true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.readDirTree(rootPath)
|
const result = await window.electronAPI.readDirTree(rootPath)
|
||||||
if (result.success && result.tree) setTree(result.tree)
|
if (result.success && result.tree) setTree(result.tree)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading('dir-load', false)
|
setLoading('dir-load', false)
|
||||||
}
|
}
|
||||||
}, [rootPath, setTree, setLoading])
|
}, [rootPath, setTree, setLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
|
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [refreshTree])
|
}, [refreshTree])
|
||||||
|
|
||||||
return { handleOpenFolder }
|
return { handleOpenFolder }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主进程事件注册 hook
|
* 主进程事件注册 hook
|
||||||
* 处理通过命令行或文件关联打开的文件
|
* 处理通过命令行或文件关联打开的文件
|
||||||
*/
|
*/
|
||||||
export function useIpcListeners() {
|
export function useIpcListeners() {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
const onOpen = (data: { filePath: string; content: string }) => {
|
const onOpen = (data: { filePath: string; content: string }) => {
|
||||||
createTab(data.filePath, data.content)
|
createTab(data.filePath, data.content)
|
||||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||||
}
|
}
|
||||||
return api.onFileOpenInTab(onOpen)
|
return api.onFileOpenInTab(onOpen)
|
||||||
}, [createTab])
|
}, [createTab])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局键盘快捷键 hook。
|
* 全局键盘快捷键 hook。
|
||||||
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||||
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
||||||
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
||||||
*/
|
*/
|
||||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||||
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
||||||
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); 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.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||||
|
|
||||||
const tabState = useTabStore.getState()
|
const tabState = useTabStore.getState()
|
||||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||||
if (isCtrl && e.key === 'w') {
|
if (isCtrl && e.key === 'w') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||||
if (isCtrl && e.key === 'Tab') {
|
if (isCtrl && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const { tabs, activeTabId, mruStack } = tabState
|
const { tabs, activeTabId, mruStack } = tabState
|
||||||
if (tabs.length > 1) {
|
if (tabs.length > 1) {
|
||||||
if (mruStack.length > 0) {
|
if (mruStack.length > 0) {
|
||||||
const targetId = mruStack[0]
|
const targetId = mruStack[0]
|
||||||
if (tabs.find(t => t.id === targetId)) {
|
if (tabs.find(t => t.id === targetId)) {
|
||||||
tabState.switchToTab(targetId)
|
tabState.switchToTab(targetId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||||
const next = e.shiftKey
|
const next = e.shiftKey
|
||||||
? (idx - 1 + tabs.length) % tabs.length
|
? (idx - 1 + tabs.length) % tabs.length
|
||||||
: (idx + 1) % tabs.length
|
: (idx + 1) % tabs.length
|
||||||
tabState.switchToTab(tabs[next].id)
|
tabState.switchToTab(tabs[next].id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [handleOpenFile, handleSave, handleSaveAs])
|
}, [handleOpenFile, handleSave, handleSaveAs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
return () => document.removeEventListener('keydown', handleKeydown)
|
return () => document.removeEventListener('keydown', handleKeydown)
|
||||||
}, [handleKeydown])
|
}, [handleKeydown])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { useSidebarStore } from '../stores/sidebarStore'
|
import { useSidebarStore } from '../stores/sidebarStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-04: 统一设置加载 hook
|
* AR-04: 统一设置加载 hook
|
||||||
* 一次性从 IndexedDB 加载所有设置,分发到各 store,
|
* 一次性从 MetonaSqlark 加载所有设置,分发到各 store,
|
||||||
* 替代各 hook 各自独立加载设置的模式。
|
* 替代各 hook 各自独立加载设置的模式。
|
||||||
*/
|
*/
|
||||||
export function useSettingsInit() {
|
export function useSettingsInit() {
|
||||||
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||||
const isInitialized = useRef(false)
|
const isInitialized = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized.current) return
|
if (isInitialized.current) return
|
||||||
isInitialized.current = true
|
isInitialized.current = true
|
||||||
|
|
||||||
settingsRepository.load()
|
settingsRepository.load()
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||||
setThemeMode(themeMode)
|
setThemeMode(themeMode)
|
||||||
|
|
||||||
// 视图模式
|
// 视图模式
|
||||||
setViewMode(settings.viewMode ?? 'editor')
|
setViewMode(settings.viewMode ?? 'editor')
|
||||||
|
|
||||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||||
const sidebarStore = useSidebarStore.getState()
|
const sidebarStore = useSidebarStore.getState()
|
||||||
if (!sidebarStore._loaded) {
|
if (!sidebarStore._loaded) {
|
||||||
useSidebarStore.setState({
|
useSidebarStore.setState({
|
||||||
isVisible: !settings.sidebarCollapsed,
|
isVisible: !settings.sidebarCollapsed,
|
||||||
sidebarWidth: settings.sidebarWidth,
|
sidebarWidth: settings.sidebarWidth,
|
||||||
_loaded: true
|
_loaded: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
logError('加载设置失败', error)
|
logError('加载设置失败', error)
|
||||||
})
|
})
|
||||||
}, [setThemeMode, setViewMode])
|
}, [setThemeMode, setViewMode])
|
||||||
|
|
||||||
return { isInitialized }
|
return { isInitialized }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useSidebarStore } from '../stores/sidebarStore'
|
import { useSidebarStore } from '../stores/sidebarStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||||
*
|
*
|
||||||
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
||||||
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
||||||
*/
|
*/
|
||||||
export function useSidebarResize() {
|
export function useSidebarResize() {
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isResizing) return
|
if (!isResizing) return
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent): void => {
|
const handleMouseMove = (e: MouseEvent): void => {
|
||||||
if (sidebarRef.current) {
|
if (sidebarRef.current) {
|
||||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||||
sidebarRef.current.style.width = newWidth + 'px'
|
sidebarRef.current.style.width = newWidth + 'px'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = (): void => {
|
const handleMouseUp = (): void => {
|
||||||
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
|
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
|
||||||
if (sidebarRef.current) {
|
if (sidebarRef.current) {
|
||||||
const width = sidebarRef.current.offsetWidth
|
const width = sidebarRef.current.offsetWidth
|
||||||
const clamped = Math.max(180, Math.min(500, width))
|
const clamped = Math.max(180, Math.min(500, width))
|
||||||
useSidebarStore.getState().setSidebarWidth(clamped)
|
useSidebarStore.getState().setSidebarWidth(clamped)
|
||||||
}
|
}
|
||||||
setIsResizing(false)
|
setIsResizing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
}
|
}
|
||||||
}, [isResizing])
|
}, [isResizing])
|
||||||
|
|
||||||
const startResize = (): void => setIsResizing(true)
|
const startResize = (): void => setIsResizing(true)
|
||||||
|
|
||||||
return { sidebarRef, isResizing, startResize }
|
return { sidebarRef, isResizing, startResize }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,91 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import MeEditor from '@metona-team/metona-editor'
|
import MeEditor from '@metona-team/metona-editor'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
import type { ThemeMode } from '../types/settings'
|
import type { ThemeMode } from '../types/settings'
|
||||||
|
|
||||||
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
||||||
function hexWithAlpha(hex: string, alpha: number): string {
|
function hexWithAlpha(hex: string, alpha: number): string {
|
||||||
if (!hex || !hex.startsWith('#')) return hex
|
if (!hex || !hex.startsWith('#')) return hex
|
||||||
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
|
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
|
||||||
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
|
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
|
||||||
function syncAppColorsToEditor(): void {
|
function syncAppColorsToEditor(): void {
|
||||||
try {
|
try {
|
||||||
const vars = MeEditor.themes.exportCSSVars()
|
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
|
||||||
if (!vars || typeof vars !== 'object') return
|
const vars = MeEditor.themes.exportCSSVars()
|
||||||
const root = document.documentElement
|
if (!vars || typeof vars !== 'object') return
|
||||||
const set = (name: string, value: string | undefined) => {
|
const root = document.documentElement
|
||||||
if (value) root.style.setProperty(name, value)
|
const set = (name: string, value: string | undefined) => {
|
||||||
}
|
if (value) root.style.setProperty(name, value)
|
||||||
const accent = vars['--md-accent'] ?? '#1a73e8'
|
}
|
||||||
// 直接映射 — 文本/边框用纯色
|
const accent = vars['--md-accent'] ?? '#1a73e8'
|
||||||
set('--bg', vars['--md-bg'])
|
// 直接映射 — 文本/边框用纯色
|
||||||
set('--text', vars['--md-text'])
|
set('--bg', vars['--md-bg'])
|
||||||
set('--border', vars['--md-border'])
|
set('--text', vars['--md-text'])
|
||||||
set('--primary', accent)
|
set('--border', vars['--md-border'])
|
||||||
set('--primary-dark', accent)
|
set('--primary', accent)
|
||||||
set('--text-secondary', vars['--md-muted'])
|
set('--primary-dark', accent)
|
||||||
set('--text-tertiary', vars['--md-muted'])
|
set('--text-secondary', vars['--md-muted'])
|
||||||
set('--code-bg', vars['--md-code-bg'])
|
set('--text-tertiary', vars['--md-muted'])
|
||||||
// 背景派生 — 用 accent 的低透明度版本
|
set('--code-bg', vars['--md-code-bg'])
|
||||||
const accentBg = hexWithAlpha(accent, 0.12)
|
// 背景派生 — 用 accent 的低透明度版本
|
||||||
set('--primary-light', accentBg)
|
const accentBg = hexWithAlpha(accent, 0.12)
|
||||||
set('--sidebar-active', accentBg)
|
set('--primary-light', accentBg)
|
||||||
set('--sidebar-bg', vars['--md-bg'])
|
set('--sidebar-active', accentBg)
|
||||||
set('--sidebar-border', vars['--md-border'])
|
set('--sidebar-bg', vars['--md-bg'])
|
||||||
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
|
set('--sidebar-border', vars['--md-border'])
|
||||||
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
|
||||||
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
|
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||||
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
|
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
|
||||||
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
|
||||||
set('--search-border', vars['--md-border'])
|
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
|
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
|
||||||
? '0 1px 3px rgba(0,0,0,0.3)'
|
root.style.setProperty('--shadow', isDark
|
||||||
: '0 1px 3px rgba(0,0,0,0.08)')
|
? '0 1px 3px rgba(0,0,0,0.3)'
|
||||||
root.style.setProperty('--shadow-lg', isDark
|
: '0 1px 3px rgba(0,0,0,0.08)')
|
||||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
root.style.setProperty('--shadow-lg', isDark
|
||||||
: '0 4px 12px rgba(0,0,0,0.1)')
|
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||||
} catch { /* 容错 */ }
|
: '0 4px 12px rgba(0,0,0,0.1)')
|
||||||
}
|
} catch { /* 容错 */ }
|
||||||
|
}
|
||||||
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
|
||||||
light: 'light',
|
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
||||||
dark: 'dark',
|
light: 'light',
|
||||||
warm: 'warm',
|
dark: 'dark',
|
||||||
}
|
warm: 'warm',
|
||||||
|
}
|
||||||
/**
|
|
||||||
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
/**
|
||||||
* 应用颜色自动同步编辑器主题,保持一致。
|
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||||
*/
|
* 应用颜色自动同步编辑器主题,保持一致。
|
||||||
export function useTheme() {
|
*/
|
||||||
const themeMode = useEditorStore(s => s.themeMode)
|
export function useTheme() {
|
||||||
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
const themeMode = useEditorStore(s => s.themeMode)
|
||||||
|
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||||
const handleCycleTheme = useCallback(() => {
|
|
||||||
const next = cycleTheme()
|
const handleCycleTheme = useCallback(() => {
|
||||||
// 同步编辑器全局主题
|
const next = cycleTheme()
|
||||||
MeEditor.setTheme(next)
|
// 同步编辑器全局主题
|
||||||
// 同步 Toast 主题
|
MeEditor.setTheme(next)
|
||||||
import('../lib/toast').then(({ MeToast }) => {
|
// 同步 Toast 主题
|
||||||
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
|
import('../lib/toast').then(({ MeToast }) => {
|
||||||
})
|
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
|
||||||
// 持久化
|
})
|
||||||
settingsRepository.save({ themeMode: next })
|
// 持久化
|
||||||
}, [cycleTheme])
|
settingsRepository.save({ themeMode: next })
|
||||||
|
}, [cycleTheme])
|
||||||
// themeMode 变化时同步应用颜色
|
|
||||||
useEffect(() => {
|
// themeMode 变化时同步应用颜色
|
||||||
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||||
syncAppColorsToEditor()
|
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||||
}, [themeMode])
|
syncAppColorsToEditor()
|
||||||
|
}, [themeMode])
|
||||||
return { themeMode, cycleTheme: handleCycleTheme }
|
|
||||||
}
|
return { themeMode, cycleTheme: handleCycleTheme }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
import { useEffect, useCallback, useRef } from 'react'
|
import { useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 未保存提醒 hook
|
* 未保存提醒 hook
|
||||||
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
|
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
|
||||||
*/
|
*/
|
||||||
export function useUnsavedWarning(
|
export function useUnsavedWarning(
|
||||||
hasUnsaved: () => boolean,
|
hasUnsaved: () => boolean,
|
||||||
confirmFn?: (message: string) => Promise<boolean>,
|
confirmFn?: (message: string) => Promise<boolean>,
|
||||||
// D5: 关闭前回调(flush 待保存数据)
|
// D5: 关闭前回调(flush 待保存数据)
|
||||||
onBeforeForceClose?: () => Promise<void>
|
onBeforeForceClose?: () => Promise<void>
|
||||||
) {
|
) {
|
||||||
const confirmFnRef = useRef(confirmFn)
|
const confirmFnRef = useRef(confirmFn)
|
||||||
confirmFnRef.current = confirmFn
|
confirmFnRef.current = confirmFn
|
||||||
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
||||||
onBeforeForceCloseRef.current = onBeforeForceClose
|
onBeforeForceCloseRef.current = onBeforeForceClose
|
||||||
|
|
||||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||||
if (confirmFnRef.current) {
|
if (confirmFnRef.current) {
|
||||||
return confirmFnRef.current(message)
|
return confirmFnRef.current(message)
|
||||||
}
|
}
|
||||||
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
|
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
|
||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) {
|
if (!window.electronAPI) {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
if (hasUnsaved()) {
|
if (hasUnsaved()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.returnValue = ''
|
e.returnValue = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('beforeunload', handler)
|
window.addEventListener('beforeunload', handler)
|
||||||
return () => window.removeEventListener('beforeunload', handler)
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
|
|
||||||
const unsubscribe = api.onConfirmClose(async () => {
|
const unsubscribe = api.onConfirmClose(async () => {
|
||||||
if (!hasUnsaved()) {
|
if (!hasUnsaved()) {
|
||||||
await onBeforeForceCloseRef.current?.()
|
await onBeforeForceCloseRef.current?.()
|
||||||
api.forceClose()
|
api.forceClose()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
await onBeforeForceCloseRef.current?.()
|
await onBeforeForceCloseRef.current?.()
|
||||||
api.forceClose()
|
api.forceClose()
|
||||||
} else {
|
} else {
|
||||||
api.cancelClose()
|
api.cancelClose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [hasUnsaved, doConfirm])
|
}, [hasUnsaved, doConfirm])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
import { getFileName, isAllowedFile, getFileExtension } from '../fileUtils'
|
||||||
|
|
||||||
describe('fileUtils', () => {
|
describe('fileUtils', () => {
|
||||||
describe('getFileName', () => {
|
describe('getFileName', () => {
|
||||||
it('should extract filename from Unix path', () => {
|
it('should extract filename from Unix path', () => {
|
||||||
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
expect(getFileName('/home/user/documents/file.md')).toBe('file.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should extract filename from Windows path', () => {
|
it('should extract filename from Windows path', () => {
|
||||||
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
expect(getFileName('C:\\Users\\test\\file.md')).toBe('file.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the input if it is already a filename', () => {
|
it('should return the input if it is already a filename', () => {
|
||||||
expect(getFileName('file.md')).toBe('file.md')
|
expect(getFileName('file.md')).toBe('file.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle path with trailing slash', () => {
|
it('should handle path with trailing slash', () => {
|
||||||
expect(getFileName('/path/to/dir/')).toBe('dir')
|
expect(getFileName('/path/to/dir/')).toBe('dir')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
it('should handle path with trailing backslash', () => {
|
it('should handle path with trailing backslash', () => {
|
||||||
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
expect(getFileName('C:\\Users\\test\\')).toBe('test')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle mixed path separators', () => {
|
it('should handle mixed path separators', () => {
|
||||||
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
expect(getFileName('C:\\Users/test\\file.md')).toBe('file.md')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getFileExtension', () => {
|
describe('getFileExtension', () => {
|
||||||
it('should return lowercase extension', () => {
|
it('should return lowercase extension', () => {
|
||||||
expect(getFileExtension('file.MD')).toBe('.md')
|
expect(getFileExtension('file.MD')).toBe('.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return .markdown extension', () => {
|
it('should return .markdown extension', () => {
|
||||||
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
expect(getFileExtension('file.markdown')).toBe('.markdown')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return .txt extension', () => {
|
it('should return .txt extension', () => {
|
||||||
expect(getFileExtension('notes.txt')).toBe('.txt')
|
expect(getFileExtension('notes.txt')).toBe('.txt')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return empty string for no extension', () => {
|
it('should return empty string for no extension', () => {
|
||||||
expect(getFileExtension('Makefile')).toBe('')
|
expect(getFileExtension('Makefile')).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return empty string for hidden files starting with dot', () => {
|
it('should return empty string for hidden files starting with dot', () => {
|
||||||
expect(getFileExtension('.gitignore')).toBe('')
|
expect(getFileExtension('.gitignore')).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should extract extension from full path', () => {
|
it('should extract extension from full path', () => {
|
||||||
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
expect(getFileExtension('/home/user/file.md')).toBe('.md')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should extract extension from Windows path', () => {
|
it('should extract extension from Windows path', () => {
|
||||||
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
expect(getFileExtension('C:\\Users\\test\\file.MARKDOWN')).toBe('.markdown')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle file with multiple dots', () => {
|
it('should handle file with multiple dots', () => {
|
||||||
expect(getFileExtension('my.file.md')).toBe('.md')
|
expect(getFileExtension('my.file.md')).toBe('.md')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isAllowedFile', () => {
|
describe('isAllowedFile', () => {
|
||||||
it('should return true for .md files', () => {
|
it('should return true for .md files', () => {
|
||||||
expect(isAllowedFile('file.md')).toBe(true)
|
expect(isAllowedFile('file.md')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return true for .markdown files', () => {
|
it('should return true for .markdown files', () => {
|
||||||
expect(isAllowedFile('file.markdown')).toBe(true)
|
expect(isAllowedFile('file.markdown')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return true for .txt files', () => {
|
it('should return true for .txt files', () => {
|
||||||
expect(isAllowedFile('file.txt')).toBe(true)
|
expect(isAllowedFile('file.txt')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return true for uppercase extensions', () => {
|
it('should return true for uppercase extensions', () => {
|
||||||
expect(isAllowedFile('file.MD')).toBe(true)
|
expect(isAllowedFile('file.MD')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for .js files', () => {
|
it('should return false for .js files', () => {
|
||||||
expect(isAllowedFile('file.js')).toBe(false)
|
expect(isAllowedFile('file.js')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for .json files', () => {
|
it('should return false for .json files', () => {
|
||||||
expect(isAllowedFile('file.json')).toBe(false)
|
expect(isAllowedFile('file.json')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for files without extension', () => {
|
it('should return false for files without extension', () => {
|
||||||
expect(isAllowedFile('Makefile')).toBe(false)
|
expect(isAllowedFile('Makefile')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should work with full paths', () => {
|
it('should work with full paths', () => {
|
||||||
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
expect(isAllowedFile('/home/user/doc.md')).toBe(true)
|
||||||
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
expect(isAllowedFile('/home/user/script.py')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { ALLOWED_EXTENSIONS } from './constants'
|
import { ALLOWED_EXTENSIONS } from './constants'
|
||||||
|
|
||||||
export function getFileName(filePath: string): string {
|
export function getFileName(filePath: string): string {
|
||||||
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
|
||||||
const clean = filePath.replace(/[/\\]+$/, '')
|
const clean = filePath.replace(/[/\\]+$/, '')
|
||||||
return clean.split(/[/\\]/).pop() || clean || filePath
|
return clean.split(/[/\\]/).pop() || clean || filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedFile(filePath: string): boolean {
|
export function isAllowedFile(filePath: string): boolean {
|
||||||
const ext = getFileExtension(filePath)
|
const ext = getFileExtension(filePath)
|
||||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// L-07: 无扩展名文件正确返回空字符串
|
// L-07: 无扩展名文件正确返回空字符串
|
||||||
export function getFileExtension(filePath: string): string {
|
export function getFileExtension(filePath: string): string {
|
||||||
const name = filePath.split(/[/\\]/).pop() || filePath
|
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||||
const dotIndex = name.lastIndexOf('.')
|
const dotIndex = name.lastIndexOf('.')
|
||||||
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||||
return name.substring(dotIndex).toLowerCase()
|
return name.substring(dotIndex).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw'
|
|||||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||||
import rehypeStringify from 'rehype-stringify'
|
import rehypeStringify from 'rehype-stringify'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
|
import { visit } from 'unist-util-visit'
|
||||||
import type { Element, Root } from 'hast'
|
import type { Element, Root } from 'hast'
|
||||||
|
|
||||||
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||||
@@ -71,6 +72,47 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义 rehype 插件:将 mermaid 代码块转为 .me-mermaid 容器
|
||||||
|
// 在 rehypeHighlight 之后运行,此时 code 元素已有 language-mermaid 类名。
|
||||||
|
// 输出结构需与 MetonaEditor 内置解析器一致,以便 mermaid.run() 统一查询。
|
||||||
|
function rehypeMermaid(): Plugin<[], Root> {
|
||||||
|
return () => (tree: Root) => {
|
||||||
|
visit(tree, 'element', (node: Element, index, parent) => {
|
||||||
|
if (node.tagName !== 'pre') return
|
||||||
|
if (parent === null || parent === undefined || index === undefined) return
|
||||||
|
|
||||||
|
const code = node.children.find(
|
||||||
|
(child): child is Element => child.type === 'element' && child.tagName === 'code'
|
||||||
|
)
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
|
const className: string[] = (code.properties?.className as string[] | undefined) ?? []
|
||||||
|
if (!className.some(c => c === 'language-mermaid' || c === 'mermaid')) return
|
||||||
|
|
||||||
|
// 提取代码文本内容
|
||||||
|
const text = (code.children ?? [])
|
||||||
|
.filter(c => c.type === 'text')
|
||||||
|
.map(c => (c as { type: 'text'; value: string }).value)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
// 替换为 mermaid 渲染容器(与 MetonaEditor 内置输出一致)
|
||||||
|
const mermaidContainer: Element = {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { className: ['me-mermaid'] },
|
||||||
|
children: [{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { className: ['mermaid'] },
|
||||||
|
children: [{ type: 'text', value: text }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children[index] = mermaidContainer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PF-01: Markdown处理器LRU缓存
|
// PF-01: Markdown处理器LRU缓存
|
||||||
const MAX_CACHE_SIZE = 20
|
const MAX_CACHE_SIZE = 20
|
||||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||||
@@ -102,6 +144,7 @@ function buildProcessor(filePath: string | null) {
|
|||||||
strip: ['script', 'on*', 'javascript:'],
|
strip: ['script', 'on*', 'javascript:'],
|
||||||
})
|
})
|
||||||
.use(rehypeHighlight)
|
.use(rehypeHighlight)
|
||||||
|
.use(rehypeMermaid())
|
||||||
.use(rehypeStringify)
|
.use(rehypeStringify)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-50
@@ -1,50 +1,50 @@
|
|||||||
import MeToast from '@metona-team/metona-toast'
|
import MeToast from '@metona-team/metona-toast'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MeToast 全局配置
|
* MeToast 全局配置
|
||||||
*
|
*
|
||||||
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
||||||
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||||
*
|
*
|
||||||
* v0.4.4: 升级 MeToast 0.2.1,安装 keyboard / accessibility 插件。
|
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
|
||||||
*/
|
*/
|
||||||
MeToast.configure({
|
MeToast.configure({
|
||||||
position: 'top-right',
|
position: 'top-right',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
max: 5,
|
max: 5,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
animation: 'slide',
|
animation: 'slide',
|
||||||
pauseOnHover: true,
|
pauseOnHover: true,
|
||||||
closeOnClick: true,
|
closeOnClick: true,
|
||||||
showProgress: true,
|
showProgress: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
locale: 'zh-CN',
|
locale: 'zh-CN',
|
||||||
width: 360,
|
width: 360,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 安装内置插件
|
// 安装内置插件
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
|
||||||
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
MeToast.use('keyboard') // ESC 关闭所有 Toast
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
|
||||||
|
|
||||||
/** 与原 showToast 保持兼容的类型子集 */
|
/** 与原 showToast 保持兼容的类型子集 */
|
||||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
|
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
|
||||||
*
|
*
|
||||||
* @param msg 消息内容
|
* @param msg 消息内容
|
||||||
* @param type 通知类型(默认 info)
|
* @param type 通知类型(默认 info)
|
||||||
* @param duration 显示时长(ms),省略则用全局默认值
|
* @param duration 显示时长(ms),省略则用全局默认值
|
||||||
*/
|
*/
|
||||||
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
|
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
|
||||||
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
|
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同步 MeToast 主题到当前暗色模式 */
|
/** 同步 MeToast 主题到当前暗色模式 */
|
||||||
export function syncToastTheme(darkMode: boolean): void {
|
export function syncToastTheme(darkMode: boolean): void {
|
||||||
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
|
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MeToast }
|
export { MeToast }
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { useEditorStore } from '../editorStore'
|
import { useEditorStore } from '../editorStore'
|
||||||
|
|
||||||
describe('editorStore', () => {
|
describe('editorStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useEditorStore.setState({
|
useEditorStore.setState({
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
themeMode: 'light',
|
themeMode: 'light',
|
||||||
externallyModified: null,
|
externallyModified: null,
|
||||||
loadingStates: {},
|
loadingStates: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setViewMode', () => {
|
describe('setViewMode', () => {
|
||||||
it('should set view mode to preview', () => {
|
it('should set view mode to preview', () => {
|
||||||
useEditorStore.getState().setViewMode('preview')
|
useEditorStore.getState().setViewMode('preview')
|
||||||
expect(useEditorStore.getState().viewMode).toBe('preview')
|
expect(useEditorStore.getState().viewMode).toBe('preview')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set view mode to editor', () => {
|
it('should set view mode to editor', () => {
|
||||||
useEditorStore.setState({ viewMode: 'preview' })
|
useEditorStore.setState({ viewMode: 'preview' })
|
||||||
useEditorStore.getState().setViewMode('editor')
|
useEditorStore.getState().setViewMode('editor')
|
||||||
expect(useEditorStore.getState().viewMode).toBe('editor')
|
expect(useEditorStore.getState().viewMode).toBe('editor')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('themeMode', () => {
|
describe('themeMode', () => {
|
||||||
it('should default to light', () => {
|
it('should default to light', () => {
|
||||||
expect(useEditorStore.getState().themeMode).toBe('light')
|
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set theme mode', () => {
|
it('should set theme mode', () => {
|
||||||
useEditorStore.getState().setThemeMode('dark')
|
useEditorStore.getState().setThemeMode('dark')
|
||||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set warm theme', () => {
|
it('should set warm theme', () => {
|
||||||
useEditorStore.getState().setThemeMode('warm')
|
useEditorStore.getState().setThemeMode('warm')
|
||||||
expect(useEditorStore.getState().themeMode).toBe('warm')
|
expect(useEditorStore.getState().themeMode).toBe('warm')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cycleTheme', () => {
|
describe('cycleTheme', () => {
|
||||||
it('should cycle light → dark', () => {
|
it('should cycle light → dark', () => {
|
||||||
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||||
expect(useEditorStore.getState().themeMode).toBe('dark')
|
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should cycle dark → warm', () => {
|
it('should cycle dark → warm', () => {
|
||||||
useEditorStore.setState({ themeMode: 'dark' })
|
useEditorStore.setState({ themeMode: 'dark' })
|
||||||
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
|
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should cycle warm → light', () => {
|
it('should cycle warm → light', () => {
|
||||||
useEditorStore.setState({ themeMode: 'warm' })
|
useEditorStore.setState({ themeMode: 'warm' })
|
||||||
expect(useEditorStore.getState().cycleTheme()).toBe('light')
|
expect(useEditorStore.getState().cycleTheme()).toBe('light')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setExternallyModified', () => {
|
describe('setExternallyModified', () => {
|
||||||
it('should set externally modified info', () => {
|
it('should set externally modified info', () => {
|
||||||
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
|
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
|
||||||
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
|
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should clear externally modified info', () => {
|
it('should clear externally modified info', () => {
|
||||||
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
|
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
|
||||||
useEditorStore.getState().setExternallyModified(null)
|
useEditorStore.getState().setExternallyModified(null)
|
||||||
expect(useEditorStore.getState().externallyModified).toBeNull()
|
expect(useEditorStore.getState().externallyModified).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loading states', () => {
|
describe('loading states', () => {
|
||||||
it('should set loading state', () => {
|
it('should set loading state', () => {
|
||||||
useEditorStore.getState().setLoading('file-open', true)
|
useEditorStore.getState().setLoading('file-open', true)
|
||||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
|
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for unset loading key', () => {
|
it('should return false for unset loading key', () => {
|
||||||
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
|
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update loading state to false', () => {
|
it('should update loading state to false', () => {
|
||||||
useEditorStore.getState().setLoading('file-open', true)
|
useEditorStore.getState().setLoading('file-open', true)
|
||||||
useEditorStore.getState().setLoading('file-open', false)
|
useEditorStore.getState().setLoading('file-open', false)
|
||||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle multiple independent loading states', () => {
|
it('should handle multiple independent loading states', () => {
|
||||||
const { setLoading } = useEditorStore.getState()
|
const { setLoading } = useEditorStore.getState()
|
||||||
setLoading('file-open', true)
|
setLoading('file-open', true)
|
||||||
setLoading('markdown-render', true)
|
setLoading('markdown-render', true)
|
||||||
|
|
||||||
const state = useEditorStore.getState()
|
const state = useEditorStore.getState()
|
||||||
expect(state.isLoading('file-open')).toBe(true)
|
expect(state.isLoading('file-open')).toBe(true)
|
||||||
expect(state.isLoading('markdown-render')).toBe(true)
|
expect(state.isLoading('markdown-render')).toBe(true)
|
||||||
|
|
||||||
setLoading('file-open', false)
|
setLoading('file-open', false)
|
||||||
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
|
||||||
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,317 +1,317 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
// Mock external dependencies before importing store
|
// Mock external dependencies before importing store
|
||||||
vi.mock('../../db/tabRepository', () => ({
|
vi.mock('../../db/tabRepository', () => ({
|
||||||
tabRepository: {
|
tabRepository: {
|
||||||
loadAll: vi.fn().mockResolvedValue([]),
|
loadAll: vi.fn().mockResolvedValue([]),
|
||||||
loadActiveTabId: vi.fn().mockResolvedValue(null),
|
loadActiveTabId: vi.fn().mockResolvedValue(null),
|
||||||
saveAll: vi.fn().mockResolvedValue(undefined),
|
saveAll: vi.fn().mockResolvedValue(undefined),
|
||||||
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
|
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
|
||||||
clearAll: vi.fn().mockResolvedValue(undefined),
|
clearAll: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('nanoid', () => {
|
vi.mock('nanoid', () => {
|
||||||
let counter = 0
|
let counter = 0
|
||||||
return {
|
return {
|
||||||
nanoid: vi.fn(() => `test-id-${++counter}`),
|
nanoid: vi.fn(() => `test-id-${++counter}`),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
import { useTabStore } from '../tabStore'
|
import { useTabStore } from '../tabStore'
|
||||||
|
|
||||||
describe('tabStore', () => {
|
describe('tabStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset store state between tests
|
// Reset store state between tests
|
||||||
useTabStore.setState({
|
useTabStore.setState({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
mruStack: [],
|
mruStack: [],
|
||||||
_loaded: false,
|
_loaded: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('createTab', () => {
|
describe('createTab', () => {
|
||||||
it('should create a new tab with default values', () => {
|
it('should create a new tab with default values', () => {
|
||||||
const tab = useTabStore.getState().createTab()
|
const tab = useTabStore.getState().createTab()
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(tab.id).toBeTruthy()
|
expect(tab.id).toBeTruthy()
|
||||||
expect(tab.filePath).toBeNull()
|
expect(tab.filePath).toBeNull()
|
||||||
expect(tab.content).toBe('')
|
expect(tab.content).toBe('')
|
||||||
expect(tab.isModified).toBe(false)
|
expect(tab.isModified).toBe(false)
|
||||||
expect(state.tabs).toHaveLength(1)
|
expect(state.tabs).toHaveLength(1)
|
||||||
expect(state.activeTabId).toBe(tab.id)
|
expect(state.activeTabId).toBe(tab.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create a tab with file path and content', () => {
|
it('should create a tab with file path and content', () => {
|
||||||
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
|
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
|
||||||
|
|
||||||
expect(tab.filePath).toBe('/test.md')
|
expect(tab.filePath).toBe('/test.md')
|
||||||
expect(tab.content).toBe('# Hello')
|
expect(tab.content).toBe('# Hello')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should switch to existing tab if same filePath is opened', () => {
|
it('should switch to existing tab if same filePath is opened', () => {
|
||||||
useTabStore.getState().createTab('/test.md', '# First')
|
useTabStore.getState().createTab('/test.md', '# First')
|
||||||
|
|
||||||
// Create another tab to make it active
|
// Create another tab to make it active
|
||||||
useTabStore.getState().createTab('/other.md', '# Other')
|
useTabStore.getState().createTab('/other.md', '# Other')
|
||||||
|
|
||||||
// Now try to open the first file again
|
// Now try to open the first file again
|
||||||
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
|
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(2)
|
expect(state.tabs).toHaveLength(2)
|
||||||
expect(state.activeTabId).toBe(existing.id)
|
expect(state.activeTabId).toBe(existing.id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('closeTab', () => {
|
describe('closeTab', () => {
|
||||||
it('should close a tab and update activeTabId', () => {
|
it('should close a tab and update activeTabId', () => {
|
||||||
const { createTab, closeTab } = useTabStore.getState()
|
const { createTab, closeTab } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
const tab2 = createTab('/file2.md')
|
const tab2 = createTab('/file2.md')
|
||||||
|
|
||||||
closeTab(tab1.id)
|
closeTab(tab1.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(1)
|
expect(state.tabs).toHaveLength(1)
|
||||||
expect(state.tabs[0].id).toBe(tab2.id)
|
expect(state.tabs[0].id).toBe(tab2.id)
|
||||||
expect(state.activeTabId).toBe(tab2.id)
|
expect(state.activeTabId).toBe(tab2.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set activeTabId to null when closing last tab', () => {
|
it('should set activeTabId to null when closing last tab', () => {
|
||||||
const { createTab, closeTab } = useTabStore.getState()
|
const { createTab, closeTab } = useTabStore.getState()
|
||||||
const tab = createTab('/file1.md')
|
const tab = createTab('/file1.md')
|
||||||
|
|
||||||
closeTab(tab.id)
|
closeTab(tab.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(0)
|
expect(state.tabs).toHaveLength(0)
|
||||||
expect(state.activeTabId).toBeNull()
|
expect(state.activeTabId).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle closing non-existent tab gracefully', () => {
|
it('should handle closing non-existent tab gracefully', () => {
|
||||||
const { createTab, closeTab } = useTabStore.getState()
|
const { createTab, closeTab } = useTabStore.getState()
|
||||||
createTab('/file1.md')
|
createTab('/file1.md')
|
||||||
|
|
||||||
closeTab('non-existent')
|
closeTab('non-existent')
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(1)
|
expect(state.tabs).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('closeOtherTabs', () => {
|
describe('closeOtherTabs', () => {
|
||||||
it('should close all tabs except the specified one', () => {
|
it('should close all tabs except the specified one', () => {
|
||||||
const { createTab, closeOtherTabs } = useTabStore.getState()
|
const { createTab, closeOtherTabs } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
createTab('/file2.md')
|
createTab('/file2.md')
|
||||||
createTab('/file3.md')
|
createTab('/file3.md')
|
||||||
|
|
||||||
closeOtherTabs(tab1.id)
|
closeOtherTabs(tab1.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(1)
|
expect(state.tabs).toHaveLength(1)
|
||||||
expect(state.tabs[0].id).toBe(tab1.id)
|
expect(state.tabs[0].id).toBe(tab1.id)
|
||||||
expect(state.activeTabId).toBe(tab1.id)
|
expect(state.activeTabId).toBe(tab1.id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('closeAllTabs', () => {
|
describe('closeAllTabs', () => {
|
||||||
it('should close all tabs', () => {
|
it('should close all tabs', () => {
|
||||||
const { createTab, closeAllTabs } = useTabStore.getState()
|
const { createTab, closeAllTabs } = useTabStore.getState()
|
||||||
createTab('/file1.md')
|
createTab('/file1.md')
|
||||||
createTab('/file2.md')
|
createTab('/file2.md')
|
||||||
|
|
||||||
closeAllTabs()
|
closeAllTabs()
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(0)
|
expect(state.tabs).toHaveLength(0)
|
||||||
expect(state.activeTabId).toBeNull()
|
expect(state.activeTabId).toBeNull()
|
||||||
expect(state.mruStack).toHaveLength(0)
|
expect(state.mruStack).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('closeTabsToRight', () => {
|
describe('closeTabsToRight', () => {
|
||||||
it('should close tabs to the right of the specified tab', () => {
|
it('should close tabs to the right of the specified tab', () => {
|
||||||
const { createTab, closeTabsToRight } = useTabStore.getState()
|
const { createTab, closeTabsToRight } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
createTab('/file2.md')
|
createTab('/file2.md')
|
||||||
createTab('/file3.md')
|
createTab('/file3.md')
|
||||||
|
|
||||||
closeTabsToRight(tab1.id)
|
closeTabsToRight(tab1.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs).toHaveLength(1)
|
expect(state.tabs).toHaveLength(1)
|
||||||
expect(state.tabs[0].id).toBe(tab1.id)
|
expect(state.tabs[0].id).toBe(tab1.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep active tab if it is in the remaining set', () => {
|
it('should keep active tab if it is in the remaining set', () => {
|
||||||
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
|
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
createTab('/file2.md')
|
createTab('/file2.md')
|
||||||
createTab('/file3.md')
|
createTab('/file3.md')
|
||||||
|
|
||||||
switchToTab(tab1.id)
|
switchToTab(tab1.id)
|
||||||
closeTabsToRight(tab1.id)
|
closeTabsToRight(tab1.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.activeTabId).toBe(tab1.id)
|
expect(state.activeTabId).toBe(tab1.id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('switchToTab', () => {
|
describe('switchToTab', () => {
|
||||||
it('should switch active tab and update MRU stack', () => {
|
it('should switch active tab and update MRU stack', () => {
|
||||||
const { createTab, switchToTab } = useTabStore.getState()
|
const { createTab, switchToTab } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
const tab2 = createTab('/file2.md')
|
const tab2 = createTab('/file2.md')
|
||||||
|
|
||||||
switchToTab(tab1.id)
|
switchToTab(tab1.id)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.activeTabId).toBe(tab1.id)
|
expect(state.activeTabId).toBe(tab1.id)
|
||||||
expect(state.mruStack).toContain(tab2.id)
|
expect(state.mruStack).toContain(tab2.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not update if switching to already active tab', () => {
|
it('should not update if switching to already active tab', () => {
|
||||||
const { createTab, switchToTab } = useTabStore.getState()
|
const { createTab, switchToTab } = useTabStore.getState()
|
||||||
createTab('/file1.md')
|
createTab('/file1.md')
|
||||||
|
|
||||||
const stateBefore = useTabStore.getState()
|
const stateBefore = useTabStore.getState()
|
||||||
switchToTab(stateBefore.activeTabId!)
|
switchToTab(stateBefore.activeTabId!)
|
||||||
const stateAfter = useTabStore.getState()
|
const stateAfter = useTabStore.getState()
|
||||||
|
|
||||||
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
|
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('updateTabContent', () => {
|
describe('updateTabContent', () => {
|
||||||
it('should update content and mark as modified', () => {
|
it('should update content and mark as modified', () => {
|
||||||
const { createTab, updateTabContent } = useTabStore.getState()
|
const { createTab, updateTabContent } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md', '# Old')
|
const tab = createTab('/file.md', '# Old')
|
||||||
|
|
||||||
updateTabContent(tab.id, '# New Content')
|
updateTabContent(tab.id, '# New Content')
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated.content).toBe('# New Content')
|
expect(updated.content).toBe('# New Content')
|
||||||
expect(updated.isModified).toBe(true)
|
expect(updated.isModified).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getActiveTab', () => {
|
describe('getActiveTab', () => {
|
||||||
it('should return the active tab', () => {
|
it('should return the active tab', () => {
|
||||||
const { createTab, getActiveTab } = useTabStore.getState()
|
const { createTab, getActiveTab } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md')
|
const tab = createTab('/file.md')
|
||||||
|
|
||||||
const active = getActiveTab()
|
const active = getActiveTab()
|
||||||
expect(active?.id).toBe(tab.id)
|
expect(active?.id).toBe(tab.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null when no tabs exist', () => {
|
it('should return null when no tabs exist', () => {
|
||||||
const { getActiveTab } = useTabStore.getState()
|
const { getActiveTab } = useTabStore.getState()
|
||||||
expect(getActiveTab()).toBeNull()
|
expect(getActiveTab()).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setModified', () => {
|
describe('setModified', () => {
|
||||||
it('should set modified flag on a tab', () => {
|
it('should set modified flag on a tab', () => {
|
||||||
const { createTab, setModified } = useTabStore.getState()
|
const { createTab, setModified } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md')
|
const tab = createTab('/file.md')
|
||||||
|
|
||||||
setModified(tab.id, true)
|
setModified(tab.id, true)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated.isModified).toBe(true)
|
expect(updated.isModified).toBe(true)
|
||||||
|
|
||||||
setModified(tab.id, false)
|
setModified(tab.id, false)
|
||||||
const state2 = useTabStore.getState()
|
const state2 = useTabStore.getState()
|
||||||
const updated2 = state2.tabs.find(t => t.id === tab.id)!
|
const updated2 = state2.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated2.isModified).toBe(false)
|
expect(updated2.isModified).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('updateTabScroll', () => {
|
describe('updateTabScroll', () => {
|
||||||
it('should update scroll position', () => {
|
it('should update scroll position', () => {
|
||||||
const { createTab, updateTabScroll } = useTabStore.getState()
|
const { createTab, updateTabScroll } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md')
|
const tab = createTab('/file.md')
|
||||||
|
|
||||||
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
|
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated.scrollTop).toBe(100)
|
expect(updated.scrollTop).toBe(100)
|
||||||
expect(updated.selectionStart).toBe(10)
|
expect(updated.selectionStart).toBe(10)
|
||||||
expect(updated.selectionEnd).toBe(20)
|
expect(updated.selectionEnd).toBe(20)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('updateTabContent same-content guard (A3)', () => {
|
describe('updateTabContent same-content guard (A3)', () => {
|
||||||
it('should not mark as modified when content is unchanged', () => {
|
it('should not mark as modified when content is unchanged', () => {
|
||||||
const { createTab, updateTabContent } = useTabStore.getState()
|
const { createTab, updateTabContent } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md', '# Same Content')
|
const tab = createTab('/file.md', '# Same Content')
|
||||||
|
|
||||||
updateTabContent(tab.id, '# Same Content')
|
updateTabContent(tab.id, '# Same Content')
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated.content).toBe('# Same Content')
|
expect(updated.content).toBe('# Same Content')
|
||||||
expect(updated.isModified).toBe(false)
|
expect(updated.isModified).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should mark as modified when content changes', () => {
|
it('should mark as modified when content changes', () => {
|
||||||
const { createTab, updateTabContent } = useTabStore.getState()
|
const { createTab, updateTabContent } = useTabStore.getState()
|
||||||
const tab = createTab('/file.md', '# Old')
|
const tab = createTab('/file.md', '# Old')
|
||||||
|
|
||||||
updateTabContent(tab.id, '# New')
|
updateTabContent(tab.id, '# New')
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||||
|
|
||||||
expect(updated.content).toBe('# New')
|
expect(updated.content).toBe('# New')
|
||||||
expect(updated.isModified).toBe(true)
|
expect(updated.isModified).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('moveTab (D1)', () => {
|
describe('moveTab (D1)', () => {
|
||||||
it('should move tab to a new position', () => {
|
it('should move tab to a new position', () => {
|
||||||
const { createTab, moveTab } = useTabStore.getState()
|
const { createTab, moveTab } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
const tab2 = createTab('/file2.md')
|
const tab2 = createTab('/file2.md')
|
||||||
const tab3 = createTab('/file3.md')
|
const tab3 = createTab('/file3.md')
|
||||||
|
|
||||||
// tab1 移到末尾:期望 [tab2, tab3, tab1]
|
// tab1 移到末尾:期望 [tab2, tab3, tab1]
|
||||||
moveTab(tab1.id, 2)
|
moveTab(tab1.id, 2)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
// 用引用相等而非基于全局计数器 ID
|
// 用引用相等而非基于全局计数器 ID
|
||||||
expect(state.tabs[0]).toBe(tab2)
|
expect(state.tabs[0]).toBe(tab2)
|
||||||
expect(state.tabs[1]).toBe(tab3)
|
expect(state.tabs[1]).toBe(tab3)
|
||||||
expect(state.tabs[2]).toBe(tab1)
|
expect(state.tabs[2]).toBe(tab1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle moving to same position (no-op)', () => {
|
it('should handle moving to same position (no-op)', () => {
|
||||||
const { createTab, moveTab } = useTabStore.getState()
|
const { createTab, moveTab } = useTabStore.getState()
|
||||||
const tab1 = createTab('/file1.md')
|
const tab1 = createTab('/file1.md')
|
||||||
createTab('/file2.md')
|
createTab('/file2.md')
|
||||||
|
|
||||||
moveTab(tab1.id, 0)
|
moveTab(tab1.id, 0)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs[0].id).toBe(tab1.id)
|
expect(state.tabs[0].id).toBe(tab1.id)
|
||||||
expect(state.tabs.length).toBe(2)
|
expect(state.tabs.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle moving non-existent tab gracefully', () => {
|
it('should handle moving non-existent tab gracefully', () => {
|
||||||
const { createTab, moveTab } = useTabStore.getState()
|
const { createTab, moveTab } = useTabStore.getState()
|
||||||
createTab('/file1.md')
|
createTab('/file1.md')
|
||||||
|
|
||||||
moveTab('non-existent', 0)
|
moveTab('non-existent', 0)
|
||||||
const state = useTabStore.getState()
|
const state = useTabStore.getState()
|
||||||
|
|
||||||
expect(state.tabs.length).toBe(1)
|
expect(state.tabs.length).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轻量 auto-save 状态 store
|
* 轻量 auto-save 状态 store
|
||||||
*
|
*
|
||||||
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
|
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
|
||||||
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
|
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
|
||||||
* useAutoSave hook 负责写入本 store。
|
* useAutoSave hook 负责写入本 store。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface AutoSaveState {
|
interface AutoSaveState {
|
||||||
isAutoSaving: boolean
|
isAutoSaving: boolean
|
||||||
autoSaveEnabled: boolean
|
autoSaveEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoSaveStore extends AutoSaveState {
|
interface AutoSaveStore extends AutoSaveState {
|
||||||
setState: (partial: Partial<AutoSaveState>) => void
|
setState: (partial: Partial<AutoSaveState>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
|
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
|
||||||
isAutoSaving: false,
|
isAutoSaving: false,
|
||||||
autoSaveEnabled: true,
|
autoSaveEnabled: true,
|
||||||
setState: (partial) => set(partial)
|
setState: (partial) => set(partial)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { ViewMode, ThemeMode } from '../types/settings'
|
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||||
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||||
|
|
||||||
// Module-level getter for the MetonaEditor instance
|
// Module-level getter for the MetonaEditor instance
|
||||||
// Used by OutlinePanel for heading navigation
|
// Used by OutlinePanel for heading navigation
|
||||||
let _getEditor: (() => MarkdownEditor | null) | null = null
|
let _getEditor: (() => MarkdownEditor | null) | null = null
|
||||||
|
|
||||||
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
||||||
_getEditor = fn
|
_getEditor = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMetonaEditor(): MarkdownEditor | null {
|
export function getMetonaEditor(): MarkdownEditor | null {
|
||||||
return _getEditor ? _getEditor() : null
|
return _getEditor ? _getEditor() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
themeMode: ThemeMode
|
themeMode: ThemeMode
|
||||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||||
externallyModified: { filePath: string } | null
|
externallyModified: { filePath: string } | null
|
||||||
// UX-02: 全局加载状态
|
// UX-02: 全局加载状态
|
||||||
loadingStates: Record<string, boolean>
|
loadingStates: Record<string, boolean>
|
||||||
|
|
||||||
setViewMode: (mode: ViewMode) => void
|
setViewMode: (mode: ViewMode) => void
|
||||||
setThemeMode: (mode: ThemeMode) => void
|
setThemeMode: (mode: ThemeMode) => void
|
||||||
cycleTheme: () => ThemeMode
|
cycleTheme: () => ThemeMode
|
||||||
setExternallyModified: (info: { filePath: string } | null) => void
|
setExternallyModified: (info: { filePath: string } | null) => void
|
||||||
// UX-02: 加载状态管理
|
// UX-02: 加载状态管理
|
||||||
setLoading: (key: string, loading: boolean) => void
|
setLoading: (key: string, loading: boolean) => void
|
||||||
isLoading: (key: string) => boolean
|
isLoading: (key: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
themeMode: 'light',
|
themeMode: 'light',
|
||||||
externallyModified: null,
|
externallyModified: null,
|
||||||
loadingStates: {},
|
loadingStates: {},
|
||||||
|
|
||||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||||
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
||||||
cycleTheme: () => {
|
cycleTheme: () => {
|
||||||
const current = get().themeMode
|
const current = get().themeMode
|
||||||
const idx = THEME_CYCLE.indexOf(current)
|
const idx = THEME_CYCLE.indexOf(current)
|
||||||
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
|
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
|
||||||
set({ themeMode: next })
|
set({ themeMode: next })
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||||
})),
|
})),
|
||||||
isLoading: (key: string) => get().loadingStates[key] ?? false
|
isLoading: (key: string) => get().loadingStates[key] ?? false
|
||||||
}))
|
}))
|
||||||
|
|||||||
+51
-51
@@ -1,51 +1,51 @@
|
|||||||
import type {
|
import type {
|
||||||
OpenFileResponse,
|
OpenFileResponse,
|
||||||
ReadFileResult,
|
ReadFileResult,
|
||||||
SaveFilePayload,
|
SaveFilePayload,
|
||||||
SaveFileResult,
|
SaveFileResult,
|
||||||
SaveAsPayload,
|
SaveAsPayload,
|
||||||
ReloadFileResult,
|
ReloadFileResult,
|
||||||
FileStatsResult,
|
FileStatsResult,
|
||||||
ReadDirTreeResult
|
ReadDirTreeResult
|
||||||
} from '../../shared/types'
|
} from '../../shared/types'
|
||||||
|
|
||||||
export interface IpcInvokeMap {
|
export interface IpcInvokeMap {
|
||||||
'dialog:openFile': [void, OpenFileResponse]
|
'dialog:openFile': [void, OpenFileResponse]
|
||||||
'file:read': [string, ReadFileResult]
|
'file:read': [string, ReadFileResult]
|
||||||
'file:save': [SaveFilePayload, SaveFileResult]
|
'file:save': [SaveFilePayload, SaveFileResult]
|
||||||
'file:saveAs': [SaveAsPayload, SaveFileResult]
|
'file:saveAs': [SaveAsPayload, SaveFileResult]
|
||||||
'file:getCurrentPath': [void, string | null]
|
'file:getCurrentPath': [void, string | null]
|
||||||
'file:stats': [string, FileStatsResult]
|
'file:stats': [string, FileStatsResult]
|
||||||
'file:reload': [void, ReloadFileResult]
|
'file:reload': [void, ReloadFileResult]
|
||||||
'tab:switched': [string | null, void]
|
'tab:switched': [string | null, void]
|
||||||
'window:forceClose': [void, void]
|
'window:forceClose': [void, void]
|
||||||
'window:cancelClose': [void, void]
|
'window:cancelClose': [void, void]
|
||||||
'dir:readTree': [string, ReadDirTreeResult]
|
'dir:readTree': [string, ReadDirTreeResult]
|
||||||
'dir:openDialog': [void, string | null]
|
'dir:openDialog': [void, string | null]
|
||||||
'dir:watch': [string, void]
|
'dir:watch': [string, void]
|
||||||
'dir:unwatch': [void, void]
|
'dir:unwatch': [void, void]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Unsubscribe = () => void
|
export type Unsubscribe = () => void
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
openFile: () => Promise<OpenFileResponse>
|
openFile: () => Promise<OpenFileResponse>
|
||||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||||
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
||||||
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
||||||
getCurrentPath: () => Promise<string | null>
|
getCurrentPath: () => Promise<string | null>
|
||||||
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
||||||
reloadFile: () => Promise<ReloadFileResult>
|
reloadFile: () => Promise<ReloadFileResult>
|
||||||
tabSwitched: (filePath: string | null) => Promise<void>
|
tabSwitched: (filePath: string | null) => Promise<void>
|
||||||
forceClose: () => Promise<void>
|
forceClose: () => Promise<void>
|
||||||
cancelClose: () => Promise<void>
|
cancelClose: () => Promise<void>
|
||||||
openExternal: (url: string) => void
|
openExternal: (url: string) => void
|
||||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||||
openFolderDialog: () => Promise<string | null>
|
openFolderDialog: () => Promise<string | null>
|
||||||
watchDir: (dirPath: string) => Promise<void>
|
watchDir: (dirPath: string) => Promise<void>
|
||||||
unwatchDir: () => Promise<void>
|
unwatchDir: () => Promise<void>
|
||||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||||
onDirChanged: (callback: () => void) => Unsubscribe
|
onDirChanged: (callback: () => void) => Unsubscribe
|
||||||
onConfirmClose: (callback: () => void) => Unsubscribe
|
onConfirmClose: (callback: () => void) => Unsubscribe
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export type ThemeMode = 'light' | 'dark' | 'warm'
|
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
themeMode: ThemeMode
|
themeMode: ThemeMode
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
themeMode: 'light',
|
themeMode: 'light',
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
sidebarWidth: 240
|
sidebarWidth: 240
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// 共享常量 — 主进程和渲染进程共用
|
// 共享常量 — 主进程和渲染进程共用
|
||||||
export const APP_VERSION = 'v0.4.4'
|
export const APP_VERSION = 'v0.5.0'
|
||||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||||
export const SKIP_DIRS = new Set([
|
export const SKIP_DIRS = new Set([
|
||||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||||
])
|
])
|
||||||
|
|||||||
+26
-26
@@ -1,26 +1,26 @@
|
|||||||
// IPC 通道名常量 — 主进程和渲染进程共享
|
// IPC 通道名常量 — 主进程和渲染进程共享
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
// 渲染进程 → 主进程 (invoke)
|
// 渲染进程 → 主进程 (invoke)
|
||||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||||
FILE_READ: 'file:read',
|
FILE_READ: 'file:read',
|
||||||
FILE_SAVE: 'file:save',
|
FILE_SAVE: 'file:save',
|
||||||
FILE_SAVE_AS: 'file:saveAs',
|
FILE_SAVE_AS: 'file:saveAs',
|
||||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||||
FILE_STATS: 'file:stats',
|
FILE_STATS: 'file:stats',
|
||||||
FILE_RELOAD: 'file:reload',
|
FILE_RELOAD: 'file:reload',
|
||||||
TAB_SWITCHED: 'tab:switched',
|
TAB_SWITCHED: 'tab:switched',
|
||||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||||
DIR_READ_TREE: 'dir:readTree',
|
DIR_READ_TREE: 'dir:readTree',
|
||||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||||
DIR_WATCH: 'dir:watch',
|
DIR_WATCH: 'dir:watch',
|
||||||
DIR_UNWATCH: 'dir:unwatch',
|
DIR_UNWATCH: 'dir:unwatch',
|
||||||
|
|
||||||
// 主进程 → 渲染进程 (send)
|
// 主进程 → 渲染进程 (send)
|
||||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||||
|
|||||||
Reference in New Issue
Block a user