21 Commits
Author SHA1 Message Date
thzxx bbaea2df3e docs: 更新关于弹框与 README(移除状态栏、依赖版本 0.4.1/0.4.4) 2026-08-09 22:07:15 +08:00
thzxx cf920ab2e6 refactor: 移除自定义状态栏 — 使用 MetonaEditor 自带底栏
- editor 自带底栏(wordCount: true)已显示字符数/词数/行数/阅读时间
- 删除自定义 StatusBar 组件、editorStore 的 stats/cursor 状态与事件绑定
- 保留 zenMode 同步(Toolbar 专注按钮消费);自动保存状态由 Toolbar 按钮显示
2026-08-09 22:04:06 +08:00
thzxx cb243c5c95 fix: 存储引擎回归 AriaEngine — sqlark 0.4.4 修复 SSTable 大 value 编码缺陷
- 0.4.4 引入 v2 格式(magic SSTC):u32 长度字段(修复 u16 截断 >64KB value)
  + UTF-8 字节精确估算(修复块大小低估导致写入越界)+ 超大条目独立成块
- 真实 Chromium 实测:300KB 中文内容写入 → close → 重启 → 读回数据完好,5 轮零失败
- schema.ts 切回 aria(version 0 + aria- 前缀内部库名)
2026-08-09 21:54:04 +08:00
thzxx b814114ff0 fix: 存储引擎切回 HybridEngine — AriaEngine SSTable 大 value 编码缺陷
根因(真实 Chromium 环境实测复现):
- AriaEngine SSTableBuilder.computeBlockSize 用 JS 字符串 length 估算块大小,
  实际写入为 UTF-8 字节(中文 1 字符 = 3 字节)→ finalSize 缓冲区低估
  → 大文件内容(中文 markdown)flush 时写入越界(offset is out of bounds)
- valueLen 为 u16(上限 64KB),大 value 长度截断
- 关闭时 flush 崩溃 → 半写 SSTable → 下次打开 DataView 越界 → ARIA_OPEN_ERROR

HybridEngine 行级 IDB 存储无此限制(300KB 中文内容实测闭环正常)
2026-08-09 21:26:47 +08:00
thzxx 63fa0d3040 fix: 数据库初始化加固 — 关闭流程禁止重开 + initDb 串行化 + 错误详情显示
- closeDatabase 后置 isClosing 标记,关闭流程中 getDb 直接拒绝,
  避免 forceClose 前其他组件重新建立连接(未关闭即被进程终止)
- initDb 串行链:失败重置后并发 getDb 不再同时 createDatabase
- 弹框显示 ARIA_OPEN_ERROR 等包装错误的 details(原始异常信息)
2026-08-09 21:09:15 +08:00
thzxx 8bee37cb89 release: v0.6.1 — 专注模式宽度官方 API 100% + 版本号更新 2026-08-09 20:45:37 +08:00
thzxx a981d9fd08 style: 专注模式宽度调整为 1200px 2026-08-09 20:39:04 +08:00
thzxx 8398e3f124 refactor: 专注模式宽度改用 0.4.1 官方 API(zenMaxWidth: '100%'),移除 CSS hack 2026-08-09 20:37:19 +08:00
thzxx 6450cd6435 chore: 升级 metona-editor 0.4.1 2026-08-09 20:34:53 +08:00
thzxx c647b7e602 chore: 升级 metona-sqlark 0.4.3(修复 close 后后台任务竞态)
- 0.4.3 修复: 写操作串行队列、close 前排空后台 flush/compaction、
  去掉 setTimeout 分片(close 后定时器执行问题)、活跃事务先回滚
- 实测 4 轮重启数据一条不丢、强杀重开完好、零警告
2026-08-09 19:54:10 +08:00
thzxx 764d3d831f fix: 存储引擎回归 AriaEngine(sqlark 0.4.2 已修复崩溃损坏问题)
- AriaEngine(LSM-Tree + WAL + MVCC)功能更强,0.4.2 修复了 0.4.1 的半写损坏
- 实测 4 轮重启数据一条不丢、强杀重开完好、旧残缺 SSTable 被完整性校验跳过
- 自愈删库恢复 aria- 前缀内部库名
2026-08-09 19:21:57 +08:00
thzxx 7e0e579dac chore: 升级 metona-sqlark 0.4.2(修复引擎损坏/版本管理/WAL 丢数据等全部已知问题)
- 移除 resolveDbVersion hack,改用标准 API(version: 1,0.4.2 打开时自动版本自适应)
- 实测 4 轮重启数据一条不丢、强杀重开完好
- 0.4.2 修复项:SSTable 残缺块跳过/完整性校验/repair 自愈、VersionError 自适应、
  version 0 TypeError、WAL count 原子提交、close 前 checkpoint、错误统一包装、
  迁移版本持久化、blocked 重试
2026-08-09 19:20:05 +08:00
thzxx b762488ac9 fix: 存储引擎切换为 HybridEngine(write-through 直写 IDB)+ 动态版本解析
- AriaEngine 的 SSTable/WAL 多 key 非原子写入在异常退出/强杀时留下半写文件,
  重启解析越界(offset is out of bounds)导致每次关闭后必损坏
- HybridEngine 每行单 key 原子写入 IDB,实测 close/强杀/多次重启数据完好
- 解决 sqlark IndexedDBEngine 建表升级版本号导致的 VersionError:
  启动时解析库当前版本再打开
- Zen 专注模式宽度改为全宽(max-width: none)
2026-08-09 17:17:39 +08:00
thzxx 0e06219969 chore: 移除误提交的临时测试脚本 2026-08-09 17:05:48 +08:00
thzxx 58c8a1a3a0 fix: 关闭应用前干净关闭数据库 + 自愈仅启动早期触发
- 退出前 db.close() 确保 WAL/SSTable 干净落盘,避免渲染进程销毁时 flush 中断留下半写文件导致下次启动损坏
- 自愈(删损坏库/重载)仅限页面生命周期内首次初始化;运行中失败直接抛错,不再阻塞应用关闭流程
2026-08-09 17:05:31 +08:00
thzxx 9fcbec790d fix: 移除 devDependencies 中残留的 rollup linux 平台包声明,平台依赖恢复由 rollup optional 机制管理 2026-08-09 16:54:06 +08:00
thzxx 26d54f042b chore: 移除 rollup 平台包 postinstall 脚本,平台依赖按需手动安装 2026-08-09 16:52:17 +08:00
thzxx ee5f35177e release: v0.6.0 — 内置解析器迁移 / Toast 全面接入 / Sqlark 深度集成
- 渲染管线迁移至 MetonaEditor 内置解析器,移除 unified/rehype 全家桶(9 个依赖)
- 修复相对路径图片修复的目录前缀碰撞与路径解析 bug
- ConfirmDialog/useConfirm/LoadingSpinner/useDocStats 移除,改用 MeToast.confirm/loading/promise
- 新增状态栏(字数/行数/阅读时间/光标位置)、Zen 模式、数据备份导出导入
- Sqlark: 版本化迁移(addMigration/migrateTo)、subscribe 表变更、备份 exportAll/importTable
- 数据库损坏自愈:异常退出残留残缺 SSTable 导致打开失败时自动重建
- 大纲导航改用 scrollToLine 官方 API
- 修复 rollup 平台包互删(postinstall 自动补齐)+ 集成测试(fake-indexeddb)
2026-08-09 16:50:06 +08:00
thzxx 20eb39efc5 release: v0.5.0 — 升级 MetonaEditor 0.4.0 / MetonaToast 0.5.0,数据库迁移 MetonaSqlark 0.4.1
- 编辑器升级 0.4.0:TypeScript 全模块重构、启用浮动格式工具栏
- 通知升级 0.5.0:钩子系统、新增动画
- 移除 Dexie.js,迁移至 MetonaSqlark 0.4.1(AriaEngine:LSM-Tree + WAL + MVCC)
- 数据库更换为 MarkLiteV2,旧 Dexie 数据放弃
- 修复 Editor 组件无条件重渲染(getActiveTab 返回新引用)
- 更新关于弹框、README、DESIGN 文档
2026-08-09 15:45:36 +08:00
thzxx d6ae9e3c8e release: v0.4.5 — Mermaid 图表渲染支持
新增 Mermaid 图表渲染:
- npm 安装 mermaid@10 作为项目依赖
- markdown.ts 新增 rehypeMermaid 插件:将 mermaid 代码块
  转换为 .me-mermaid 容器(与 MetonaEditor 内置输出一致)
- Editor.tsx 初始化 mermaid (startOnLoad: false)
- 绑定 afterRender 事件 → mermaid.run() 渲染 SVG 图表
- 首次加载 setTimeout 兜底渲染

技术实现:
- rehypeMermaid 插件在 rehypeHighlight 之后运行
- 查找 pre > code.language-mermaid 节点
- 替换为 div.me-mermaid > div.mermaid 容器
- 保持 unified 管线完整(图片路径修复 + sanitize + highlight)
- mermaid.run({ querySelector: '.me-mermaid .mermaid' })

质量保证:
- TypeScript 严格模式: 0 errors
- 单元测试: 98/98 passed (16 markdown + 82 others)
2026-07-25 16:41:14 +08:00
thzxx 2e7baa1073 release: v0.4.4 — 升级 MetonaEditor 0.2.4 & MetonaToast 0.2.1
依赖升级:
- @metona-team/metona-editor: 0.1.14 → 0.2.4
- @metona-team/metona-toast: 0.1.2 → 0.2.1(精确版本)

Editor.tsx 适配 v0.2.4:
- 移除 ExtendedEditorOptions 类型 hack(lineNumbers/autoBrackets 已原生支持)
- 本地声明 EditMode/ThemeName 类型(v0.2.4 不再命名导出)
- 适配 onModeChange 签名 (mode: EditMode → mode: string)
- 适配 onLinkClick 签名 ({href}) → (href, text, editor)
- 新增 syncScroll: true 同步滚动
- 新增 wordWrap: true 自动换行
- 新增 outline: false(保留自研 OutlinePanel)
- 新增 historyLimit: 100 / historyDebounce: 400
- 新增 shortcutHelp 预设插件(按 ? 弹出快捷键面板)
- 新增 onLinkClick 回调(通过 openExternal 安全打开链接)
- 新增 onFocus / onBlur 回调(预留扩展点)

toast.ts 适配 v0.2.1:
- 安装 keyboard 插件(ESC 关闭所有 Toast)
- 安装 accessibility 插件(屏幕阅读器朗读)
- 新增 width: 360 配置
2026-07-25 16:23:59 +08:00
70 changed files with 7026 additions and 5123 deletions
+4
View File
@@ -6,6 +6,10 @@
# 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 私有 registrymetona-editor / metona-toast / metona-sqlark):
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
# //git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
#
# 配置说明: # 配置说明:
# registry — npm 包下载源 # registry — npm 包下载源
# ELECTRON_MIRROR — Electron 二进制文件下载源 # ELECTRON_MIRROR — Electron 二进制文件下载源
+64 -62
View File
@@ -2,13 +2,13 @@
## 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.4.0 编辑器(三模式视图 + 内置解析器 + 插件系统)、Zustand 状态管理、MetonaSqlarkAriaEngine)持久化
### 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 + 内置解析器 XSS 防护
4. **可测试性** — 业务逻辑(lib/)与 UIcomponents/)解耦 4. **可测试性** — 业务逻辑(lib/)与 UIcomponents/)解耦
## 2. 技术架构 ## 2. 技术架构
@@ -20,12 +20,12 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 桌面框架 | 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 解析 | MetonaEditor 内置解析器 | v0.4.0 | 零依赖,GFM + 脚注 + 数学公式 + mermaid |
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js | | 代码高亮 | MetonaEditor 内置高亮器 | v0.4.0 | 零依赖,16 种语言 |
| Toast | @metona-team/metona-toast | v2.0.1 | 通知提示组件 | | Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 |
| 构建工具 | electron-vite | v3 | Electron + ViteHMR 热更新 | | 构建工具 | electron-vite | v3 | Electron + ViteHMR 热更新 |
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 | | 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 | | 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
@@ -86,7 +86,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
| 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 | `MetonaEditor 内置解析器` | 渲染 MarkdownescapeHTML + safeUrl XSS 防护) |
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | | 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
| 路径 | `validatePath()` | 防止路径遍历攻击 | | 路径 | `validatePath()` | 防止路径遍历攻击 |
@@ -109,7 +109,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │ │ │ │ │ │ │
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │ │ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
│ │ IndexedDB (Dexie.js) │ │ │ │ IndexedDB (MetonaSqlark) │ │
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │ │ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
│ └───────────────────────────────────────────────────┘ │ │ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
@@ -164,29 +164,42 @@ interface SidebarState {
## 4. 数据持久化 — IndexedDB ## 4. 数据持久化 — IndexedDB
通过 Dexie.js 封装 IndexedDB v0.5.0: 由 Dexie.js 迁移至 MetonaSqlarkAriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。
MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()),
表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。
### 4.1 数据库 Schema ### 4.1 数据库 Schema
```typescript ```typescript
const db = new Dexie('MarkLite') // schema.ts — 懒加载单例
let dbPromise: Promise<MetonaSqlark> | null = null
db.version(1).stores({ export function getDb(): Promise<MetonaSqlark> {
tabSnapshots: 'id, filePath, updatedAt', // 标签页快照 if (!dbPromise) {
settings: 'id', // 用户设置 dbPromise = MetonaSqlark.create({
recentFiles: '++id, filePath, lastOpened', // 最近打开文件 name: 'MarkLiteV2',
activeTab: 'id' // 当前活动标签 mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
}) diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 1,
}).then(async (db) => {
if (!(await db.getTableNames()).includes('tabSnapshots')) {
await db.defineTable('tabSnapshots', { id: { type: 'string', primaryKey: true }, ... })
}
// settings / recentFiles / activeTab 同理
return db
})
}
return dbPromise
}
``` ```
### 4.2 数据模型 ### 4.2 数据模型
| Store | 字段 | 说明 | | Store | 字段 | 说明 |
|-------|------|------| |-------|------|------|
| `tabSnapshots` | id, filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt | 标签页状态快照 | | `tabSnapshots` | id(PK), filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt(index) | 标签页状态快照 |
| `settings` | id, darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 | | `settings` | id(PK), themeMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 | | `recentFiles` | filePath(PK), lastOpened(index) | 最近打开文件列表v0.5.0 改用 filePath 主键,sqlark 无自增) |
| `activeTab` | id, activeTabId | 当前活动标签 ID | | `activeTab` | id(PK), activeTabId | 当前活动标签 ID |
## 5. IPC 通信设计 ## 5. IPC 通信设计
@@ -252,26 +265,27 @@ MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向
### 6.4 渲染管线集成 ### 6.4 渲染管线集成
通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现: v0.6.0: 移除 unified/remark/rehype 自研管线,改用 MetonaEditor **内置解析器**parseMarkdown),
通过 `render` 钩子接入,实现:
- **相对路径图片解析**将相对路径转换为 `file://` 绝对路径 - **相对路径图片解析**内置解析器的 safeUrl 会过滤 `file:` 协议,因此渲染后做 HTML 后处理,将相对路径图片 src 转换为 `file://` 绝对路径(越界路径保持原样)
- **XSS 防护**rehype-sanitize 过滤危险标签 - **XSS 防护**内置 escapeHTML + safeUrl(过滤 javascript:/vbscript:/file:/data:+ 属性转义
- **代码高亮**rehype-highlight 语法高亮 - **代码高亮**内置零依赖高亮器(`highlight: MeEditor.highlight`16 种语言)
- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶 - **Mermaid 图表**:内置解析器原生输出 `.me-mermaid` 容器,`mermaid.run()` 直接渲染
``` ```
Markdown 源码 Markdown 源码
unified 管线(renderMarkdownSync parseMarkdownMetonaEditor 内置解析器
├── remark-parse 解析为 MDAST ├── GFM / 任务列表 / 表格 / 删除线
├── remark-gfm GFM 扩展 ├── 脚注 / 数学公式 / 定义列表 / emoji
├── remark-rehype 转换为 HAST ├── 引用链接 / 自动链接 / 上下标
├── rehype-raw 解析内联 HTML ├── mermaid → <div class="me-mermaid">…
── rehype-sanitize 安全过滤 ── XSS 防护(escapeHTML + safeUrl
├── rehype-fixImages 相对路径 → file://
├── rehype-highlight 代码高亮
└── rehype-stringify 序列化为 HTML fixImageSrcsHTML 后处理:相对路径 → file://)
MetonaEditor 预览区渲染 MetonaEditor 预览区渲染
@@ -293,35 +307,24 @@ MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变
## 7. Markdown 渲染管线 ## 7. Markdown 渲染管线
v0.6.0: 渲染完全由 MetonaEditor 内置解析器承担(零依赖),应用侧仅保留图片路径修复后处理:
``` ```
Markdown 文本 Markdown 文本
remark-parse 解析为 MDAST parseMarkdownMetonaEditor 内置解析器)
├── 块级:标题 / 列表 / 引用 / 代码块 / 表格 / 水平线 / 脚注 / 数学公式 / 定义列表
├── 行内:粗体 / 斜体 / 删除线 / 高亮 / 上下标 / 行内代码 / 链接 / 图片 / emoji
├── mermaid<div class="me-mermaid"><pre class="mermaid">…
└── 安全:escapeHTML 转义 + safeUrl URL 过滤 + 属性级注入防护
remark-gfm 扩展 GFM 语法 fixImageSrcsmarkdown.ts 后处理)
└── 相对路径图片 src → file:// 绝对路径(越界 ../ 不处理)
remark-rehype 转换为 HAST MetonaEditor 预览区 / getHTML 导出
rehype-raw 解析内联 HTML
rehype-sanitize 安全过滤
rehype-fixImages 相对路径图片转 file:// URL
rehype-highlight 代码语法高亮
rehype-stringify 序列化为 HTML
Renderer (MetonaEditor preview / Preview component)
``` ```
## 8. UI 设计 ## 8. UI 设计
@@ -402,12 +405,11 @@ npm run build:portable # 便携版(免安装)
|------|------|------| |------|------|------|
| react / react-dom | ^18.3 | UI 框架 | | react / react-dom | ^18.3 | UI 框架 |
| zustand | ^5.0 | 状态管理 | | zustand | ^5.0 | 状态管理 |
| dexie | ^4.0 | IndexedDB 封装 | | @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(AriaEngine) |
| nanoid | ^5.0 | 唯一 ID 生成 | | nanoid | ^5.0 | 唯一 ID 生成 |
| @metona-team/metona-editor | 0.1.14 | Markdown 编辑器(零依赖 | | @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(内置解析器 + 高亮 |
| @metona-team/metona-toast | ^2.0.1 | Toast 通知组件 | | @metona-team/metona-toast | 0.5.0 | Toast 通知组件 |
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 | | mermaid | ^10.9 | Mermaid 图表渲染 |
| rehype-highlight | ^7.0 | 代码语法高亮 |
### 开发依赖 ### 开发依赖
+1 -1
View File
@@ -1,4 +1,4 @@
# MarkLite v0.4.3 — 开发环境配置指南 # MarkLite v0.5.0 — 开发环境配置指南
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。 以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
+19 -14
View File
@@ -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.6.1-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,18 @@
|------|------| |------|------|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 | | ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置解析器渲染、格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、Zen 专注模式、粘贴图片转 base64 |
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 | | 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,MetonaEditor 内置解析器渲染 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🔤 **代码高亮** | MetonaEditor 内置零依赖高亮器(js / ts / python / bash / css / html / json 等 16 种语言) |
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 | | 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB),编辑器主题同步切换 | | 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 | | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | | 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | | 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 |
| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlarkAriaEngine)持久化,关闭后可恢复 |
| 📦 **数据备份** | 一键导出/导入全部数据为 JSON 文件(MetonaSqlark exportAll/importTable |
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | | ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | | 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 | | 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
@@ -134,7 +136,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 +147,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.1 | 内置解析器 + 高亮,三模式视图 + 浮动格式栏 + Zen 模式 + 插件系统 |
| 状态管理 | [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.4 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 |
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | | Markdown 解析 | MetonaEditor 内置解析器 | 零依赖:GFM / 脚注 / 数学公式 / Mermaid |
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | | 代码高亮 | MetonaEditor 内置高亮器 | 零依赖,16 种语言 |
| 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 +227,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 +263,7 @@ MarkLite/
- ✅ 引用块 - ✅ 引用块
- ✅ 水平线 - ✅ 水平线
- ✅ Emoji 短码 `:smile:` `:rocket:` - ✅ Emoji 短码 `:smile:` `:rocket:`
- ✅ Mermaid 图表 ` ```mermaid `
- ✅ HTML 内联元素 - ✅ HTML 内联元素
- ✅ GFMGitHub Flavored Markdown - ✅ GFMGitHub Flavored Markdown
+216 -438
View File
@@ -4,518 +4,296 @@
<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.1.10在线演示</title> <title>MetonaEditor v0.4.1 — 全功能演示</title>
<style> <style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root { :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}
--app-bg: #f5f6f8; [data-md-theme=dark]{--app-bg:#0f1117;--app-text:#e2e8f0;--app-card:#1a1d27;--app-border:#2d3140;--header-bg:linear-gradient(135deg,#0a0d15 0%,#161923 100%)}
--app-text: #1a1a2e; body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--app-bg);color:var(--app-text);min-height:100vh;line-height:1.6;transition:background .3s,color .3s}
--app-card: #ffffff;
--app-border: #e5e7eb;
--app-accent: #3b82f6;
--app-accent2: #8b5cf6;
/* dark overrides */ .header{background:var(--header-bg);color:var(--header-text);padding:28px 24px 20px;text-align:center;position:relative;overflow:hidden}
--header-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); .header::before{content:'';position:absolute;top:-60px;right:-60px;width:300px;height:300px;background:radial-gradient(circle,rgba(139,92,246,.3) 0%,transparent 70%);border-radius:50%}
--header-text: #f1f5f9; .header h1{font-size:1.85rem;font-weight:800;letter-spacing:-.5px;position:relative}
--badge-bg: rgba(255,255,255,0.12); .header h1 span.grad{background:linear-gradient(135deg,var(--app-accent),var(--app-accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
--badge-text: #e2e8f0; .header .badges{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:12px;position:relative}
} .header .badge{padding:4px 12px;border-radius:20px;font-size:13px;font-weight:500;background:var(--badge-bg);color:var(--badge-text);border:1px solid rgba(255,255,255,.1)}
[data-md-theme="dark"] {
--app-bg: #0f1117;
--app-text: #e2e8f0;
--app-card: #1a1d27;
--app-border: #2d3140;
--header-bg: linear-gradient(135deg, #0a0d15 0%, #161923 100%);
}
body { .toolbar-strip{display:flex;align-items:center;gap:8px;padding:8px 20px;background:var(--app-card);border-bottom:1px solid var(--app-border);flex-wrap:wrap;position:sticky;top:0;z-index:50;transition:background .3s}
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; .toolbar-strip label{font-size:12px;font-weight:500;color:var(--app-text);opacity:.7;white-space:nowrap}
background: var(--app-bg); .toolbar-strip select{padding:4px 8px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;background:var(--app-bg);color:var(--app-text);cursor:pointer}
color: var(--app-text); .toolbar-strip button{padding:5px 12px;border:1px solid var(--app-border);border-radius:6px;font-size:12px;font-weight:500;background:var(--app-card);color:var(--app-text);cursor:pointer;transition:all .15s;white-space:nowrap}
min-height: 100vh; .toolbar-strip button:hover{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
line-height: 1.6; .toolbar-strip button.on{background:var(--app-accent);color:#fff;border-color:var(--app-accent)}
transition: background 0.3s, color 0.3s; .toolbar-strip .spacer{flex:1}
}
/* ====== Header ====== */ .editor-section{max-width:1280px;margin:20px auto;padding:0 20px}
.header { .editor-card{border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.06);background:var(--app-card);border:1px solid var(--app-border);transition:.3s}
background: var(--header-bg); .editor-card:hover{box-shadow:0 8px 36px rgba(0,0,0,.1)}
color: var(--header-text);
padding: 32px 24px 24px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -60px; right: -60px;
width: 300px; height: 300px;
background: radial-gradient(circle, rgba(139,92,246,0.3) 0%, transparent 70%);
border-radius: 50%;
}
.header h1 {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.5px;
position: relative;
}
.header h1 span.grad {
background: linear-gradient(135deg, var(--app-accent), var(--app-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header .badges {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 14px;
position: relative;
}
.header .badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: var(--badge-bg);
color: var(--badge-text);
border: 1px solid rgba(255,255,255,0.1);
}
/* ====== Toolbar Strip ====== */ .info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;max-width:1280px;margin:20px auto;padding:0 20px}
.toolbar-strip { .info-card{background:var(--app-card);border:1px solid var(--app-border);border-radius:10px;padding:16px 18px;transition:.3s}
display: flex; .info-card:hover{box-shadow:0 4px 16px rgba(0,0,0,.08)}
align-items: center; .info-card .icon{font-size:22px;margin-bottom:6px}
gap: 10px; .info-card h4{font-size:13px;font-weight:650;margin-bottom:3px}
padding: 10px 24px; .info-card p{font-size:12px;color:var(--app-text);opacity:.65;margin:0}
background: var(--app-card);
border-bottom: 1px solid var(--app-border);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 50;
transition: background 0.3s;
}
.toolbar-strip label {
font-size: 13px;
font-weight: 500;
color: var(--app-text);
opacity: 0.7;
white-space: nowrap;
}
.toolbar-strip select {
padding: 5px 10px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
background: var(--app-bg);
color: var(--app-text);
cursor: pointer;
}
.toolbar-strip button {
padding: 6px 14px;
border: 1px solid var(--app-border);
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: var(--app-card);
color: var(--app-text);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-strip button:hover { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip button.accent { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
.toolbar-strip .spacer { flex: 1; }
.toggle-group { display: flex; gap: 2px; } .footer{text-align:center;padding:28px 24px;font-size:13px;color:var(--app-text);opacity:.5}
.toggle-group button { .footer a{color:var(--app-accent);text-decoration:none}
border-radius: 0; @media(max-width:768px){.header h1{font-size:1.4rem}.toolbar-strip{padding:6px 10px;gap:4px}.toolbar-strip button{padding:4px 8px;font-size:11px}.info-grid{grid-template-columns:1fr 1fr}}
margin: 0;
}
.toggle-group button:first-child { border-radius: 6px 0 0 6px; }
.toggle-group button:last-child { border-radius: 0 6px 6px 0; }
.toggle-group button.on { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
/* ====== Editor Container ====== */
.editor-section {
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.editor-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
background: var(--app-card);
border: 1px solid var(--app-border);
transition: box-shadow 0.3s, background 0.3s;
}
.editor-card:hover { box-shadow: 0 8px 36px rgba(0,0,0,0.1); }
/* ====== Info Cards ====== */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.info-card {
background: var(--app-card);
border: 1px solid var(--app-border);
border-radius: 10px;
padding: 18px 20px;
transition: box-shadow 0.3s;
}
.info-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.info-card .icon { font-size: 24px; margin-bottom: 8px; }
.info-card h4 { font-size: 14px; font-weight: 650; margin-bottom: 4px; }
.info-card p { font-size: 12px; color: var(--app-text); opacity: 0.65; margin: 0; }
/* ====== Footer ====== */
.footer {
text-align: center;
padding: 32px 24px;
font-size: 13px;
color: var(--app-text);
opacity: 0.5;
}
.footer a { color: var(--app-accent); text-decoration: none; }
/* ====== Responsive ====== */
@media (max-width: 768px) {
.header h1 { font-size: 1.5rem; }
.toolbar-strip { padding: 8px 12px; gap: 6px; }
.toolbar-strip button { padding: 5px 10px; font-size: 12px; }
.info-grid { grid-template-columns: 1fr 1fr; }
}
</style> </style>
</head> </head>
<body> <body>
<header class="header"> <header class="header">
<h1><span class="grad">Metona</span>Editor</h1> <h1><span class="grad">Metona</span>Editor v0.4.1</h1>
<div class="badges"> <div class="badges">
<span class="badge">v0.1.10</span> <span class="badge">TypeScript</span>
<span class="badge">零依赖</span> <span class="badge">运行时依赖</span>
<span class="badge">532 tests</span> <span class="badge">841 tests</span>
<span class="badge">MIT</span> <span class="badge">6 个插件</span>
<span class="badge">中文优先</span> <span class="badge">桌面端优先</span>
</div> </div>
</header> </header>
<div class="toolbar-strip" id="controlBar"> <div class="toolbar-strip" id="bar">
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:8px;" title="返回首页">🏠</a> <a href="index.html" style="text-decoration:none;font-size:18px;margin-right:4px" title="首页">🏠</a>
<label>主题</label> <label>主题</label>
<select id="themeSelect"> <select id="selTheme"><option value="auto">🌓 跟随系统</option><option value="light">☀️ 亮色</option><option value="dark">🌙 暗色</option><option value="warm">🔥 暖色</option></select>
<option value="auto">🌓 跟随系统</option>
<option value="light">☀️ 亮色</option>
<option value="dark">🌙 暗色</option>
<option value="warm">🔥 暖色</option>
</select>
<label>语言</label> <label>语言</label>
<select id="localeSelect"> <select id="selLocale"><option value="zh-CN">中文</option><option value="en-US">English</option><option value="ja">日本語</option><option value="ko">한국어</option><option value="fr">Français</option><option value="de">Deutsch</option></select>
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
<label>模式</label> <label>模式</label>
<div class="toggle-group" id="modeBtns"> <button data-cmd="mode" data-arg="edit">编辑</button>
<button data-mode="edit">编辑</button> <button data-cmd="mode" data-arg="split" class="on">分屏</button>
<button data-mode="split" class="on">分屏</button> <button data-cmd="mode" data-arg="preview">预览</button>
<button data-mode="preview">预览</button>
</div>
<div class="spacer"></div> <div class="spacer"></div>
<button data-cmd="zen" title="Zen 专注模式">🧘 Zen</button>
<label>行号</label> <button data-cmd="outline" title="大纲面板">📋 大纲</button>
<div class="toggle-group" id="lineNumBtns"> <button data-cmd="wrap" title="切换换行">↩ 换行</button>
<button class="on"></button> <button data-cmd="float" title="浮动工具栏" class="on">💬 浮动栏</button>
<button></button> <button data-cmd="toast" title="Toast 通知">💬 Toast</button>
</div> <button data-cmd="openFile" title="打开文件">📂 打开</button>
<button data-cmd="saveFile" title="保存文件">💾 保存</button>
<label>大纲</label>
<div class="toggle-group" id="outlineBtns">
<button></button>
<button class="on"></button>
</div>
<button onclick="demoToast()" title="演示 Toast 通知">💬 Toast</button>
<button onclick="demoOutline()" title="生成大纲">📋 大纲</button>
</div> </div>
<div class="editor-section"> <div class="editor-section">
<div class="editor-card"> <div class="editor-card"><div id="editor"></div></div>
<div id="editor"></div>
</div>
</div> </div>
<div class="info-grid"> <div class="info-grid">
<div class="info-card"> <div class="info-card"><div class="icon">🏗️</div><h4>TypeScript 全模块重构</h4><p>17 个 .ts 源文件,严格模式,完整类型导出</p></div>
<div class="icon">📝</div> <div class="info-card"><div class="icon">📝</div><h4>自研解析器 99% 行覆盖</h4><p>CommonMark + GFM:粗斜体、链接内格式、脚注、数学公式等</p></div>
<h4>丰富语法</h4> <div class="info-card"><div class="icon">🎨</div><h4>内置语法高亮</h4><p>零依赖 tokenizerjs / ts / python / bash 等 16 个语言标识</p></div>
<p>数学公式、脚注、定义列表、嵌套列表、表格对齐、任务列表、Emoji</p> <div class="info-card"><div class="icon">📂</div><h4>磁盘文件读写</h4><p>File System Access API 打开 / 保存真实 .md 文件</p></div>
</div> <div class="info-card"><div class="icon">🔢</div><h4>行号装订线</h4><p>当前行高亮,与编辑区滚动同步</p></div>
<div class="info-card"> <div class="info-card"><div class="icon">🧘</div><h4>Zen 专注模式</h4><p>工具栏自动隐藏,宽度可配置(zenMaxWidth,默认 960px</p></div>
<div class="icon">⌨️</div> <div class="info-card"><div class="icon">⌨️</div><h4>快捷键面板</h4><p>按 ? 查看全部快捷键,Ctrl+F/H 搜索替换</p></div>
<h4>快捷键</h4> <div class="info-card"><div class="icon">📊</div><h4>Mermaid 图表</h4><p>```mermaid 代码块,加载 Mermaid.js 即可渲染</p></div>
<p>Ctrl+B 粗体 · Ctrl+I 斜体 · Ctrl+S 保存 · Ctrl+F 搜索 · Ctrl+H 替换</p> <div class="info-card"><div class="icon">🔌</div><h4>6 个预设插件</h4><p>autoSave / exportTool / searchReplace / imagePaste / shortcutHelp / fileSystem</p></div>
</div> <div class="info-card"><div class="icon"></div><h4>增量性能</h4><p>字数/词数/行数差异统计,击键零全量扫描;高亮规则缓存;大纲 O(n)</p></div>
<div class="info-card">
<div class="icon">🎨</div>
<h4>4 套主题</h4>
<p>亮色 · 暗色 · 暖色 · 跟随系统,实例级隔离,外部主题跟随</p>
</div>
<div class="info-card">
<div class="icon">🔌</div>
<h4>4 个插件</h4>
<p>自动保存草稿 · 导出 MD/HTML · 搜索替换 · 图片粘贴</p>
</div>
<div class="info-card">
<div class="icon">🧩</div>
<h4>编辑增强</h4>
<p>行号装订线 · 智能 Enter · 括号自动闭合 · 拖放文件 · 大纲面板</p>
</div>
<div class="info-card">
<div class="icon">🛡️</div>
<h4>安全</h4>
<p>XSS 防护 · URL 协议过滤 · HTML 实体保护 · sanitize 钩子</p>
</div>
</div> </div>
<footer class="footer"> <footer class="footer">
MetonaEditor v0.1.10 · MetonaEditor v0.4.1 · TypeScript · <a href="index.html">首页</a> · <a href="docs.html">API 文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT
<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 License
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="../dist/metona-editor.js"></script> <script src="../dist/metona-editor.js"></script>
<script> <script>
(function () { (function(){
const demoMd = [ var demoMd=[
'# 🚀 MetonaEditor 演示', '# 🚀 MetonaEditor v0.4.1 全功能演示',
'', '',
'> 轻量、零依赖、精致美观的 **Markdown Editor** 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 单文件,开箱即用,中文优先。', '> 全模块 TypeScript 严格模式,17 个源文件,841 个测试全部通过。',
'', '',
'## ✨ 核心特性', '## ✨ v0.4.1 新特性',
'', '',
'- 零依赖 · 单文件打包 · UMD / ESM / CJS', '- **Zen 专注模式宽度可配置**`zenMaxWidth` 默认 960px,支持数字 / CSS 字符串 / `false` 不限宽',
'- 三模式视图 · 分屏拖拽 · 滚动同步', '- **修复 5 处问题**`wordWrap: false` 生效 · outline 启动即构建 · edit 模式初始行号 · exportTool 卸载清理 · 替换后统计刷新',
'- 行号装订线 · 当前行高亮', '- **Playwright 浏览器冒烟测试**22 项断言真实 Chromium 验证',
'- 智能 Enter · 括号自动闭合', '- **实例级 i18n**:状态栏 / 工具栏 / 右键菜单 / 大纲全部跟随实例语言',
'- 4 套主题 · 实例级隔离 · 外部跟随', '- `sideEffects: false` 优化打包 · `MeEditor.destroy()` 全面复位',
'- 插件系统 v2 · 依赖排序 · 异步加载',
'', '',
'## 📐 数学公式', '## 📐 文本样式',
'', '',
'行内公式:爱因斯坦质能方程 $E = mc^2$', '- **粗体** / __另一种粗体__',
'- ***粗斜体*** / ___另一种粗斜体___',
'- *斜体* / _另一种斜体_',
'- ~~删除线~~ / ==高亮标记==',
'- <u>下划线</u>Ctrl+U)· 链接内格式: [**粗体链接**](https://example.com)',
'- 化学:H~2~O / 数学:x^2^',
'- 代码:`const x = 42;`',
'- Emoji:smile: :rocket: :fire: :rainbow: :coffee:',
'', '',
'块级公式:', '## 📊 数学公式 & 图表',
'$$', '',
'\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}', '行内:$E = mc^2$',
'$$', '块级:$$\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$',
'',
'```mermaid',
'graph TD',
' A[TypeScript] --> B[Rollup]',
' B --> C[UMD/ESM/CJS]',
' C --> D[Browser]',
'```',
'', '',
'## 📋 表格与对齐', '## 📋 表格与对齐',
'', '',
'| 版本 | 日期 | 测试 |', '| 版本 | 日期 | 测试 | 主题 |',
'| :--- | :---: | ---: |', '| :--- | :---: | ---: | --- |',
'| v0.1.10 | 2026-07 | 532+ |', '| v0.4.1 | 2026-08 | 841 | Zen宽度可配置+5项修复 |',
'| v0.1.6 | 2026-07 | 521 |', '| v0.4.0 | 2026-08 | 826 | E2E冒烟+实例i18n |',
'| v0.1.5 | 2026-07 | 499 |', '| v0.3.1 | 2026-08 | 821 | 增量统计+高亮缓存 |',
'| v0.3.0 | 2026-08 | 801 | 安全修复+缓存指纹 |',
'| v0.2.5 | 2026-08 | 782 | 内置高亮+安全修复 |',
'| v0.2.2 | 2026-07 | 694 | 法语+表格格式化 |',
'| v0.2.1 | 2026-07 | 684 | 引用链接+右键菜单 |',
'| v0.2.0 | 2026-07 | 610 | TypeScript 重构 |',
'| v0.1.15 | 2026-07 | 583 | 解析器增强 |',
'| v0.1.14 | 2026-07 | 535 | 语法高亮+Mermaid |',
'', '',
'## ✅ 任务列表', '## ✅ 任务列表',
'', '',
'- [x] 解析器增强(11 项新语法)', '- [x] TypeScript 全模块重构',
'- [x] 主题系统(实例隔离 + 外部跟随)', '- [x] 解析器 99% 行覆盖率',
'- [x] 插件 v2 + i18n 增强', '- [x] parseTokens / renderTokens 分离',
'- [x] 编辑体验(行号 / 自动格式化 / 括号闭合)', '- [x] registerBlockHandler 扩展',
'- [ ] 更多语言翻译包', '- [x] 6 个预设插件完整测试',
'- [ ] VS Code 插件', '- [ ] VS Code 扩展',
'', '',
'## 🎯 嵌套列表', '## 🎯 嵌套列表',
'', '',
'1. 一级有序', '1. 一级有序',
' 1. 二级有序', ' 1. 二级有序(自动递增)',
' 2. 二级第二项', ' 2. 第二项',
'2. 回到一级', '2. 回到一级',
' - 混杂无序', ' - 混杂无序列表',
' - 第二项', ' - 第二项',
' - 三级嵌套', ' - [x] 三级嵌套 + 任务',
'',
'## 📖 定义列表',
'',
'MetonaEditor',
': 一款轻量、零依赖的中文优先 Markdown 编辑器库。',
': 支持 CommonMark + GFM 扩展语法。',
'',
'Markdown',
': 由 John Gruber 创建的轻量级标记语言。',
'',
'## 🔗 链接与图片',
'',
'访问 [MetonaEditor 仓库](https://git.metona.cn/MetonaTeam/MetonaEditor \'点击访问源码\')',
'',
'图片示例:![示例](https://placehold.co/400x120/3b82f6/white?text=MetonaEditor)',
'', '',
'## 💻 代码块', '## 💻 代码块',
'', '',
'```js', '```ts',
'import MeEditor from \'@metona-team/metona-editor\';', 'import MeEditor from "@metona-team/metona-editor";',
'', '',
'const editor = MeEditor.create(\'#editor\', {', 'const editor = MeEditor.create("#editor", {',
' value: \'# Hello World\',', ' mode: "split",',
' mode: \'split\',', ' theme: "dark",',
' lineNumbers: true,', ' lineNumbers: true,',
' autoBrackets: true,', ' autoBrackets: true,',
' plugins: [\'autoSave\', \'searchReplace\'],', ' plugins: ["autoSave", "fileSystem", "shortcutHelp"],',
' onChange: (value) => console.log(value),', ' onChange: (value, editor) => {',
' console.log("changed:", value.length, "chars");',
' },',
'});', '});',
'',
'// 链式 API',
'editor.exec("bold").exec("h1").focus();',
'```', '```',
'', '',
'## 🎨 文本样式', '```python',
'def fib(n):',
' """返回斐波那契数列第 n 项"""',
' a, b = 0, 1',
' for _ in range(n):',
' a, b = b, a + b',
' return a',
'', '',
'- **粗体文本** 与 __另一种粗体__', 'print(fib(10)) # 55',
'- *斜体文本* 与 _另一种斜体_', '```',
'- ~~删除线~~ 与 ==高亮标记==', '',
'- 化学式:H~2~O 与 数学:x^2^ + y^2^ = z^2^', '```bash',
'- 行内代码:`const x = 42;`', '# 安装(Gitea 私有源)',
'- Emoji:smile: :rocket: :fire: :tada: :rainbow: :coffee:', 'npm install @metona-team/metona-editor --registry=https://git.metona.cn/api/packages/MetonaTeam/npm/',
'npm run dev',
'```',
'', '',
'## 📌 脚注', '## 📌 脚注',
'', '',
'这是一个带脚注的句子[^1],这是另一个脚注[^note]。', '这是一个带脚注的句子[^1],另一个脚注[^note]。',
'', '',
'[^1]: 第一个脚注的详细说明内容。', '[^1]: 脚注可以包含 **格式化** 和 `代码`。',
'[^note]: 脚注可以包含 **格式化** 和 `代码`。', '[^note]: 第二个脚注的说明。',
'', '',
'---', '---',
'', '',
'> **提示:** 试试拖放图片到编辑器、按 `Ctrl+F` 搜索、`Ctrl+H` 替换', '> **提示:** 按 `?` 查看快捷键 · 拖放文件到编辑区 · Ctrl+F 查找 · Ctrl+H 替换',
'> 在列表行尾按 `Enter` 体验智能延续选中文字后输入 `(` 体验自动包裹', '> 在列表行尾 Enter 体验智能延续 · 选中文字后 `(` 体验自动包裹',
].join('\n'); '> 点击「📂 打开」加载磁盘 .md 文件 · 点击「💾 保存」写入磁盘',
].join('\n');
const editor = window.MeEditor.create('#editor', { var editor = MeEditor.create('#editor',{
value: demoMd, value: demoMd,
mode: 'split', mode: 'split', height: 620,
height: 620, lineNumbers: true, autoBrackets: true, wordCount: true,
lineNumbers: true, theme: 'auto', locale: 'zh-CN', outline: false,
autoBrackets: true, highlight: MeEditor.highlight,
wordCount: true, plugins: ['autoSave','exportTool','searchReplace','imagePaste','shortcutHelp','fileSystem'],
outline: false, onLinkClick: function(uri, text, ed){
theme: 'auto', ed.toast('链接: ' + uri, { type: 'info', duration: 2000 });
locale: 'zh-CN',
plugins: ['autoSave', 'exportTool', 'searchReplace', 'imagePaste'],
});
// ====== Control Bar Handlers ======
// Theme
document.getElementById('themeSelect').addEventListener('change', function () {
editor.setTheme(this.value);
});
// Locale
document.getElementById('localeSelect').addEventListener('change', function () {
editor.setLocale(this.value);
});
// Mode buttons
document.querySelectorAll('#modeBtns button').forEach(function (btn) {
btn.addEventListener('click', function () {
editor.setMode(this.dataset.mode);
document.querySelectorAll('#modeBtns button').forEach(function (b) { b.classList.remove('on'); });
this.classList.add('on');
});
});
// Line numbers toggle
var lineNumBtns = document.querySelectorAll('#lineNumBtns button');
lineNumBtns[0].addEventListener('click', function () {
editor.config.lineNumbers = true;
if (editor.gutter) editor.gutter.style.display = '';
editor._renderGutter();
lineNumBtns[0].classList.add('on');
lineNumBtns[1].classList.remove('on');
});
lineNumBtns[1].addEventListener('click', function () {
editor.config.lineNumbers = false;
if (editor.gutter) editor.gutter.style.display = 'none';
lineNumBtns[1].classList.add('on');
lineNumBtns[0].classList.remove('on');
});
// Outline toggle
var outlineBtns = document.querySelectorAll('#outlineBtns button');
outlineBtns[0].addEventListener('click', function () {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
});
outlineBtns[1].addEventListener('click', function () {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[1].classList.add('on');
outlineBtns[0].classList.remove('on');
});
// Toast demo
window.demoToast = function () {
var types = ['success', 'error', 'warning', 'info'];
var type = types[Math.floor(Math.random() * types.length)];
var messages = {
success: '✅ 内容已自动保存',
error: '❌ 操作失败,请重试',
warning: '⚠️ 有未保存的更改',
info: 'ℹ️ 这是来自插件系统的通知',
};
editor.toast(messages[type], { type: type, duration: 2500 });
};
// Outline demo
window.demoOutline = function () {
if (editor.config.outline) {
editor.config.outline = false;
var panel = editor.el.querySelector('.me-outline');
if (panel) panel.remove();
outlineBtns[0].classList.remove('on');
outlineBtns[1].classList.add('on');
} else {
editor.config.outline = true;
editor._buildOutline();
outlineBtns[0].classList.add('on');
outlineBtns[1].classList.remove('on');
} }
}; });
// Register custom shortcut // Control bar
editor.registerShortcut('Ctrl+Shift+T', function () { var bar = document.getElementById('bar');
editor.toast('快捷键 Ctrl+Shift+T 已触发!', { type: 'info' });
}, 'Toast test');
// Listen for theme change to update select document.getElementById('selTheme').addEventListener('change', function(){
editor.on('themeChange', function (data) { editor.setTheme(this.value);
document.getElementById('themeSelect').value = data.theme; });
}); document.getElementById('selLocale').addEventListener('change', function(){
editor.setLocale(this.value);
});
// Sync editor mode button state bar.addEventListener('click', function(e){
editor.on('modeChange', function (mode) { var btn = e.target.closest('button[data-cmd]');
document.querySelectorAll('#modeBtns button').forEach(function (b) { if (!btn) return;
b.classList.toggle('on', b.dataset.mode === mode); var cmd = btn.dataset.cmd;
}); if (cmd === 'mode') { editor.setMode(btn.dataset.arg); updateModeBtns(btn.dataset.arg); }
}); else if (cmd === 'zen') { editor.toggleZen(); btn.classList.toggle('on', editor.isZen()); }
else if (cmd === 'outline') { toggleOutline(btn); }
else if (cmd === 'wrap') { editor.toggleWordWrap(); btn.classList.toggle('on', !editor.isWordWrap()); }
else if (cmd === 'float') {
editor.toggleFloatingToolbar();
btn.classList.toggle('on', editor.isFloatingToolbar());
}
else if (cmd === 'toast') {
var types = ['success','error','warning','info'];
var msgs = { success: '✅ 操作成功', error: '❌ 操作失败', warning: '⚠️ 注意', info: '️ 提示信息' };
editor.toast(msgs[types[Math.floor(Math.random()*4)]], { type: types[Math.floor(Math.random()*4)], duration: 2000 });
}
else if (cmd === 'openFile') { editor.openFile?.(); }
else if (cmd === 'saveFile') { editor.saveFile?.(); }
});
function updateModeBtns(mode){
bar.querySelectorAll('[data-cmd=mode]').forEach(function(b){ b.classList.toggle('on', b.dataset.arg === mode); });
}
function toggleOutline(btn){
editor.config.outline = !editor.config.outline;
if (editor.config.outline) { editor._buildOutline(); btn.classList.add('on'); }
else { var p = editor.el.querySelector('.me-outline'); if (p) p.remove(); btn.classList.remove('on'); }
}
// Custom shortcut
editor.registerShortcut('Ctrl+Shift+T', function(){
editor.toast('自定义快捷键已触发!', { type: 'info' });
}, 'Toast 通知演示');
// Events
editor.on('themeChange', function(d){ document.getElementById('selTheme').value = d.theme; });
editor.on('modeChange', function(m){ updateModeBtns(m); });
editor.on('fileOpened', function(d){ editor.toast('已打开: ' + d.name, { type: 'success' }); });
editor.on('fileSaved', function(){ editor.toast('文件已保存', { type: 'success' }); });
// Mermaid
function renderMermaid(){
if (typeof mermaid === 'undefined') return;
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }); } catch(e) {}
}
mermaid.initialize({ startOnLoad: false, theme: 'default' });
editor.on('afterRender', renderMermaid);
setTimeout(renderMermaid, 300);
})(); })();
</script> </script>
</body> </body>
+318 -449
View File
@@ -4,179 +4,68 @@
<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 · 文档</title> <title>MetonaEditor v0.4.1 · 文档</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } *{box-sizing:border-box;margin:0;padding:0}
:root { :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%)}
--bg: #0f1117; html{scroll-behavior:smooth}
--bg-soft: #161922; body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}
--card: #1c2029; .container{max-width:1000px;margin:0 auto;padding:0 24px}
--card-hover: #232834; nav{position:sticky;top:0;z-index:100;background:rgba(15,17,23,.85);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
--text: #e6e8eb; .nav-inner{max-width:1000px;margin:0 auto;padding:12px 24px;display:flex;align-items:center;justify-content:space-between}
--muted: #9ca3af; .nav-brand{display:inline-flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:15px}
--accent: #3b82f6; .nav-brand .logo-mark{width:26px;height:26px;border-radius:6px;background:var(--gradient);display:flex;align-items:center;justify-content:center;flex-shrink:0}
--accent-2: #8b5cf6; .logo-mark svg{display:block}
--accent-3: #ec4899; .nav-links{display:flex;gap:22px;align-items:center}
--accent-soft: rgba(59, 130, 246, 0.12); .nav-links a{color:var(--muted);text-decoration:none;font-size:13.5px;transition:color .15s}
--border: rgba(255, 255, 255, 0.08); .nav-links a:hover,.nav-links a.active{color:var(--text)}
--gradient: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%); .nav-links a.active{color:var(--accent);font-weight:600}
--shadow: 0 20px 50px -20px rgba(0,0,0,0.5);
}
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
min-height: 100vh;
}
.container { max-width: 1000px; margin: 0 auto; padding: 0 24px; }
/* Nav */ header.docs-header{text-align:center;padding:64px 24px 40px;background:radial-gradient(ellipse 60% 50% at 50% 0%,var(--accent-soft),transparent 70%),var(--bg)}
nav { header h1{font-size:clamp(28px,4vw,44px);font-weight:800;letter-spacing:-.02em;margin-bottom:10px}
position: sticky; top: 0; z-index: 100; header h1 .grad{background:var(--gradient);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
background: rgba(15, 17, 23, 0.85); header .sub{color:var(--muted);font-size:15px}
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1000px; margin: 0 auto; padding: 12px 24px;
display: flex; align-items: center; justify-content: space-between;
}
.nav-brand {
display: inline-flex; align-items: center; gap: 10px;
text-decoration: none; color: var(--text);
font-weight: 700; font-size: 15px;
}
.nav-brand .logo-mark {
width: 26px; height: 26px; border-radius: 6px;
background: var(--gradient);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.nav-links { display: flex; gap: 22px; align-items: center; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 13.5px; transition: color .15s; }
.nav-links a:hover, .nav-links a.active { color: var(--text); }
.nav-links a.active { color: var(--accent); font-weight: 600; }
/* Header */ .docs-layout{display:flex;gap:32px;padding:40px 0 80px}
header.docs-header { .sidebar{position:sticky;top:72px;width:200px;flex-shrink:0;max-height:calc(100vh - 100px);overflow-y:auto;padding-right:16px;border-right:1px solid var(--border)}
text-align: center; padding: 64px 24px 40px; .sidebar a{display:block;padding:6px 12px;border-radius:6px;color:var(--muted);text-decoration:none;font-size:13.5px;transition:background .15s,color .15s;margin-bottom:2px}
background: radial-gradient(ellipse 60% 50% at 50% 0%, var(--accent-soft), transparent 70%), var(--bg); .sidebar a:hover,.sidebar a.active{background:var(--card);color:var(--text)}
} .sidebar .toc-group{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;padding:16px 12px 6px;font-weight:600}
header h1 { font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 10px; } .content{flex:1;min-width:0}
header h1 .grad { background: var(--gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } .content section{margin-bottom:48px}
header .sub { color: var(--muted); font-size: 15px; } .content h2{font-size:24px;font-weight:700;letter-spacing:-.02em;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.content h3{font-size:18px;font-weight:600;margin:28px 0 14px}
.content h4{font-size:15px;font-weight:600;margin:20px 0 10px;color:var(--accent)}
/* Sidebar + Content Layout */ table{width:100%;border-collapse:collapse;margin:16px 0;font-size:13.5px}
.docs-layout { display: flex; gap: 32px; padding: 40px 0 80px; } th,td{padding:10px 14px;text-align:left;border:1px solid var(--border)}
.sidebar { th{background:var(--card);font-weight:600;color:var(--text)}
position: sticky; top: 72px; td{color:var(--muted)}
width: 200px; flex-shrink: 0; tr:nth-child(even) td{background:var(--bg-soft)}
max-height: calc(100vh - 100px); overflow-y: auto;
padding-right: 16px;
border-right: 1px solid var(--border);
}
.sidebar a {
display: block; padding: 6px 12px; border-radius: 6px;
color: var(--muted); text-decoration: none; font-size: 13.5px;
transition: background .15s, color .15s;
margin-bottom: 2px;
}
.sidebar a:hover, .sidebar a.active { background: var(--card); color: var(--text); }
.sidebar .toc-group { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; padding: 16px 12px 6px; font-weight: 600; }
.content { flex: 1; min-width: 0; }
.content section { margin-bottom: 48px; }
.content h2 {
font-size: 24px; font-weight: 700; letter-spacing: -0.02em;
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.content h3 { font-size: 18px; font-weight: 600; margin: 28px 0 14px; }
.content h4 { font-size: 15px; font-weight: 600; margin: 20px 0 10px; color: var(--accent); }
/* Tables */ code{font-family:"SF Mono","Cascadia Code",Consolas,monospace;font-size:13px;padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;color:var(--accent)}
table { pre{background:var(--bg-soft);border:1px solid var(--border);border-radius:10px;padding:18px 20px;overflow-x:auto;font-family:"SF Mono","Cascadia Code",Consolas,monospace;font-size:13px;line-height:1.8;color:#e6e8eb;margin:14px 0}
width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px; .c-key{color:#c792ea}.c-str{color:#c3e88d}.c-fn{color:#82aaff}.c-com{color:#5c6370;font-style:italic}.c-kw{color:#89ddff}.c-num{color:#f78c6c}.c-punc{color:#89ddff;opacity:.7}
} .badge{display:inline-block;padding:3px 10px;background:var(--accent-soft);color:var(--accent);border-radius:999px;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:4px}
th, td {
padding: 10px 14px; text-align: left;
border: 1px solid var(--border);
}
th { background: var(--card); font-weight: 600; color: var(--text); }
td { color: var(--muted); }
tr:nth-child(even) td { background: var(--bg-soft); }
/* Code */ footer{border-top:1px solid var(--border);padding:36px 24px;text-align:center;color:var(--muted);font-size:13px;background:var(--bg-soft)}
code { footer a{color:var(--accent);text-decoration:none}
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 13px; padding: 2px 6px;
background: var(--card); border: 1px solid var(--border);
border-radius: 4px; color: var(--accent);
}
pre {
background: var(--bg-soft); border: 1px solid var(--border);
border-radius: 10px; padding: 18px 20px; overflow-x: auto;
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 13px; line-height: 1.8;
color: #e6e8eb; margin: 14px 0;
}
pre .c-key { color: #c792ea; }
pre .c-str { color: #c3e88d; }
pre .c-fn { color: #82aaff; }
pre .c-com { color: #5c6370; font-style: italic; }
pre .c-kw { color: #89ddff; }
pre .c-num { color: #f78c6c; }
pre .c-punc { color: #89ddff; opacity: 0.7; }
/* Badges */ @media(max-width:768px){.docs-layout{flex-direction:column}.sidebar{position:static;width:100%;max-height:none;border-right:0;border-bottom:1px solid var(--border);padding-bottom:12px;display:flex;flex-wrap:wrap;gap:4px}.sidebar .toc-group{width:100%}}
.badge {
display: inline-block; padding: 3px 10px;
background: var(--accent-soft); color: var(--accent);
border-radius: 999px; font-size: 12px; font-weight: 600;
margin-right: 6px; margin-bottom: 4px;
}
/* Footer */
footer {
border-top: 1px solid var(--border); padding: 36px 24px;
text-align: center; color: var(--muted); font-size: 13px;
background: var(--bg-soft);
}
footer a { color: var(--accent); text-decoration: none; }
@media (max-width: 768px) {
.docs-layout { flex-direction: column; }
.sidebar {
position: static; width: 100%; max-height: none;
border-right: 0; border-bottom: 1px solid var(--border);
padding-bottom: 12px;
display: flex; flex-wrap: wrap; gap: 4px;
}
.sidebar .toc-group { width: 100%; }
}
</style> </style>
</head> </head>
<body> <body>
<nav> <nav>
<div class="nav-inner"> <div class="nav-inner">
<a class="nav-brand" href="index.html"> <a class="nav-brand" href="index.html"><span class="logo-mark"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V6l7 8 7-8v13"/></svg></span>MetonaEditor</a>
<span class="logo-mark" title="MetonaEditor"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V6l7 8 7-8v13"/></svg></span> <div class="nav-links"><a href="index.html">首页</a><a href="demo.html">演示</a><a href="docs.html" class="active">文档</a></div>
MetonaEditor
</a>
<div class="nav-links">
<a href="index.html">首页</a>
<a href="demo.html">演示</a>
<a href="docs.html" class="active">文档</a>
</div>
</div> </div>
</nav> </nav>
<header class="docs-header"> <header class="docs-header">
<h1><span class="grad">文档</span></h1> <h1><span class="grad">API 文档</span></h1>
<p class="sub">MetonaEditor v0.1.10 完整 API、配置与使用指南</p> <p class="sub">MetonaEditor v0.4.1 · TypeScript · 完整配置、API 与使用指南</p>
</header> </header>
<div class="container"> <div class="container">
@@ -189,20 +78,25 @@
<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">模式与全屏</a> <a href="#api-mode">模式 / 全屏 / Zen</a>
<a href="#api-stats">统计与状态</a> <a href="#api-stats">统计与状态</a>
<a href="#api-readonly">启用/禁用/只读</a> <a href="#api-readonly">启用 / 禁用 / 只读</a>
<a href="#api-events">事件系统</a> <a href="#api-events">事件系统</a>
<a href="#api-plugin">插件管理</a> <a href="#api-plugin">插件管理</a>
<a href="#api-lifecycle">生命周期</a> <a href="#api-shortcuts">快捷键管理</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> <div class="toc-group">语法高亮</div>
<a href="#syntax">解析器语法</a> <a href="#highlight">内置高亮器</a>
<div class="toc-group">解析器</div>
<a href="#parser-syntax">语法参考</a>
<a href="#parser-api">解析器 API</a>
<div class="toc-group">插件</div> <div class="toc-group">插件</div>
<a href="#plugins">插件约定</a> <a href="#plugins-convention">插件约定</a>
<a href="#preset-plugins">预设插件</a> <a href="#preset-plugins">预设插件</a>
<div class="toc-group">主题</div> <div class="toc-group">主题</div>
<a href="#themes">主题系统</a> <a href="#themes">主题系统</a>
@@ -212,94 +106,85 @@
<main class="content"> <main class="content">
<!-- ======== 快速入门 ======== --> <!-- 快速入门 -->
<section id="quickstart"> <section id="quickstart">
<h2>安装与引入</h2> <h2>安装与引入</h2>
<pre><span class="c-com"># npm 安装(Gitea 私有源)</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-punc">--</span>registry<span class="c-punc">=</span>https<span class="c-punc">:</span><span class="c-punc">//</span>git<span class="c-punc">.</span>metona<span class="c-punc">.</span>cn<span class="c-punc">/</span>api<span class="c-punc">/</span>packages<span class="c-punc">/</span>MetonaTeam<span class="c-punc">/</span>npm<span class="c-punc">/</span>
<pre><span class="c-com"># npm 安装</span> <span class="c-com">// 或项目 .npmrc 配置 scope 后直接安装</span>
<span class="c-kw">npm</span> install @metona-team/metona-editor <span class="c-com">// @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/</span>
<span class="c-com"># ES Module</span> <span class="c-com">// TypeScript / ES Module</span>
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span> <span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
<span class="c-fn">value</span><span class="c-punc">:</span> <span class="c-str">'# Hello World'</span><span class="c-punc">,</span> <span class="c-fn">value</span><span class="c-punc">:</span> <span class="c-str">'# Hello World'</span><span class="c-punc">,</span>
<span class="c-fn">mode</span><span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> <span class="c-fn">mode</span><span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span>
<span class="c-punc">});</span> <span class="c-punc">});</span>
<span class="c-com"># 浏览器 UMD</span> <span class="c-com">// 浏览器 UMD</span>
<span class="c-com">&lt;script src="dist/metona-editor.js"&gt;&lt;/script&gt;</span> <span class="c-com">&lt;script src="dist/metona-editor.js"&gt;&lt;/script&gt;</span>
<span class="c-com">&lt;script&gt;</span> <span class="c-com">&lt;script&gt;</span>
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">);</span> <span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">);</span>
<span class="c-com">&lt;/script&gt;</span></pre> <span class="c-com">&lt;/script&gt;</span></pre>
</section> </section>
<!-- ======== 配置项 ======== --> <!-- 配置 -->
<section id="config"> <section id="config">
<h2>全部配置项</h2> <h2>全部配置项</h2>
<p style="color:var(--muted);margin-bottom:14px">所有配置均为可选,以下是默认值:</p> <p style="color:var(--muted);margin-bottom:14px">所有配置均为可选</p>
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> <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> <span class="c-com">// 初始 Markdown 文本</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>
placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 占位符</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> <span class="c-com">// 'edit' | 'split' | 'preview'</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>
height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 数字为 px,字符串原样使用</span> toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> wordCount<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span>
toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> <span class="c-com">// 工具栏配置,false 隐藏</span> lineNumbers<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> outline<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
wordCount<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>
autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 自动聚焦</span> autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span>
spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 拼写检查</span> readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span>
readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 只读模式</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>
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> <span class="c-com">// 历史栈上限</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>
historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 历史防抖延迟(ms</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>
syncScroll<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">// Zen 内容区最大宽度(数字为 px / 字符串为 CSS 值 / false 不限宽)</span>
tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span> <span class="c-com">// 制表符空格数(0 表示 \t</span> zenMaxWidth<span class="c-punc">:</span> <span class="c-num">960</span><span class="c-punc">,</span>
maxLength<span class="c-punc">:</span> <span class="c-num">0</span><span class="c-punc">,</span> <span class="c-com">// 最大字符数(0=不限)</span>
floatingToolbar<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 选中文本浮动格式栏</span>
<span class="c-com">// 主题与国际化</span> <span class="c-com">// 主题 / 语言</span>
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> <span class="c-com">// 'light' | 'dark' | 'auto' | 'warm' | 自定义</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>
locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span> <span class="c-com">// 'zh-CN' | 'en-US' | 自定义</span>
<span class="c-com">// 自定义渲染</span> <span class="c-com">// 钩子</span>
render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (md, env) =&gt; html</span> render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (code, lang) =&gt; html</span> sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (html) =&gt; safeHtml</span>
<span class="c-com">// 外观</span> <span class="c-com">// 外观</span>
className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 容器额外 class</span> className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> style<span class="c-punc">:</span> <span class="c-punc">{},</span>
style<span class="c-punc">:</span> <span class="c-punc">{},</span> <span class="c-com">// 内联样式</span>
<span class="c-com">// 插件回调</span> <span class="c-com">// 插件 / 回调</span>
plugins<span class="c-punc">:</span> <span class="c-punc">[],</span> <span class="c-com">// 实例级插件数组</span> plugins<span class="c-punc">:</span> <span class="c-punc">[],</span>
onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span> onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span> onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span> onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span> onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) =&gt; void</span> onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> onLinkClick<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span>
onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (mode, editor) =&gt; void</span>
onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (fullscreen, editor) =&gt; void</span>
onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) =&gt; void</span>
<span class="c-punc">});</span></pre> <span class="c-punc">});</span></pre>
</section> </section>
<!-- ======== 工具栏 ======== --> <!-- 工具栏 -->
<section id="toolbar"> <section id="toolbar">
<h2>工具栏配置</h2> <h2>工具栏</h2>
<pre><span class="c-com">// 默认完整工具栏</span>
<pre><span class="c-com">// 默认工具栏(完整按钮)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">true</span> <span class="c-punc">});</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">true</span> <span class="c-punc">});</span>
<span class="c-com">// 隐藏工具栏</span> <span class="c-com">// 隐藏</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">false</span> <span class="c-punc">});</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">false</span> <span class="c-punc">});</span>
<span class="c-com">// 自定义工具栏 — '|' 为分隔符</span> <span class="c-com">// 自定义 | 为分隔符</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">,</span> <span class="c-str">'redo'</span><span class="c-punc">]</span> <span class="c-punc">});</span></pre>
toolbar<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'h1'</span><span class="c-punc">,</span> <span class="c-str">'h2'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">,</span> <span class="c-str">'redo'</span><span class="c-punc">],</span>
<span class="c-punc">});</span></pre>
<p>可用动作:</p> <p>可用动作:</p>
<p> <p>
<span class="badge">bold</span><span class="badge">italic</span><span class="badge">strikethrough</span><span class="badge">underline</span><span class="badge">code</span> <span class="badge">bold</span><span class="badge">italic</span><span class="badge">strikethrough</span><span class="badge">underline</span><span class="badge">code</span>
@@ -311,306 +196,296 @@ MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class
</p> </p>
</section> </section>
<!-- ======== 实例 API ======== --> <!-- 实例 API -->
<section id="api-content"> <section id="api-content"><h2>实例 API</h2>
<h2>实例 API</h2>
<h3>内容操作</h3> <h3>内容操作</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getValue</span><span class="c-punc">();</span> <span class="c-com">// 获取 Markdown 文本</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">getValue</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
editor<span class="c-punc">.</span><span class="c-fn">setValue</span><span class="c-punc">(</span>md<span class="c-punc">,</span> <span class="c-punc">{</span> silent <span class="c-punc">});</span> <span class="c-com">// 设置内容,silent 不触发 change</span> editor<span class="c-punc">.</span><span class="c-fn">setValue</span><span class="c-punc">(</span>md<span class="c-punc">,</span> <span class="c-punc">{</span> silent <span class="c-punc">});</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// 获取渲染后的 HTML</span> editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// => string</span>
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制刷新预览(内容未变时)</span> editor<span class="c-punc">.</span><span class="c-fn">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">// 光标处插入 / 替换选区</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">// 选区包裹</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">focus</span><span class="c-punc">();</span> <span class="c-com">// 聚焦</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">blur</span><span class="c-punc">();</span> <span class="c-com">// 失焦</span></pre> editor<span class="c-punc">.</span><span class="c-fn">replaceAllRegex</span><span class="c-punc">(</span>pattern<span class="c-punc">,</span> replace<span class="c-punc">)</span> <span class="c-com">// => 替换数</span>
editor<span class="c-punc">.</span><span class="c-fn">lineCount</span><span class="c-punc">();</span> <span class="c-fn">getLine</span><span class="c-punc">(</span>n<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">copyAsMarkdown</span><span class="c-punc">();</span> <span class="c-fn">copyAsHTML</span><span class="c-punc">();</span> <span class="c-com">// 剪贴板</span>
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-fn">blur</span><span class="c-punc">();</span></pre>
</section> </section>
<section id="api-exec"> <section id="api-cursor"><h3>光标与选区</h3>
<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>
<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-punc">...</span>args<span class="c-punc">);</span> <span class="c-com">// 执行命令,返回 this 链式调用</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">exec</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">).</span><span class="c-fn">exec</span><span class="c-punc">(</span><span class="c-str">'h1'</span><span class="c-punc">);</span> <span class="c-com">// 链式调用</span></pre> editor<span class="c-punc">.</span><span class="c-fn">scrollToLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectLine</span><span class="c-punc">(</span>line<span class="c-punc">);</span> <span class="c-com">// => this</span>
editor<span class="c-punc">.</span><span class="c-fn">selectAll</span><span class="c-punc">();</span> <span class="c-com">// => this</span></pre>
<p>伴随事件 <code>selectionChange</code><code>{ start, end, text }</code>)与 <code>cursorMove</code><code>{ line, column }</code>)。</p>
</section>
<section id="api-exec"><h3>命令执行</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">)</span> <span class="c-com">// => this, 支持链式</span></pre>
<table> <table>
<tr><th>action</th><th>效果</th><th>快捷键</th></tr> <tr><th>action</th><th>效果</th><th>快捷键</th></tr>
<tr><td><code>bold</code></td><td>加粗 **text**</td><td>Ctrl+B</td></tr> <tr><td><code>bold</code></td><td>**text**</td><td>Ctrl+B</td></tr>
<tr><td><code>italic</code></td><td>斜体 *text*</td><td>Ctrl+I</td></tr> <tr><td><code>italic</code></td><td>*text*</td><td>Ctrl+I</td></tr>
<tr><td><code>strikethrough</code></td><td>删除线 ~~text~~</td><td></td></tr> <tr><td><code>strikethrough</code></td><td>~~text~~</td><td></td></tr>
<tr><td><code>underline</code></td><td>下划线 &lt;u&gt;text&lt;/u&gt;</td><td>Ctrl+U</td></tr> <tr><td><code>underline</code></td><td>&lt;u&gt;text&lt;/u&gt;</td><td>Ctrl+U</td></tr>
<tr><td><code>code</code></td><td>行内代码 `text`</td><td>Ctrl+E</td></tr> <tr><td><code>code</code></td><td>`text`</td><td>Ctrl+E</td></tr>
<tr><td><code>h1</code>/<code>h2</code>/<code>h3</code></td><td>标题 # / ## / ###</td><td>Ctrl+1/2/3</td></tr> <tr><td><code>h1</code>/<code>h2</code>/<code>h3</code></td><td># / ## / ###</td><td>Ctrl+1/2/3</td></tr>
<tr><td><code>quote</code></td><td>引用 ></td><td>Ctrl+Q</td></tr> <tr><td><code>quote</code></td><td>&gt; text</td><td>Ctrl+Q</td></tr>
<tr><td><code>ul</code>/<code>ol</code></td><td>无序/有序列表</td><td></td></tr> <tr><td><code>ul</code>/<code>ol</code></td><td>- / 1. 列表</td><td></td></tr>
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab/Shift+Tab</td></tr> <tr><td><code>link</code></td><td>[text](url)</td><td>Ctrl+K</td></tr>
<tr><td><code>hr</code></td><td>水平线 ---</td><td></td></tr> <tr><td><code>image</code></td><td>![alt](url)</td><td></td></tr>
<tr><td><code>link</code></td><td>插入链接 [text](url)</td><td>Ctrl+K</td></tr>
<tr><td><code>image</code></td><td>插入图片 ![alt](url)</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>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y</td></tr> <tr><td><code>formatTable</code> <span class="badge">v0.2.2</span></td><td>光标所在表格按列宽对齐</td><td></td></tr>
<tr><td><code>hr</code></td><td>---水平线</td><td></td></tr>
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab / Shift+Tab</td></tr>
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Y</td></tr>
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr> <tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td></td></tr>
<tr><td><code>fullscreen</code></td><td>切换全屏</td><td></td></tr> <tr><td><code>fullscreen</code></td><td>全屏</td><td></td></tr>
<tr><td><code>zen</code></td><td>Zen 模式</td><td></td></tr>
<tr><td><code>wordwrap</code></td><td>自动换行</td><td></td></tr>
</table> </table>
</section> </section>
<section id="api-history"> <section id="api-history"><h3>历史栈</h3>
<h3>历史栈</h3> <pre>editor<span class="c-punc">.</span><span class="c-fn">undo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span>
<pre>editor<span class="c-punc">.</span><span class="c-fn">undo</span><span class="c-punc">();</span> <span class="c-com">// 撤销</span> editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span> <span class="c-com">// 重做</span>
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> <span class="c-com">// 是否可撤销</span>
editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span> <span class="c-com">// 是否可重做</span></pre>
</section> </section>
<section id="api-mode"> <section id="api-mode"><h3>模式 / 全屏 / Zen / 浮动工具栏</h3>
<h3>模式与全屏</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> <span class="c-com">// 设置模式:edit | split | preview</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">getMode</span><span class="c-punc">();</span> <span class="c-com">// 获取当前模式</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">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 切换全屏</span> editor<span class="c-punc">.</span><span class="c-fn">setZenMaxWidth</span><span class="c-punc">(</span><span class="c-num">1200</span><span class="c-punc">);</span> <span class="c-com">// 运行时调整专注模式宽度(数字 / CSS 字符串 / false</span>
editor<span class="c-punc">.</span><span class="c-fn">exitFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 退出全屏</span> editor<span class="c-punc">.</span><span class="c-fn">getZenMaxWidth</span><span class="c-punc">();</span> <span class="c-com">// => number | string | false</span>
editor<span class="c-punc">.</span><span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 是否全屏</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"> <section id="api-stats"><h3>统计与状态</h3>
<h3>统计与状态</h3>
<pre>editor<span class="c-punc">.</span><span class="c-fn">getStats</span><span class="c-punc">();</span> <pre>editor<span class="c-punc">.</span><span class="c-fn">getStats</span><span class="c-punc">();</span>
<span class="c-com">// =&gt; { characters, words, chineseChars, englishWords, lines, readingTime }</span> <span class="c-com">// => { characters, words, chineseChars, englishWords, lines, readingTime }</span>
editor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
<span class="c-com">// =&gt; { id, mode, theme, locale, fullscreen, readOnly, disabled, destroyed, plugins }</span></pre> <span class="c-com">// => { id, mode, theme, locale, fullscreen, readOnly, disabled, destroyed, plugins }</span></pre>
</section> </section>
<section id="api-readonly"> <section id="api-readonly"><h3>启用 / 禁用 / 只读</h3>
<h3>启用/禁用/只读</h3> <pre>editor<span class="c-punc">.</span><span class="c-fn">enable</span><span class="c-punc">();</span> <span class="c-fn">disable</span><span class="c-punc">();</span> <span class="c-fn">isDisabled</span><span class="c-punc">();</span>
<pre>editor<span class="c-punc">.</span><span class="c-fn">enable</span><span class="c-punc">();</span> <span class="c-com">// 启用</span> editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-fn">isReadOnly</span><span class="c-punc">();</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">disable</span><span class="c-punc">();</span> <span class="c-com">// 禁用</span>
editor<span class="c-punc">.</span><span class="c-fn">isDisabled</span><span class="c-punc">();</span> <span class="c-com">// 是否禁用</span>
editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-com">// 只读模式</span>
editor<span class="c-punc">.</span><span class="c-fn">isReadOnly</span><span class="c-punc">();</span> <span class="c-com">// 是否只读</span></pre>
</section> </section>
<section id="api-events"> <section id="api-events"><h3>事件系统</h3>
<h3>事件系统</h3>
<table> <table>
<tr><th>事件</th><th>触发时机</th><th>回调参数</th></tr> <tr><th>事件</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>input</code></td><td>textarea 原生 input</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>focus</code></td><td>聚焦</td><td><code>(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>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>(value, 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>
<tr><td><code>fullscreen</code></td><td>全屏切换</td><td><code>(fullscreen, editor)</code></td></tr> <tr><td><code>fullscreen</code></td><td>全屏切换</td><td><code>(fullscreen, editor)</code></td></tr>
<tr><td><code>themeChange</code></td><td>主题变更</td><td><code>({ theme, resolved, config })</code></td></tr>
<tr><td><code>localeChange</code></td><td>语言变更</td><td><code>({ locale, direction })</code></td></tr>
<tr><td><code>zenChange</code></td><td>Zen 切换</td><td><code>(zen, editor)</code></td></tr>
<tr><td><code>selectionChange</code></td><td>选区变化</td><td><code>({ start, end, text })</code></td></tr>
<tr><td><code>cursorMove</code></td><td>光标移动</td><td><code>({ line, column })</code></td></tr>
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr> <tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
<tr><td><code>autosave</code></td><td>autoSave 保存</td><td><code>({ key, value })</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></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>afterRender</code></td><td>渲染后</td><td><code>(editor)</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>
<tr><th>钩子</th><th>触发时机</th></tr> <tr><th>钩子</th><th>触发时机</th></tr>
<tr><td><code>beforeCreate</code></td><td>构造函数初始化前</td></tr> <tr><td><code>beforeCreate</code> / <code>afterCreate</code></td><td>实例构造前后</td></tr>
<tr><td><code>afterCreate</code></td><td>构造函数初始化完成</td></tr> <tr><td><code>beforeRender</code> / <code>afterRender</code></td><td>全局渲染前后</td></tr>
<tr><td><code>beforeRender</code></td><td>每次渲染前(全局)</td></tr> <tr><td><code>beforeDestroy</code> / <code>afterDestroy</code></td><td>销毁前后</td></tr>
<tr><td><code>afterRender</code></td><td>每次渲染后(全局)</td></tr>
<tr><td><code>beforeDestroy</code></td><td>destroy 前</td></tr>
<tr><td><code>afterDestroy</code></td><td>destroy 后</td></tr>
</table> </table>
</section> </section>
<section id="api-plugin"> <section id="api-plugin"><h3>插件管理</h3>
<h3>插件管理</h3> <pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装</span>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装插件</span> editor<span class="c-punc">.</span><span class="c-fn">unuse</span><span class="c-punc">(</span>name<span class="c-punc">);</span> <span class="c-com">// 卸载</span>
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 获取已安装插件列表</span> editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 已安装列表</span>
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加工具栏按钮</span></pre> editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加按钮</span></pre>
</section> </section>
<section id="api-lifecycle"> <section id="api-shortcuts"><h3>快捷键管理</h3>
<h3>生命周期</h3> <pre>editor<span class="c-punc">.</span><span class="c-fn">registerShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">,</span> handler<span class="c-punc">,</span> <span class="c-str">'desc'</span><span class="c-punc">);</span>
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-com">// 销毁实例</span> editor<span class="c-punc">.</span><span class="c-fn">unregisterShortcut</span><span class="c-punc">(</span><span class="c-str">'Ctrl+Shift+T'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">isDestroyed</span><span class="c-punc">();</span> <span class="c-com">// 是否已销毁</span></pre> editor<span class="c-punc">.</span><span class="c-fn">getShortcuts</span><span class="c-punc">();</span></pre>
</section> </section>
<!-- ======== 静态 API ======== --> <section id="api-destroy"><h3>生命周期 / Toast</h3>
<section id="static-api"> <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>
<h2>静态 API</h2> editor<span class="c-punc">.</span><span class="c-fn">toast</span><span class="c-punc">(</span>msg<span class="c-punc">,</span> <span class="c-punc">{</span> type<span class="c-punc">,</span> duration<span class="c-punc">,</span> animation <span class="c-punc">});</span></pre>
<p>toast 动画支持 <code>fade</code> / <code>slide</code> / <code>scale</code> / <code>bounce</code> / <code>flip</code> / <code>rotate</code> / <code>zoom</code> 七种,<code>duration: 0</code> 不自动消失。</p>
</section>
<!-- 静态 API -->
<section id="static-api"><h2>静态 API</h2>
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 工厂函数</span> <span class="c-com">// 工厂函数</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> options<span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> options<span class="c-punc">);</span>
<span class="c-com">// 全局默认插件</span> <span class="c-com">// 全局默认插件</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span> delay<span class="c-punc">:</span> <span class="c-num">2000</span> <span class="c-punc">});</span> MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">,</span> <span class="c-punc">{</span> delay<span class="c-punc">:</span> <span class="c-num">2000</span> <span class="c-punc">});</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>customPlugin<span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>customPlugin<span class="c-punc">);</span>
<span class="c-com">// 全局事件钩子</span> <span class="c-com">// 全局钩子</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> <span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">=&gt;</span> <span class="c-punc">{});</span> MeEditor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> <span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> handler<span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> handler<span class="c-punc">);</span>
<span class="c-com">// 主题与语言</span> <span class="c-com">// 全局主题 / 语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span> 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>
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span> <span class="c-com">// =&gt; { version, theme, locale, globalPlugins, presetPlugins }</span> <span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 销毁全局资源</span> <span class="c-com">// 内置语法高亮</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</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>
<span class="c-com">// 内置解析器</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-fn">parseMarkdown</span><span class="c-punc">(</span><span class="c-str">'# Hello'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '&lt;h1 id="hello"&gt;Hello&lt;/h1&gt;'</span>
<span class="c-fn">safeUrl</span><span class="c-punc">(</span><span class="c-str">'javascript:alert(1)'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; ''</span>
<span class="c-fn">slugify</span><span class="c-punc">(</span><span class="c-str">'Hello World'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; 'hello-world'</span></pre>
</section> </section>
<!-- ======== 语法参考 ======== --> <!-- 语法高亮 -->
<section id="syntax"> <section id="highlight"><h2>内置语法高亮</h2>
<h2>解析器语法参考</h2> <p>v0.2.5 起内置零依赖轻量高亮器,支持 js / ts / tsx / jsx / python / bash / css / html / json / yaml / markdown / java / go / rust(含 shell / md 别名共 16 个语言标识)。</p>
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-com">// 方式一:作为 highlight 钩子(代码块自动高亮)</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span> highlight<span class="c-punc">:</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span> <span class="c-punc">});</span>
<span class="c-com">// 方式二:独立调用</span>
<span class="c-kw">const</span> html <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">highlight</span><span class="c-punc">(</span>code<span class="c-punc">,</span> <span class="c-str">'typescript'</span><span class="c-punc">);</span>
<span class="c-com">// 注册自定义语言</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">registerLanguage</span><span class="c-punc">(</span><span class="c-str">'myLang'</span><span class="c-punc">,</span> <span class="c-punc">{</span> keywords<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'kw'</span><span class="c-punc">]</span><span class="c-punc">,</span> builtins<span class="c-punc">:</span> <span class="c-punc">[]</span> <span class="c-punc">});</span></pre>
<p>高亮输出使用 <code>me-hl-keyword</code> / <code>me-hl-string</code> / <code>me-hl-comment</code> / <code>me-hl-number</code> / <code>me-hl-builtin</code> / <code>me-hl-function</code> 类。内置浅色 / 深色自适应配色,可通过 CSS 覆盖定制:</p>
<pre><span class="c-com">/* 自定义配色(覆盖内置默认) */</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-keyword</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#a855f7</span> <span class="c-punc">}</span>
<span class="c-kw">.me-preview</span> <span class="c-punc">.</span><span class="c-kw">me-hl-string</span><span class="c-punc">{</span> color<span class="c-punc">:</span> <span class="c-str">#22c55e</span> <span class="c-punc">}</span></pre>
</section>
<!-- 解析器语法 -->
<section id="parser-syntax"><h2>解析器语法参考</h2>
<table> <table>
<tr><th>语法</th><th>示例</th><th>输出</th></tr> <tr><th>语法</th><th>示例</th><th>输出</th></tr>
<tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td><code>&lt;h1&gt;</code> ~ <code>&lt;h6&gt;</code></td></tr> <tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td>&lt;h1&gt; ~ &lt;h6&gt;</td></tr>
<tr><td>段落</td><td>纯文本</td><td><code>&lt;p&gt;</code></td></tr> <tr><td>Setext 标题</td><td><code>H1\n===</code></td><td>&lt;h1&gt; / &lt;h2&gt;</td></tr>
<tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td><code>&lt;strong&gt;</code></td></tr> <tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td>&lt;strong&gt;</td></tr>
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><code>&lt;em&gt;</code></td></tr> <tr><td>斜体 <span class="badge">v0.1.15</span></td><td><code>***text***</code> <code>___text___</code></td><td>&lt;em&gt;&lt;strong&gt;</td></tr>
<tr><td>删除线</td><td><code>~~text~~</code></td><td><code>&lt;del&gt;</code></td></tr> <tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td>&lt;em&gt;</td></tr>
<tr><td>高亮标记</td><td><code>==text==</code></td><td><code>&lt;mark&gt;</code></td></tr> <tr><td>删除线 / 高亮</td><td><code>~~del~~</code> <code>==mark==</code></td><td>&lt;del&gt; / &lt;mark&gt;</td></tr>
<tr><td>上标</td><td><code>x^2^</code></td><td><code>&lt;sup&gt;</code></td></tr> <tr><td>下划线 <span class="badge">v0.3.0</span></td><td><code>&lt;u&gt;text&lt;/u&gt;</code>(裸标签透传)</td><td>&lt;u&gt;</td></tr>
<tr><td>下标</td><td><code>H~2~O</code></td><td><code>&lt;sub&gt;</code></td></tr> <tr><td>上标 / 下标</td><td><code>x^2^</code> <code>H~2~O</code></td><td>&lt;sup&gt; / &lt;sub&gt;</td></tr>
<tr><td>行内代码</td><td><code>`code`</code></td><td><code>&lt;code&gt;</code></td></tr> <tr><td>行内代码</td><td><code>`code`</code></td><td>&lt;code&gt;</td></tr>
<tr><td>代码块</td><td><code>```lang</code></td><td><code>&lt;pre&gt;&lt;code&gt;</code></td></tr> <tr><td>代码块</td><td><code>```lang</code></td><td>&lt;pre&gt;&lt;code&gt;</td></tr>
<tr><td>引用</td><td><code>&gt; quote</code></td><td><code>&lt;blockquote&gt;</code></td></tr> <tr><td>缩进代码块</td><td><code> code</code></td><td>&lt;pre&gt;&lt;code&gt;</td></tr>
<tr><td>无序列表</td><td><code>- item</code></td><td><code>&lt;ul&gt;&lt;li&gt;</code></td></tr> <tr><td>引用</td><td><code>&gt; quote</code></td><td>&lt;blockquote&gt;</td></tr>
<tr><td>序列表</td><td><code>1. item</code></td><td><code>&lt;ol&gt;&lt;li&gt;</code></td></tr> <tr><td>序列表</td><td><code>- * + item</code></td><td>&lt;ul&gt;&lt;li&gt;</td></tr>
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><code>&lt;li class="me-task-item"&gt;</code></td></tr> <tr><td>有序列表</td><td><code>1. item</code></td><td>&lt;ol&gt;&lt;li&gt;</td></tr>
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><code>&lt;hr&gt;</code></td></tr> <tr><td>嵌套列表</td><td>多级缩进</td><td>多级 &lt;ul&gt;/&lt;ol&gt;</td></tr>
<tr><td></td><td><code>| a | b |</code></td><td><code>&lt;table&gt;</code>(支持 <code>:---:</code> 列对齐)</td></tr> <tr><td>任务列</td><td><code>- [x] done</code></td><td>&lt;li class="me-task-item"&gt;</td></tr>
<tr><td>链接</td><td><code>[text](url)</code></td><td><code>&lt;a&gt;</code></td></tr> <tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td>&lt;hr&gt;</td></tr>
<tr><td>图片</td><td><code>![alt](url)</code></td><td><code>&lt;img&gt;</code></td></tr> <tr><td>表格</td><td><code>| a | b |</code></td><td>&lt;table&gt; + 列对齐</td></tr>
<tr><td>自动链接</td><td><code>&lt;https://...&gt;</code></td><td><code>&lt;a&gt;</code></td></tr> <tr><td>表格格式化 <span class="badge">v0.2.2</span></td><td><code>exec('formatTable')</code></td><td>列宽对齐</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(80+ 常用)</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>![alt](url)</code></td><td>&lt;img&gt;</td></tr>
<tr><td>引用链接 <span class="badge">v0.2.2</span></td><td><code>[text][ref]</code> + <code>[ref]: url</code></td><td>&lt;a&gt; 引用式</td></tr>
<tr><td>引用图片 <span class="badge">v0.2.2</span></td><td><code>![alt][ref]</code> + <code>[ref]: url</code></td><td>&lt;img&gt; 引用式</td></tr>
<tr><td>自动链接</td><td><code>&lt;https://...&gt;</code></td><td>&lt;a&gt;</td></tr>
<tr><td>数学公式</td><td><code>$E=mc^2$</code></td><td>&lt;span&gt; / &lt;div&gt;</td></tr>
<tr><td>脚注</td><td><code>text[^1]</code></td><td>&lt;sup&gt; + 底部</td></tr>
<tr><td>定义列表</td><td><code>Term\n: def</code></td><td>&lt;dl&gt;&lt;dt&gt;&lt;dd&gt;</td></tr>
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(150+</td></tr>
<tr><td>反斜杠转义 <span class="badge">v0.1.15</span></td><td><code>\*</code> <code>\_</code></td><td>取消标点特殊含义</td></tr>
</table> </table>
<h4>XSS 防护</h4>
<h3>XSS 防护</h3> <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>&lt;u&gt;</code> 透传,提供 <code>sanitize</code> 钩子。</p>
<p>所有文本经 HTML 转义。URL 自动过滤 <code>javascript:</code> <code>vbscript:</code> <code>file:</code> 及非图片 <code>data:</code> 协议。</p>
</section> </section>
<!-- ======== 插件 ======== --> <section id="parser-api"><h2>解析器 API</h2>
<section id="plugins"> <h3>parseTokens / renderTokens</h3>
<h2>插件约定</h2> <pre><span class="c-kw">import</span> <span class="c-punc">{</span> parseTokens<span class="c-punc">,</span> renderTokens <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-kw">const</span> <span class="c-punc">{</span> tokens<span class="c-punc">,</span> footnotes <span class="c-punc">}</span> <span class="c-punc">=</span> <span class="c-fn">parseTokens</span><span class="c-punc">(</span><span class="c-str">'# Hello\n\n**world**'</span><span class="c-punc">);</span>
tokens<span class="c-punc">.</span><span class="c-fn">unshift</span><span class="c-punc">({</span> type<span class="c-punc">:</span> <span class="c-str">'hr'</span> <span class="c-punc">});</span> <span class="c-com">// 渲染前变换</span>
<span class="c-kw">const</span> html <span class="c-punc">=</span> <span class="c-fn">renderTokens</span><span class="c-punc">(</span>tokens<span class="c-punc">,</span> env<span class="c-punc">,</span> footnotes<span class="c-punc">);</span></pre>
<h3>registerBlockHandler</h3>
<pre><span class="c-kw">import</span> <span class="c-punc">{</span> registerBlockHandler <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
<span class="c-fn">registerBlockHandler</span><span class="c-punc">({</span>
name<span class="c-punc">:</span> <span class="c-str">'custom'</span><span class="c-punc">,</span> priority<span class="c-punc">:</span> <span class="c-num">8.5</span><span class="c-punc">,</span>
test<span class="c-punc">:</span> <span class="c-punc">(</span>line<span class="c-punc">)</span> <span class="c-punc">=></span> line<span class="c-punc">.</span><span class="c-fn">match</span><span class="c-punc">(</span><span class="c-re">/^:::/</span><span class="c-punc">),</span>
parse<span class="c-punc">:</span> <span class="c-punc">(</span>lines<span class="c-punc">,</span> i<span class="c-punc">,</span> match<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">({</span> token<span class="c-punc">:</span> <span class="c-punc">{</span> type<span class="c-punc">:</span> <span class="c-str">'alert'</span> <span class="c-punc">},</span> newIndex<span class="c-punc">:</span> j <span class="c-punc">})</span>
<span class="c-punc">});</span></pre>
</section>
<!-- 插件 -->
<section id="plugins-convention"><h2>插件约定</h2>
<pre><span class="c-kw">const</span> myPlugin <span class="c-punc">=</span> <span class="c-punc">{</span> <pre><span class="c-kw">const</span> myPlugin <span class="c-punc">=</span> <span class="c-punc">{</span>
name<span class="c-punc">:</span> <span class="c-str">'myPlugin'</span><span class="c-punc">,</span> name<span class="c-punc">:</span> <span class="c-str">'myPlugin'</span><span class="c-punc">,</span> description<span class="c-punc">:</span> <span class="c-str">'我的插件'</span><span class="c-punc">,</span>
description<span class="c-punc">:</span> <span class="c-str">'我的自定义插件'</span><span class="c-punc">,</span> <span class="c-fn">install</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span> editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span> <span class="c-punc">},</span>
<span class="c-fn">destroy</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span> editor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span> <span class="c-punc">},</span>
<span class="c-fn">install</span><span class="c-punc">(</span>editor<span class="c-punc">,</span> options<span class="c-punc">)</span> <span class="c-punc">{</span>
<span class="c-com">// this 指向插件对象本身</span>
<span class="c-kw">this</span><span class="c-punc">.</span>_timer <span class="c-punc">=</span> <span class="c-kw">null</span><span class="c-punc">;</span>
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
<span class="c-punc">},</span>
<span class="c-fn">destroy</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span>
<span class="c-kw">if</span> <span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">)</span> <span class="c-fn">clearTimeout</span><span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
<span class="c-punc">},</span>
<span class="c-punc">};</span></pre> <span class="c-punc">};</span></pre>
</section> </section>
<section id="preset-plugins"> <section id="preset-plugins"><h3>预设插件</h3>
<h3>预设插件</h3> <h4>autoSave</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'autoSave'</span><span class="c-punc">,</span> <span class="c-punc">{</span> key<span class="c-punc">:</span> <span class="c-str">'draft-key'</span><span class="c-punc">,</span> delay<span class="c-punc">:</span> <span class="c-num">1000</span> <span class="c-punc">});</span>
<h4>autoSave — 自动保存草稿</h4> 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>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span> <h4>exportTool</h4>
key<span class="c-punc">:</span> <span class="c-str">'me-draft-'</span> <span class="c-punc">+</span> editor<span class="c-punc">.</span>id<span class="c-punc">,</span> <span class="c-com">// localStorage key</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>
delay<span class="c-punc">:</span> <span class="c-num">1000</span><span class="c-punc">,</span> <span class="c-com">// 防抖延迟(ms</span> editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'doc.md'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'doc.html'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">exportPDF</span><span class="c-punc">();</span></pre>
<span class="c-punc">});</span> <h4>searchReplace</h4>
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> <span class="c-com">// 恢复草稿</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>
editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span> <span class="c-com">// 清除草稿</span> <span class="c-com">// Ctrl+F 查找 / Ctrl+H 替换,支持正则 .* 、大小写 Aa、全字 ab 开关</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">getDraftKey</span><span class="c-punc">();</span> <span class="c-com">// 获取存储 key</span></pre> <h4>imagePaste</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'imagePaste'</span><span class="c-punc">,</span> <span class="c-punc">{</span> maxSizeKB<span class="c-punc">:</span> <span class="c-num">500</span> <span class="c-punc">});</span> <span class="c-com">// 粘贴图片转 base64,超限警告</span></pre>
<h4>exportTool — 导出文件</h4> <h4>shortcutHelp</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>exportTool<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">'shortcutHelp'</span><span class="c-punc">);</span> <span class="c-com">// 按 ? 弹出快捷键面板</span></pre>
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'my-doc.md'</span><span class="c-punc">);</span> <h4>fileSystem</h4>
editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'my-doc.html'</span><span class="c-punc">,</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">'fileSystem'</span><span class="c-punc">);</span>
title<span class="c-punc">:</span> <span class="c-str">'文档标题'</span><span class="c-punc">,</span> editor<span class="c-punc">.</span><span class="c-fn">openFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFile</span><span class="c-punc">();</span> editor<span class="c-punc">.</span><span class="c-fn">saveFileAs</span><span class="c-punc">();</span></pre>
css<span class="c-punc">:</span> <span class="c-str">'body { font-family: sans-serif; }'</span><span class="c-punc">,</span>
lang<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
<span class="c-punc">});</span></pre>
<h4>searchReplace — 查找替换</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>searchReplace<span class="c-punc">);</span>
<span class="c-com">// Ctrl+F → 查找 Ctrl+H → 替换</span>
<span class="c-com">// Enter → 下一个 Shift+Enter → 上一个 Escape → 关闭</span></pre>
<h4>imagePaste — 粘贴图片转 base64</h4>
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>imagePaste<span class="c-punc">);</span>
<span class="c-com">// 在编辑区 Ctrl+V 粘贴剪贴板图片</span>
<span class="c-com">// 自动插入 ![](data:image/png;base64,...)</span></pre>
</section> </section>
<!-- ======== 主题 ======== --> <!-- 主题 -->
<section id="themes"> <section id="themes"><h2>主题系统</h2>
<h2>主题系统</h2> <pre><span class="c-com">// 实例级</span>
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span>
<pre><span class="c-com">// 全局切换主题</span> <span class="c-com">// 注册自定义</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'light'</span><span class="c-punc">);</span> <span class="c-com">// 亮色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> <span class="c-com">// 暗色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'warm'</span><span class="c-punc">);</span> <span class="c-com">// 暖色</span>
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'auto'</span><span class="c-punc">);</span> <span class="c-com">// 自动(默认)</span>
<span class="c-com">// 实例级主题(v0.1.5,每个编辑器独立)</span>
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span> <span class="c-com">// 'dark'</span>
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'themeChange'</span><span class="c-punc">,</span> <span class="c-punc">({</span> theme <span class="c-punc">})</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
<span class="c-com">// 注册自定义主题(v0.1.5: 支持继承)</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span> extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span> <span class="c-punc">});</span>
extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> <span class="c-com">// 继承 dark 主题</span>
accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span><span class="c-punc">,</span>
<span class="c-punc">});</span>
<span class="c-com">// 外部主题跟随(v0.1.5</span> <span class="c-com">// 外部跟随</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">followExternalTheme</span><span class="c-punc">(</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>
<span class="c-punc">{</span> element<span class="c-punc">:</span> document<span class="c-punc">.</span>body<span class="c-punc">,</span> attr<span class="c-punc">:</span> <span class="c-str">'data-theme'</span> <span class="c-punc">},</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>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</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-punc">);</span>
<span class="c-com">// 从父容器继承主题</span>
themeUtils<span class="c-punc">.</span><span class="c-fn">adoptFromParent</span><span class="c-punc">(</span>editor<span class="c-punc">.</span>container<span class="c-punc">,</span>
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
<span class="c-punc">);</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> <span class="c-com">// { '--md-bg': '#fff', ... }</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">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span> <span class="c-com">// '#3b82f6'</span></pre>
<p>CSS 变量限定在 <code>.me-wrapper</code> 元素上,不污染全局。支持 <code>@media print</code> 打印样式。v0.1.5 新增实例级主题隔离、外部跟随、主题继承。</p>
</section> </section>
<!-- ======== 国际化 ======== --> <!-- 国际化 -->
<section id="i18n"> <section id="i18n"><h2>国际化</h2>
<h2>国际化</h2> <pre><span class="c-com">// 切换</span>
editor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span> MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'zh-CN'</span><span class="c-punc">);</span>
<pre><span class="c-com">// 切换语言</span> <span class="c-com">// 添加翻译</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>
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'zh-CN'</span><span class="c-punc">);</span>
<span class="c-com">// 翻译函数</span>
<span class="c-kw">import</span> <span class="c-punc">{</span> i18nUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span> <span class="c-kw">import</span> <span class="c-punc">{</span> i18nUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '粗体'zh-CN</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><span class="c-punc">(</span><span class="c-str">'ja'</span><span class="c-punc">,</span> <span class="c-punc">{</span> bold<span class="c-punc">:</span> <span class="c-str">'太字'</span> <span class="c-punc">});</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; 'Bold'en-US</span>
<span class="c-com">// 添加语言</span> <span class="c-com">// 远程加载</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><span class="c-punc">(</span><span class="c-str">'ja'</span><span class="c-punc">,</span> <span class="c-punc">{</span> bold<span class="c-punc">:</span> <span class="c-str">'太字'</span><span class="c-punc">,</span> italic<span class="c-punc">:</span> <span class="c-str">'斜体'</span> <span class="c-punc">});</span> <span class="c-kw">await</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">loadRemote</span><span class="c-punc">(</span><span class="c-str">'/locales/ja.json'</span><span class="c-punc">,</span> <span class="c-str">'ja'</span><span class="c-punc">);</span>
<span class="c-com">// 格式化</span> <span class="c-com">// 格式化</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '1,234,567'</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '$99.99'</span> i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span></pre>
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span> <span class="c-com">// =&gt; '1/15/2024'</span></pre>
</section> </section>
</main> </main>
@@ -619,24 +494,18 @@ i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span
<footer> <footer>
<div class="container"> <div class="container">
MetonaEditor v0.1.10 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License MetonaEditor v0.4.1 · TypeScript · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
</div> </div>
</footer> </footer>
<script> <script>
// 高亮当前 section 的侧边栏链接 const sections = document.querySelectorAll('section[id]');
const sections = document.querySelectorAll('section[id]'); const links = document.querySelectorAll('.sidebar a');
const links = document.querySelectorAll('.sidebar a'); window.addEventListener('scroll', () => {
window.addEventListener('scroll', () => {
let current = ''; let current = '';
sections.forEach(s => { sections.forEach(s => { if (window.scrollY >= s.offsetTop - 100) current = s.id; });
if (window.scrollY >= s.offsetTop - 100) current = s.id; links.forEach(a => { a.classList.toggle('active', a.getAttribute('href') === '#' + current); });
}); });
links.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === '#' + current);
});
});
</script> </script>
</body> </body>
</html> </html>
+811
View File
@@ -0,0 +1,811 @@
<!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.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
--bg:#0a0a0f; --surface:#0d1117; --surface2:#161b22; --border:#30363d;
--primary:#6366f1; --accent:#06b6d4; --accent2:#ec4899; --green:#22c55e;
--text:#e2e8f0; --text2:#8b949e; --radius:12px;
--gradient:linear-gradient(135deg,#6366f1,#06b6d4,#ec4899);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; overflow:hidden; }
header {
height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px;
background:var(--surface); border-bottom:1px solid var(--border); z-index:10; position:relative;
}
.logo { font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:10px; }
.logo .icon { width:28px; height:28px; border-radius:7px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:900; color:#fff; }
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
nav { display:flex; gap:20px; }
nav a { color:var(--text2); text-decoration:none; font-size:0.88rem; transition:.2s; }
nav a:hover,.nav-active { color:var(--text); }
.status { display:flex; align-items:center; gap:8px; font-size:0.82rem; color:var(--text2); }
.status .dot { width:7px; height:7px; border-radius:50%; background:var(--green); animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.main { display:flex; height:calc(100vh - 56px); }
.editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); min-width:400px; }
.result-panel { flex:1; display:flex; flex-direction:column; min-width:400px; }
.panel-header {
padding:12px 20px; font-size:0.78rem; font-weight:600; text-transform:uppercase;
letter-spacing:1px; color:var(--text2); border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between;
}
.panel-header .badge {
font-size:0.7rem; padding:3px 10px; border-radius:999px; background:var(--surface2);
border:1px solid var(--border); text-transform:none; letter-spacing:0;
}
.editor-area { flex:1; display:flex; flex-direction:column; }
#sql-input {
flex:1; background:var(--surface); color:var(--text); border:none; padding:20px;
font-family:'JetBrains Mono','Fira Code',monospace; font-size:0.9rem; line-height:1.8;
resize:none; outline:none; caret-color:var(--primary);
}
#sql-input::placeholder { color:var(--text2); opacity:0.5; }
.toolbar { display:flex; gap:8px; padding:12px 20px; border-top:1px solid var(--border); background:var(--surface2); flex-wrap:wrap; }
.btn {
display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px;
font-weight:600; font-size:0.85rem; cursor:pointer; border:none; transition:.25s; font-family:inherit;
}
.btn-run { background:var(--gradient); color:#fff; box-shadow:0 4px 15px rgba(99,102,241,0.35); }
.btn-run:hover { transform:translateY(-1px); box-shadow:0 6px 25px rgba(99,102,241,0.5); }
.btn-run:active { transform:scale(0.97); }
.btn-clear { background:var(--surface2); color:var(--text2); border:1px solid var(--border); }
.btn-clear:hover { color:var(--text); border-color:var(--text2); }
.btn-preset { background:transparent; color:var(--accent); border:1px solid var(--accent); font-size:0.78rem; padding:6px 14px; }
.btn-preset:hover { background:rgba(6,182,212,0.12); }
.result-content { flex:1; overflow-y:auto; padding:20px; }
.result-table { width:100%; border-collapse:collapse; font-size:0.85rem; }
.result-table th { text-align:left; padding:10px 14px; background:var(--surface2); color:var(--accent); font-weight:600; border-bottom:2px solid var(--border); position:sticky; top:0; z-index:1; }
.result-table td { padding:8px 14px; border-bottom:1px solid var(--border); color:var(--text2); max-width:250px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.result-table tr:hover td { background:rgba(99,102,241,0.04); }
.empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text2); gap:12px; }
.empty-state .icon { font-size:3rem; opacity:0.3; }
.error-box { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:10px; padding:16px; color:#f87171; font-family:'JetBrains Mono',monospace; font-size:0.82rem; margin-bottom:16px; }
.success-box { background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3); border-radius:10px; padding:12px 16px; color:#4ade80; font-size:0.85rem; margin-bottom:16px; }
.sql-tag { display:inline-block; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:4px 12px; font-family:'JetBrains Mono',monospace; font-size:0.75rem; color:var(--accent); margin-bottom:8px; }
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
@media(max-width:900px){ .main { flex-direction:column; } .editor-panel,.result-panel { min-width:auto; } .editor-panel { height:50%; } }
</style>
</head>
<body>
<header>
<div class="logo"><div class="icon"></div><span>MetonaSqlark</span></div>
<nav>
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a>
</nav>
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.4</div>
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</header>
<div class="main">
<div class="editor-panel">
<div class="editor-area">
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.4 在线演示
-- 已预置 users / orders / products 表数据
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
-- 查看所有数据
SELECT * FROM users;
-- 条件查询 + 排序
SELECT name, age FROM users WHERE age > 25 ORDER BY age DESC;
-- JOIN 多表关联
SELECT u.name, o.product, o.amount
FROM users u INNER JOIN orders o ON u.id = o.user_id;
-- 聚合统计
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;</textarea>
<div class="toolbar">
<button class="btn btn-run" onclick="runQuery()">▶ 执行 (Ctrl+Enter)</button>
<button class="btn btn-clear" onclick="clearResults()">🗑 清空</button>
<span style="flex:1"></span>
<button class="btn btn-preset" onclick="loadPreset('all')">📋 全数据</button>
<button class="btn btn-preset" onclick="loadPreset('join')">🔗 JOIN</button>
<button class="btn btn-preset" onclick="loadPreset('agg')">📊 聚合</button>
<button class="btn btn-preset" onclick="loadPreset('ddl')">🏗 DDL</button>
<button class="btn btn-preset" onclick="loadPreset('crud')">✏️ CRUD</button>
<button class="btn btn-preset" onclick="loadPreset('subquery')">🔍 子查询</button>
<button class="btn btn-preset" onclick="loadPreset('tx')">🔒 事务</button>
<button class="btn btn-preset" onclick="loadPreset('scalar')">🎯 标量子查询</button>
<button class="btn btn-preset" onclick="loadPreset('cascade')">🔗 级联</button>
<button class="btn btn-preset" onclick="loadPreset('adv')">🧪 高级</button>
<button class="btn btn-preset" onclick="loadPreset('alter')">🏗 ALTER</button>
<button class="btn btn-preset" onclick="loadPreset('truncate')">🗑 TRUNCATE</button>
<button class="btn btn-preset" onclick="loadPreset('casewhen')">🎯 CASE WHEN</button>
<button class="btn btn-preset" onclick="loadPreset('union')">🔀 UNION</button>
<button class="btn btn-preset" onclick="loadPreset('insertselect')">📥 INSERT SELECT</button>
<button class="btn btn-preset" onclick="loadPreset('exists')">🔍 EXISTS</button>
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</button>
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
<button class="btn btn-preset" onclick="loadPreset('v040')" style="color:#22c55e;border-color:#22c55e;">🚰 v0.4.0 新特性</button>
<button class="btn btn-preset" onclick="loadPreset('onupdate')" style="color:#fbbf24;border-color:#fbbf24;">🔄 ON UPDATE</button>
<button class="btn btn-preset" onclick="loadPreset('repair')" style="color:#22c55e;border-color:#22c55e;">🛡 自愈</button>
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</div>
</div>
</div>
<div class="result-panel">
<div class="panel-header">
<span>📊 查询结果</span>
<span class="badge" id="row-count">0 行</span>
</div>
<div class="result-content" id="result-area">
<div class="empty-state">
<div class="icon"></div>
<div>执行 SQL 查询以查看结果</div>
<div style="font-size:0.8rem;opacity:0.6">Ctrl + Enter 快捷执行 · 支持多语句</div>
</div>
</div>
</div>
</div>
<script src="../dist/metona-sqlark.js"></script>
<script>
// Resolve global: try both MetonaSqlark and MeSqlark
const globalNs = window.MetonaSqlark || window.MeSqlark || {};
const { MetonaSqlark, MeSqlark, create } = globalNs;
const DBClass = MetonaSqlark || MeSqlark;
let db = null;
const resultArea = document.getElementById('result-area');
const rowCount = document.getElementById('row-count');
const sqlInput = document.getElementById('sql-input');
// Init database
let currentEngine = 'memory';
async function initDB() {
try {
const engine = currentEngine;
// 关闭旧实例(预设重置/引擎切换时数据回到初始状态)
if (db) { try { await db.close(); } catch { /* ignore */ } }
db = new DBClass({ name: 'demo', mode: engine });
await db.init();
// v0.4.2: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
await db.getEngine().clearAll();
}
await seedDemoData(db);
renderInfo(`${engine === 'aria' ? '🌲 AriaEngineLSM-Tree + WAL + MVCC' : '⚡ Memory'} 引擎已就绪`);
} catch (e) {
renderError('数据库初始化失败: ' + e.message);
}
}
// 建表 + 种子数据(每次预设重置/引擎切换后重建,保证演示可预期)
async function seedDemoData(db) {
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
email: { type: 'string' },
age: { type: 'number', default: 0 },
});
await db.defineTable('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
product: { type: 'string' },
amount: { type: 'number' },
quantity: { type: 'number', default: 1 },
});
await db.defineTable('products', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
price: { type: 'number', default: 0 },
category: { type: 'string' },
});
// Seed data
await db.table('users').insertMany([
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
{ id: '4', name: 'Diana', email: 'diana@demo.com', age: 28 },
{ id: '5', name: 'Eve', email: 'eve@demo.com', age: 22 },
]);
await db.table('orders').insertMany([
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200, quantity: 1 },
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50, quantity: 2 },
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150, quantity: 1 },
{ id: 'o4', user_id: '3', product: 'Monitor', amount: 400, quantity: 1 },
{ id: 'o5', user_id: '3', product: 'Cable', amount: 20, quantity: 3 },
]);
await db.table('products').insertMany([
{ id: 'p1', name: 'Laptop', price: 1200, category: 'Electronics' },
{ id: 'p2', name: 'Mouse', price: 50, category: 'Accessories' },
{ id: 'p3', name: 'Keyboard', price: 150, category: 'Accessories' },
]);
}
// v0.4.2: 切换存储引擎(重建数据库实例)
async function switchEngine(engine) {
if (engine === currentEngine) return;
currentEngine = engine;
document.getElementById('btn-memory').style.opacity = engine === 'memory' ? '1' : '0.6';
document.getElementById('btn-aria').style.opacity = engine === 'aria' ? '1' : '0.6';
document.getElementById('engine-status').textContent = engine === 'aria' ? '🌲 Aria' : '⚡ Memory';
document.getElementById('engine-dot').style.background = engine === 'aria' ? '#ec4899' : '';
renderInfo(`⏳ 正在初始化 ${engine === 'aria' ? 'Aria' : 'Memory'} 引擎…`);
await initDB();
}
// Run SQL
async function runQuery() {
const sql = sqlInput.value.trim();
if (!sql || !db) return;
const statements = sql.split(';').map(s => s.trim()).filter(s => {
// 过滤空语句和纯注释语句
if (s.length === 0) return false;
const nonComment = s.split('\n').filter(l => !l.trimStart().startsWith('--')).join('\n').trim();
return nonComment.length > 0;
});
try {
const allResults = [];
for (const stmt of statements) {
const result = await db.query(stmt);
// 深拷贝查询结果,防止后续语句通过引用修改已保存的结果
const resultCopy = Array.isArray(result) ? result.map(r => ({ ...r })) : result;
allResults.push({ sql: stmt, result: resultCopy });
}
if (allResults.length === 1) {
renderResult(allResults[0]);
} else {
renderAllResults(allResults);
}
} catch (e) {
renderError(e.message || String(e));
}
}
function renderResult({ sql, result }) {
if (Array.isArray(result)) {
renderTable(result);
} else {
renderSuccess(`✅ 执行成功 (影响 ${result ?? 0} 行)`);
}
}
function renderAllResults(allResults) {
let html = '';
for (const { sql, result } of allResults) {
html += `<div class="sql-tag">> ${escapeHTML(sql)}</div>`;
if (Array.isArray(result)) {
if (result.length === 0) {
html += '<div class="success-box">✅ 查询返回 0 行</div>';
} else {
html += buildTableHTML(result);
}
} else {
html += `<div class="success-box">✅ 执行成功 (影响 ${result ?? 0} 行)</div>`;
}
html += '<div style="height:12px"></div>';
}
resultArea.innerHTML = html;
updateRowCount(allResults.reduce((sum, r) => sum + (Array.isArray(r.result) ? r.result.length : 0), 0));
}
function renderTable(rows) {
if (!rows || rows.length === 0) {
resultArea.innerHTML = '<div class="success-box">✅ 查询返回 0 行</div>';
updateRowCount(0);
return;
}
resultArea.innerHTML = buildTableHTML(rows);
updateRowCount(rows.length);
}
function buildTableHTML(rows) {
const columns = Object.keys(rows[0]);
let html = '<table class="result-table"><thead><tr>';
for (const col of columns) {
html += `<th>${escapeHTML(col)}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of rows) {
html += '<tr>';
for (const col of columns) {
const val = row[col];
html += `<td>${formatCell(val)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function formatCell(val) {
if (val === null) return '<span style="color:#64748b;font-style:italic">NULL</span>';
if (val === undefined) return '<span style="color:#64748b">-</span>';
if (typeof val === 'boolean') return `<span style="color:#fbbf24">${val}</span>`;
if (typeof val === 'number') return `<span style="color:#fbbf24">${val}</span>`;
return escapeHTML(String(val));
}
function renderError(msg) {
resultArea.innerHTML = `<div class="error-box">❌ ${escapeHTML(msg)}</div>`;
updateRowCount(0);
}
function renderSuccess(msg) {
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
}
function renderInfo(msg) {
resultArea.innerHTML = `<div class="success-box" style="color:#22c55e;border-color:#22c55e;">${msg}</div>`;
}
function clearResults() {
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
updateRowCount(0);
}
function updateRowCount(n) { rowCount.textContent = `${n}`; }
function escapeHTML(str) { return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;'); }
// 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.4)
-- 点击右上角「🌲 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: 原子写入 + 崩溃恢复(残缺 SSTable 打开自动跳过)+ 批量组提交
-- • MVCC: 版本链 + 快照隔离
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
-- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • 二级索引: 每列独立 LSM + 跨重启自动恢复
-- • 外键级联: ON DELETE / ON UPDATE — CASCADE / SET NULL / RESTRICT (v0.4.2)
-- • 自愈: db.repair() 无需删库重建 (v0.4.2)
-- 生产环境 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.2: JOIN 主表 WHERE 条件下推 + 二级索引跨重启恢复)
SELECT u.name, o.product, o.amount
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.user_id = '1';
-- 删除索引
DROP INDEX idx_orders_user ON orders (user_id);
-- 删除后回退全表扫描(结果不变)
SELECT * FROM orders WHERE user_id = '3';
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND
-- DROP INDEX idx_nonexist ON orders (user_id);`,
onupdate: `-- 🔄 ON UPDATE 外键级联 (v0.4.2)
-- 更新父表主键 → 子表外键自动级联(CASCADE / SET NULL / RESTRICT
-- 建带 onUpdate 外键的表
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS audit_log;
CREATE TABLE accounts (id STRING PRIMARY KEY, owner STRING);
CREATE TABLE audit_log (
id STRING PRIMARY KEY,
account_id STRING REFERENCES accounts.id ON UPDATE CASCADE
);
-- 种子数据
INSERT INTO accounts VALUES ('a1', 'Alice');
INSERT INTO audit_log VALUES ('l1', 'a1');
INSERT INTO audit_log VALUES ('l2', 'a1');
-- 更新父表主键 a1 → a2
UPDATE accounts SET id = 'a2' WHERE id = 'a1';
-- 子表外键已级联为 a2
SELECT * FROM audit_log ORDER BY id;
-- 再按新主键反查
SELECT * FROM accounts WHERE id = 'a2';`,
repair: `-- 🛡 崩溃恢复自愈 (v0.4.2)
-- db.repair(): 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL
-- db.clearAll(): 清空全部数据与表结构(保留库本身)
-- 打开数据库时自动跳过残缺 SSTable,无需删库重建
-- 数据写入
CREATE TABLE IF NOT EXISTS notes (id STRING PRIMARY KEY, body STRING);
INSERT INTO notes VALUES ('n1', 'Hello');
INSERT INTO notes VALUES ('n2', 'World');
-- 模拟数据(正常数据)
SELECT * FROM notes ORDER BY id;
-- API 自愈(控制台执行):
-- await db.repair(); → 校验+清理损坏文件,重建索引
-- await db.clearAll(); → 清空全部表与数据
-- 迁移版本持久化(重启后不重跑已执行迁移)
-- db.addMigration(1, async () => { ... });
-- await db.migrateTo(1);`,
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
-- 分号分隔的多语句一次执行
DROP TABLE IF EXISTS audit;
CREATE TABLE audit (id STRING PRIMARY KEY, msg STRING);
INSERT INTO audit VALUES ('a1', 'first');
INSERT INTO audit VALUES ('a2', 'second');
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → ROLLBACKa3 不会生效)
BEGIN;
INSERT INTO audit VALUES ('a3', 'will be rolled back');
ROLLBACK;
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → COMMITa4 生效)
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 / LASTNULL 排序位置控制
SELECT name FROM users ORDER BY age ASC NULLS FIRST LIMIT 3;
-- 普通列别名 + ORDER BY 别名
SELECT name AS n FROM users ORDER BY n DESC;
-- SQL 标准 '' 字符串转义
SELECT 'it''s a test' AS escaped;
-- 哈希连接(等值 ON 走 O(N+M);多列等值 ON 同样支持)
SELECT u.name, o.product FROM users u
INNER JOIN orders o ON o.user_id = u.id;`,
};
// v0.4.1: 点击预设前自动重置数据(前序预设可能修改/删除种子数据,重置保证每个预设可预期演示)
async function loadPreset(name) {
if (presets[name]) {
sqlInput.value = presets[name];
await initDB();
runQuery();
}
}
// Keyboard shortcut
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
});
// Boot
document.getElementById('btn-memory').style.opacity = '1';
document.getElementById('btn-aria').style.opacity = '0.6';
document.getElementById('engine-status').textContent = '⚡ Memory';
initDB().then(() => {
console.log('✅ MetonaSqlark v0.4.2 demo ready');
setTimeout(runQuery, 300);
}).catch(err => {
renderError('初始化失败: ' + err.message);
});
</script>
</body>
</html>
+866
View File
@@ -0,0 +1,866 @@
<!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.4</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
--bg:#0a0a0f; --surface:#14141f; --surface2:#1a1a2e; --border:#2a2a45;
--primary:#6366f1; --primary-glow:#818cf8; --accent:#06b6d4; --accent2:#ec4899;
--text:#e2e8f0; --text2:#94a3b8; --gradient:linear-gradient(135deg,#6366f1 0%,#06b6d4 50%,#ec4899 100%);
--radius:14px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.7; }
header {
position:fixed; top:0; left:0; right:0; z-index:100; backdrop-filter:blur(20px);
background:rgba(10,10,15,0.85); border-bottom:1px solid var(--border); padding:16px 32px;
}
header .inner { max-width:1400px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; }
.logo { font-size:1.3rem; font-weight:800; display:flex; align-items:center; gap:10px; }
.logo .icon { width:32px; height:32px; border-radius:8px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:1rem; font-weight:900; color:#fff; }
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
nav { display:flex; gap:28px; }
nav a { color:var(--text2); text-decoration:none; font-weight:500; font-size:0.92rem; transition:.2s; }
nav a:hover,.nav-active { color:var(--text); }
.layout { display:flex; max-width:1400px; margin:0 auto; padding-top:80px; min-height:100vh; }
.sidebar {
width:260px; min-width:260px; padding:32px 24px; position:sticky; top:80px; height:calc(100vh - 80px);
overflow-y:auto; border-right:1px solid var(--border); background:var(--bg);
}
.sidebar h4 { font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--text2); margin:20px 0 8px; }
.sidebar h4:first-child { margin-top:0; }
.sidebar a { display:block; padding:7px 12px; border-radius:6px; color:var(--text2); text-decoration:none; font-size:0.9rem; transition:.15s; }
.sidebar a:hover,.sidebar a.active { color:var(--text); background:var(--surface2); }
.sidebar a.active { border-left:3px solid var(--primary); }
.content { flex:1; padding:32px 48px 80px; max-width:950px; }
.content h2 { font-size:1.8rem; font-weight:800; margin:48px 0 16px; padding-top:24px; border-top:1px solid var(--border); }
.content h2:first-of-type { margin-top:0; padding-top:0; border-top:none; }
.content h3 { font-size:1.25rem; font-weight:700; margin:32px 0 12px; color:var(--primary-glow); }
.content p { color:var(--text2); margin-bottom:16px; }
.content code {
background:var(--surface2); padding:2px 8px; border-radius:5px; font-family:'JetBrains Mono',monospace;
font-size:0.88rem; color:var(--accent);
}
.content pre {
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
padding:20px 24px; overflow-x:auto; font-family:'JetBrains Mono',monospace; font-size:0.85rem;
line-height:1.65; margin:16px 0; color:#e2e8f0;
}
.content pre .k { color:#c084fc; } .content pre .s { color:#34d399; }
.content pre .f { color:#60a5fa; } .content pre .c { color:#64748b; }
.content pre .n { color:#fbbf24; } .content pre .t { color:#f472b6; }
.content table { width:100%; border-collapse:collapse; margin:16px 0; }
.content th,.content td { text-align:left; padding:12px 16px; border-bottom:1px solid var(--border); font-size:0.9rem; }
.content th { color:var(--text); font-weight:600; background:var(--surface); }
.content td { color:var(--text2); }
.content td code { font-size:0.82rem; }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
</style>
</head>
<body>
<header>
<div class="inner">
<div class="logo"><div class="icon"></div><span>MetonaSqlark</span></div>
<nav>
<a href="index.html">首页</a>
<a href="docs.html" class="nav-active">文档</a>
<a href="demo.html">演示</a>
<a href="benchmark.html">基准</a>
</nav>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<h4>快速开始</h4>
<a href="#install" class="active">安装</a>
<a href="#create">创建数据库</a>
<a href="#define-table">定义表</a>
<h4>查询 API</h4>
<a href="#sql-query">SQL 查询</a>
<a href="#query-builder">Query Builder</a>
<a href="#join">JOIN 查询</a>
<a href="#groupby">GROUP BY & 聚合</a>
<a href="#where-ops">WHERE 操作符</a>
<h4>高级特性</h4>
<a href="#aria-engine">AriaEngine 🆕</a>
<a href="#transaction">事务 & 回滚</a>
<a href="#subquery">子查询</a>
<a href="#alter-table">ALTER TABLE 🆕</a>
<a href="#truncate">TRUNCATE TABLE 🆕</a>
<a href="#foreign-key">外键级联</a>
<a href="#connection-pool">连接池</a>
<a href="#migration">数据迁移</a>
<a href="#selfheal">崩溃自愈 🆕</a>
<a href="#export">导入导出</a>
<a href="#plugin">插件 & 钩子</a>
<a href="#subscribe">发布订阅</a>
<h4>框架集成</h4>
<a href="#react">React 集成</a>
<a href="#vue">Vue 集成</a>
<h4>类型参考</h4>
<a href="#types">TypeScript 类型</a>
<a href="#config">完整配置项</a>
<a href="#engine">存储引擎</a>
<a href="#errors">错误处理</a>
</aside>
<main class="content">
<h2 id="install">📦 安装</h2>
<p>MetonaSqlark 支持多种引入方式,覆盖 npm、CDN、ESM、CJS 所有常见场景。</p>
<h3>npm 安装(推荐)</h3>
<pre><span class="c"># 配置 Gitea Registry(一次性)</span>
npm config set @metona-team:registry https://git.metona.cn/api/packages/MetonaTeam/npm/
<span class="c"># 安装</span>
npm install @metona-team/metona-sqlark</pre>
<h3>CDN / UMD</h3>
<pre><span class="c">&lt;!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark --&gt;</span>
&lt;script src=<span class="s">"https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"</span>&gt;&lt;/script&gt;
&lt;script&gt;
<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>
&lt;/script&gt;</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>删除级联 ✅ v0.4.1</td></tr>
<tr><td><code>onUpdate</code></td><td><code>'CASCADE'\|'SET NULL'\|'RESTRICT'</code></td><td>更新级联(更新主键时触发)✅ v0.4.2</td></tr>
</table>
<h2 id="sql-query">🔍 SQL 查询</h2>
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
<p><code>db.queryStream(sql, onRow)</code> — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECTWHERE/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) =&gt; {
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>链式 APITypeScript 友好,享受 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 JOINSQL 和 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>
<p><strong>v0.4.2: 迁移版本持久化到库内</strong> — 重启后从持久化版本继续执行,已执行迁移不重跑(此前 version 每次从 config 重置,可能重跑不幂等的迁移)。</p>
<h2 id="selfheal">🛡 崩溃恢复自愈(v0.4.2</h2>
<p>异常退出(强杀/断电)后无需删库重建:打开数据库时自动跳过残缺 SSTable,应用层可调用自愈 API 恢复一致性。</p>
<pre><span class="c">// 自愈 — 校验并清理损坏 SSTable / 重建二级索引 / 截断 WAL</span>
<span class="k">await</span> db.<span class="f">repair</span>();
<span class="c">// 清空全部数据与表结构(保留库本身,实例可继续使用)</span>
<span class="k">await</span> db.<span class="f">clearAll</span>();
<span class="c">// 引擎级元数据(迁移版本等)</span>
<span class="c">// 引擎接口 IStorageEngine 可选扩展:repair() / clearAll() / getMeta() / setMeta()</span></pre>
<h2 id="export">📤 导入导出</h2>
<pre><span class="c">// 导出单表 — 返回 JSON 数组</span>
<span class="k">const</span> userData = <span class="k">await</span> db.<span class="f">exportTable</span>(<span class="s">'users'</span>);
<span class="c">// [{ id: '1', name: 'Alice', ... }, ...]</span>
<span class="c">// 导出全库 — 返回 { tableName: rows[] }</span>
<span class="k">const</span> allData = <span class="k">await</span> db.<span class="f">exportAll</span>();
<span class="c">// { users: [...], orders: [...], products: [...] }</span>
<span class="c">// 导入数据 — 返回主键列表</span>
<span class="k">const</span> pks = <span class="k">await</span> db.<span class="f">importTable</span>(<span class="s">'users'</span>, userData);</pre>
<h2 id="plugin">🧩 插件 & 钩子</h2>
<p>14 种生命周期钩子,支持插件机制。</p>
<table>
<tr><th>钩子名称</th><th>触发时机</th><th>参数</th></tr>
<tr><td><code>beforeCreateTable</code></td><td>创建表前</td><td>schema</td></tr>
<tr><td><code>afterCreateTable</code></td><td>创建表后</td><td>schema</td></tr>
<tr><td><code>beforeDropTable</code></td><td>删除表前</td><td>tableName</td></tr>
<tr><td><code>afterDropTable</code></td><td>删除表后</td><td>tableName</td></tr>
<tr><td><code>beforeInsert</code></td><td>插入前</td><td>rows[]</td></tr>
<tr><td><code>afterInsert</code></td><td>插入后</td><td>rows[]</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> &lt;div&gt;Loading...&lt;/div&gt;;
<span class="k">if</span> (error) <span class="k">return</span> &lt;div&gt;Error: {error.message}&lt;/div&gt;;
<span class="k">return</span> (
&lt;div&gt;
{data.<span class="f">map</span>(u =&gt; &lt;div key={u.id}&gt;{u.name} ({u.age})&lt;/div&gt;)}
&lt;button onClick={refresh}&gt;刷新&lt;/button&gt;
&lt;/div&gt;
);
}
<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> &lt;div&gt;Initializing...&lt;/div&gt;;
<span class="k">return</span> &lt;UserList /&gt;;
}</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>&lt;<span class="t">User</span>&gt;(<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.2 生产就绪与崩溃自愈</strong> — 残缺 SSTable 打开自动跳过(不删库)· WAL 记录与计数原子写入 + 按 key 扫描恢复 · 事务进行中 checkpoint 不截断 WAL · 二级索引跨重启自动恢复 · ALTER TABLE / 事务内 DDL 全引擎持久化 · ON UPDATE 外键级联(含更新主键)· OPFS schema 持久化(空表/索引完整保留)· `repair()` / `clearAll()` 统一自愈接口 · 迁移版本持久化到库内。<br>
<strong>v0.4.3 关闭时序与后台任务加固</strong> — 后台 flush/compaction 不再使用 setTimeout 延迟(close 排空全部任务后才关闭存储,杜绝"backend 关闭后写存储/重开污染")· 后台失败在 `flush()`/`close()` 显式报告(`ARIA_BACKGROUND_ERROR`,不静默吞错)· 预加载等待链稳定(修复 compaction 竞态跳块丢数据)· 事务提交先落 WAL 再合并快照(崩溃一致)· OPFS 写操作串行队列 + close 等待。<br>
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2"SSTC")与 v1"SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持 · 958 测试 52 套件。</p>
<h3>存储模式对比</h3>
<table>
<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/OSSTableReader 二分查找统一 ✅ 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>
+213 -112
View File
@@ -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>
@@ -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.1.2</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.1.2 · 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');}
} }
// === 图标网格 === // === 图标网格 ===
+350 -344
View File
@@ -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.1.2 完整 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 = loadingduration 强制 0(不自动关闭)closeButton 和 showProgress 强制为 false。返回 LoadingControl 对象</p> <p>type = loadingduration 强制 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&lt;boolean&gt;</code>内置 10 秒安全超时,超时自动 resolve(false)。</p> <p>确认对话框返回 <code>Promise&lt;boolean&gt;</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&lt;string|null&gt;</code>Enter 或点击提交按钮返回输入值,取消返回 null。内置 10 秒安全超时。</p> <p>输入对话框返回 <code>Promise&lt;string|null&gt;</code>。Enter / 提交返回输入值,取消返回 null10 秒超时。</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 的 typepassword/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 后转 success1s 后自动关闭</td></tr>
<tr><td>opts.progressColor</td><td>string</td><td>"#3b82f6"</td><td>进度条填充颜色</td></tr> <tr><td>error(message?)</td><td>转 error2s 后自动关闭</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 toast1s 后自动关闭</td></tr>
<tr><td>error</td><td>(message?: string)</td><td>替换为 error toast2s 后自动关闭</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>顺序逐个显示消息队列。前一条关闭后延时显示下一条。返回 QueueControlthenable + 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&lt;string|object&gt;</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> },
<span class="s">'步骤三'</span>,
], { <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> q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
<span class="kw">await</span> q; <span class="cm">// 等待完成thenable 支持 await</span></pre> <span class="kw">await</span> q; <span class="cm">// 等待完成</span></pre></div>
<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&lt;string|object&gt;</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 默认 0closeButton 默认 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>按钮内联样式,可覆盖 colorstyle.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>
<tr><td>position</td><td>string</td><td>可选。位置如 'top-right',不传则清除全部</td></tr>
</table>
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span> <pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre> MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre></div>
<h2 id="updatePosition">updatePosition(position) <span class="badge badge-v5">v0.3</span></h2>
<p>运行时将 Toast 移动到新位置容器,立即生效。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">position.js</span></div>
<pre><span class="kw">const</span> t = MeToast.<span class="fn">info</span>(<span class="s">'可移动的 Toast'</span>);
t.<span class="fn">updatePosition</span>(<span class="s">'bottom-left'</span>);</pre></div>
<h2 id="remove">remove() / removeToast(id) <span class="badge badge-new">立即移除</span></h2>
<p>立即从 DOM 和内存移除,<b>不触发离场动画</b>。适用于无动画快速清除。</p>
<div class="terminal"><div class="terminal-bar"><span class="t-dot"></span><span class="t-dot"></span><span class="t-dot"></span><span class="t-title">remove.js</span></div>
<pre>t.<span class="fn">remove</span>(); <span class="cm">// 实例方法</span>
MeToast.<span class="fn">removeToast</span>(t.id); <span class="cm">// 按 id(等价)</span></pre></div>
<h2 id="configure">configure(opts)</h2> <h2 id="configure">configure(opts)</h2>
<p>全局配置,影响后续所有 Toast。themelocale 变化触发相应副作用(应用主题 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) =&gt; {
<span class="kw">if</span> (toast.type === <span class="s">'error'</span> &amp;&amp; !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> () =&gt; {
<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> &lt;button onClick={submit}&gt;提交&lt;/button&gt;;
}
<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 ? &lt;<span class="fn">Toast</span> type=<span class="s">"info"</span> message=<span class="s">"正在保存..."</span> /&gt; : 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) =&gt; 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) =&gt; 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) =&gt; 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) =&gt; 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) =&gt; void</td><td>update() 后</td></tr>
<tr><td>onError</td><td>({ hook, source, error, toast }) =&gt; 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>
+6 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.4.3", "version": "0.6.1",
"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,21 +28,13 @@
"author": "MarkLite", "author": "MarkLite",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@metona-team/metona-editor": "0.1.14", "@metona-team/metona-editor": "0.4.1",
"@metona-team/metona-toast": "^0.1.2", "@metona-team/metona-sqlark": "0.4.4",
"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",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
@@ -59,6 +51,7 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"fake-indexeddb": "^6.2.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"lint-staged": "^17.0.7", "lint-staged": "^17.0.7",
+15 -8
View File
@@ -10,26 +10,29 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
try { try {
const fileStat = await stat(filePath) const fileStat = await stat(filePath)
if (fileStat.size > MAX_FILE_SIZE) { if (fileStat.size > MAX_FILE_SIZE) {
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } return {
success: false,
error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件`,
}
} }
// L-01: 检测并剥离 BOMUTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF // L-01: 检测并剥离 BOMUTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF
const buffer = await readFile(filePath) const buffer = await readFile(filePath)
let content: string let content: string
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) { if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
// UTF-16 BE: swap bytes to LE 再解码 // UTF-16 BE: swap bytes to LE 再解码
const swapped = Buffer.allocUnsafe(buffer.length) const swapped = Buffer.allocUnsafe(buffer.length)
buffer.copy(swapped) buffer.copy(swapped)
swapped.swap16() swapped.swap16()
content = swapped.toString('utf-16le') content = swapped.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) { } else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
// UTF-16 LE // UTF-16 LE
content = buffer.toString('utf-16le') content = buffer.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else { } else {
// UTF-8 (含 FEFF BOM 剥离) // UTF-8 (含 FEFF BOM 剥离)
content = buffer.toString('utf-8') content = buffer.toString('utf-8')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} }
return { success: true, content } return { success: true, content }
} catch (err) { } catch (err) {
@@ -48,7 +51,11 @@ export async function saveFileContent(filePath: string, content: string): Promis
return { success: true, filePath } return { success: true, filePath }
} catch (err) { } catch (err) {
// 清理残留临时文件 // 清理残留临时文件
try { await unlink(tmpFile) } catch { /* ignore */ } try {
await unlink(tmpFile)
} catch {
/* ignore */
}
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
} }
@@ -58,7 +65,7 @@ export async function buildDirTree(
dirPath: string, dirPath: string,
depth = 0, depth = 0,
maxDepth = 10, maxDepth = 10,
visited?: Set<string> visited?: Set<string>,
): Promise<FileNode[]> { ): Promise<FileNode[]> {
if (depth > maxDepth) return [] if (depth > maxDepth) return []
+1 -1
View File
@@ -15,7 +15,7 @@ export class FileWatcher {
if (!filePath) return if (!filePath) return
try { try {
this.currentPath = filePath this.currentPath = filePath
this.watcher = fs.watch(filePath, (eventType) => { this.watcher = fs.watch(filePath, eventType => {
if (eventType === 'change') { if (eventType === 'change') {
if (this.isSelfWriting) return if (this.isSelfWriting) return
const win = this.getMainWindow() const win = this.getMainWindow()
+7 -6
View File
@@ -10,7 +10,7 @@ const state = {
activeFilePath: null as string | null, activeFilePath: null as string | null,
pendingFilePath: null as string | null, pendingFilePath: null as string | null,
isClosing: false, isClosing: false,
closeTimeout: null as NodeJS.Timeout | null closeTimeout: null as NodeJS.Timeout | null,
} }
const fileWatcher = new FileWatcher(() => mainWindow) const fileWatcher = new FileWatcher(() => mainWindow)
@@ -18,20 +18,22 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow)
function openFileInTab(filePath: string): void { function openFileInTab(filePath: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return if (!mainWindow || mainWindow.isDestroyed()) return
readFileContent(filePath).then(result => { readFileContent(filePath)
.then(result => {
if (result.success && mainWindow && !mainWindow.isDestroyed()) { if (result.success && mainWindow && !mainWindow.isDestroyed()) {
state.activeFilePath = filePath state.activeFilePath = filePath
fileWatcher.start(filePath) fileWatcher.start(filePath)
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
} }
}).catch((err) => { })
.catch(err => {
// eslint-disable-next-line no-console -- IPC file open error // eslint-disable-next-line no-console -- IPC file open error
console.error('openFileInTab failed:', err) console.error('openFileInTab failed:', err)
}) })
} }
const lockOk = setupSingleInstanceLock((filePath) => { const lockOk = setupSingleInstanceLock(filePath => {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus() mainWindow.focus()
@@ -42,10 +44,9 @@ const lockOk = setupSingleInstanceLock((filePath) => {
if (!lockOk) { if (!lockOk) {
app.quit() app.quit()
} else { } else {
function setupCloseHandler(): void { function setupCloseHandler(): void {
if (!mainWindow) return if (!mainWindow) return
mainWindow.on('close', (e) => { mainWindow.on('close', e => {
if (state.isClosing) return if (state.isClosing) return
state.isClosing = true state.isClosing = true
e.preventDefault() e.preventDefault()
+69 -13
View File
@@ -2,7 +2,7 @@ import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electro
import { readFileContent, saveFileContent, buildDirTree } from './file-system' import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher' import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises' import { stat, readFile, writeFile } from 'fs/promises'
import { basename, isAbsolute } from 'path' import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击 // 安全校验:拒绝路径遍历攻击
@@ -24,7 +24,12 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null, getMainWindow: () => BrowserWindow | null,
fileWatcher: FileWatcher, fileWatcher: FileWatcher,
sidebarWatcher: SidebarWatcher, sidebarWatcher: SidebarWatcher,
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null } state: {
activeFilePath: string | null
pendingFilePath: string | null
isClosing: boolean
closeTimeout: NodeJS.Timeout | null
},
): void { ): void {
// 打开文件对话框 // 打开文件对话框
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
@@ -33,7 +38,7 @@ export function registerIpcHandlers(
try { try {
const result = await dialog.showOpenDialog(win, { const result = await dialog.showOpenDialog(win, {
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }],
}) })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0] const filePath = result.filePaths[0]
@@ -61,7 +66,9 @@ export function registerIpcHandlers(
}) })
// 保存文件 // 保存文件
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { ipcMain.handle(
IPC_CHANNELS.FILE_SAVE,
async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
if (data.filePath && !validatePath(data.filePath)) { if (data.filePath && !validatePath(data.filePath)) {
return { success: false, error: '无效的文件路径' } return { success: false, error: '无效的文件路径' }
} }
@@ -85,7 +92,7 @@ export function registerIpcHandlers(
} else { } else {
if (!win) return { success: false, error: '窗口不可用' } if (!win) return { success: false, error: '窗口不可用' }
const saveResult = await dialog.showSaveDialog(win, { const saveResult = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
}) })
if (!saveResult.canceled) { if (!saveResult.canceled) {
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
@@ -107,15 +114,18 @@ export function registerIpcHandlers(
} }
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 另存为 // 另存为
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { ipcMain.handle(
IPC_CHANNELS.FILE_SAVE_AS,
async (_event: IpcMainInvokeEvent, data: { content: string }) => {
const win = getMainWindow() const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' } if (!win) return { success: false, error: '窗口不可用' }
try { try {
const result = await dialog.showSaveDialog(win, { const result = await dialog.showSaveDialog(win, {
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }],
}) })
if (!result.canceled) { if (!result.canceled) {
fileWatcher.setSelfWriting(true) fileWatcher.setSelfWriting(true)
@@ -133,7 +143,8 @@ export function registerIpcHandlers(
fileWatcher.setSelfWriting(false) fileWatcher.setSelfWriting(false)
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 获取当前路径 // 获取当前路径
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
@@ -159,7 +170,9 @@ export function registerIpcHandlers(
}) })
// 目录树 // 目录树
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { ipcMain.handle(
IPC_CHANNELS.DIR_READ_TREE,
async (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) { if (!validatePath(dirPath)) {
return { success: false, error: '无效的目录路径' } return { success: false, error: '无效的目录路径' }
} }
@@ -169,7 +182,8 @@ export function registerIpcHandlers(
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message } return { success: false, error: (err as Error).message }
} }
}) },
)
// 打开文件夹对话框 // 打开文件夹对话框
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
@@ -193,7 +207,9 @@ export function registerIpcHandlers(
}) })
// 标签切换 // 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { ipcMain.handle(
IPC_CHANNELS.TAB_SWITCHED,
(_event: IpcMainInvokeEvent, filePath: string | null) => {
const normalizedPath = filePath || null const normalizedPath = filePath || null
if (normalizedPath && !validatePath(normalizedPath)) { if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop() fileWatcher.stop()
@@ -205,7 +221,8 @@ export function registerIpcHandlers(
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite') win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : 'MarkLite')
} }
}) },
)
// 窗口控制 // 窗口控制
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
@@ -225,4 +242,43 @@ export function registerIpcHandlers(
state.closeTimeout = null state.closeTimeout = null
} }
}) })
// v0.6.0: 数据备份导出 — 保存对话框 + 写 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_EXPORT, async (_event: IpcMainInvokeEvent, content: string) => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const dateStr = new Date().toISOString().slice(0, 10)
const result = await dialog.showSaveDialog(win, {
title: '导出数据备份',
defaultPath: `marklite-backup-${dateStr}.json`,
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled) return { success: false, canceled: true }
await writeFile(result.filePath, content, 'utf8')
return { success: true, filePath: result.filePath }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// v0.6.0: 数据备份导入 — 打开对话框 + 读 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_IMPORT, async () => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const result = await dialog.showOpenDialog(win, {
title: '导入数据备份',
properties: ['openFile'],
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
const content = await readFile(result.filePaths[0], 'utf8')
return { success: true, content }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
} }
+4 -4
View File
@@ -13,16 +13,16 @@ export function createWindow(): BrowserWindow {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true sandbox: true,
}, },
titleBarStyle: 'default', titleBarStyle: 'default',
show: false show: false,
}) })
mainWindow.setMenu(null) mainWindow.setMenu(null)
// H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP // H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP
mainWindow.webContents.on('will-navigate', (event) => { mainWindow.webContents.on('will-navigate', event => {
event.preventDefault() event.preventDefault()
}) })
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
@@ -35,7 +35,7 @@ export function createWindow(): BrowserWindow {
} }
export function setupSingleInstanceLock( export function setupSingleInstanceLock(
onSecondInstance: (filePath: string | null) => void onSecondInstance: (filePath: string | null) => void,
): boolean { ): boolean {
const gotTheLock = app.requestSingleInstanceLock() const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) { if (!gotTheLock) {
+26 -11
View File
@@ -7,8 +7,8 @@ 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),
@@ -38,27 +38,42 @@ const api: ElectronAPI = {
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),
// v0.6.0: 数据备份导出/导入
exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
importData: () => ipcRenderer.invoke(IPC_CHANNELS.DATA_IMPORT),
// 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)
+33 -17
View File
@@ -11,7 +11,8 @@ import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop' import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave' import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners' import { useIpcListeners } from './hooks/useIpcListeners'
import { useConfirm } from './hooks/useConfirm' import { MeToast } from './lib/toast'
import { closeDatabase } from './db/schema'
import { Toolbar } from './components/Toolbar/Toolbar' import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar' import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor' import { Editor } from './components/Editor/Editor'
@@ -21,7 +22,6 @@ import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay' import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog' import { AboutDialog } from './components/AboutDialog'
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
export function App() { export function App() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
@@ -34,12 +34,13 @@ export function App() {
const externallyModified = useEditorStore(s => s.externallyModified) const externallyModified = useEditorStore(s => s.externallyModified)
const setExternallyModified = useEditorStore(s => s.setExternallyModified) const setExternallyModified = useEditorStore(s => s.setExternallyModified)
const { themeMode, cycleTheme } = useTheme() const { themeMode, cycleTheme } = useTheme()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false) const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), []) const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit() useSettingsInit()
useEffect(() => { loadFromDB() }, [loadFromDB]) useEffect(() => {
loadFromDB()
}, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations() const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave() const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
@@ -48,17 +49,22 @@ export function App() {
// useAutoSave() already called above to get state for toolbar // useAutoSave() already called above to get state for toolbar
// UX-01: 传入 confirm 函数替代原生 confirm() // UX-01: 传入 confirm 函数替代原生 confirm()
// v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false)
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => { const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return confirm({ return MeToast.confirm(message, {
title: '未保存的更改', title: '未保存的更改',
message, confirmText: '不保存',
variant: 'warning', cancelText: '取消',
confirmLabel: '不保存',
cancelLabel: '取消'
}) })
}, [confirm]) }, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB) // v0.6.0: 关闭前先保存标签快照,再干净关闭数据库(避免 flush 中断导致下次启动损坏)
const handleBeforeForceClose = useCallback(async () => {
await flushSaveToDB()
await closeDatabase()
}, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
useKeyboard(handleOpenFile, handleSave, handleSaveAs) useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners() useIpcListeners()
@@ -85,8 +91,10 @@ export function App() {
<ErrorBoundary> <ErrorBoundary>
<div id="app" className={`mode-${viewMode}`}> <div id="app" className={`mode-${viewMode}`}>
<Toolbar <Toolbar
onOpen={handleOpenFile} onSave={handleSave} onOpen={handleOpenFile}
themeMode={themeMode} onCycleTheme={cycleTheme} onSave={handleSave}
themeMode={themeMode}
onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)} onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving} isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled} autoSaveEnabled={autoSaveEnabled}
@@ -97,20 +105,28 @@ export function App() {
<div id="main-content"> <div id="main-content">
<TabBar /> <TabBar />
{externallyModified && ( {externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} /> <ModifiedBanner
onReload={handleReloadModified}
onDismiss={() => setExternallyModified(null)}
/>
)} )}
{tabs.length > 0 ? ( {tabs.length > 0 ? (
<div id="content-wrapper"> <div id="content-wrapper">
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div> <div id="editor-panel">
<Editor themeMode={themeMode} onAppSave={handleSave} />
</div>
</div> </div>
) : ( ) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} /> <WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/>
)} )}
</div> </div>
</div> </div>
<DropOverlay /> <DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />} {showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
) )
@@ -17,7 +17,13 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
} }
return ( return (
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite"> <div
className="about-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="关于 MarkLite"
>
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}> <div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="about-header"> <div className="about-header">
<AppIcon size={64} /> <AppIcon size={64} />
@@ -35,6 +41,8 @@ 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> <span></span>
</div> </div>
</div> </div>
@@ -44,9 +52,12 @@ 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.1 · MetonaToast 0.5.0 · MetonaSqlark 0.4.4</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>
</div> </div>
</div> </div>
) )
@@ -1,111 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
onConfirm: () => void
onCancel: () => void
}
export const ConfirmDialog = React.memo(function ConfirmDialog({
open,
title,
message,
confirmLabel = '确定',
cancelLabel = '取消',
variant = 'warning',
onConfirm,
onCancel
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => {
if (!open) {
// Only restore focus if the element is still in the DOM
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
previousFocusRef.current.focus()
}
return
}
previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => {
clearTimeout(timer)
}
}, [open])
// ESC 键关闭
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}, [onCancel])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
// 防止背景滚动
useEffect(() => {
if (!open) return
const original = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = original }
}, [open])
if (!open) return null
return (
<div
className="confirm-overlay"
onClick={onCancel}
role="presentation"
>
<div
className="confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
onClick={e => e.stopPropagation()}
>
<div className={`confirm-header confirm-${variant}`}>
<h3 id="confirm-title">{title}</h3>
</div>
<div className="confirm-body">
<p id="confirm-message">{message}</p>
</div>
<div className="confirm-actions">
<button
className="confirm-btn confirm-btn-cancel"
onClick={onCancel}
type="button"
>
{cancelLabel}
</button>
<button
ref={confirmRef}
className={`confirm-btn confirm-btn-${variant}`}
onClick={onConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
})
ConfirmDialog.displayName = 'ConfirmDialog'
@@ -1 +0,0 @@
export { ConfirmDialog } from './ConfirmDialog'
+93 -18
View File
@@ -1,17 +1,19 @@
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, MarkdownEditorOptions, EditMode, ThemeName } 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.1.9 demo 验证过但类型声明未收录的配置项 // v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
interface ExtendedEditorOptions extends MarkdownEditorOptions { type EditMode = 'edit' | 'split' | 'preview'
lineNumbers?: boolean type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
autoBrackets?: boolean
} // v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps { interface EditorProps {
themeMode: ThemeMode themeMode: ThemeMode
@@ -37,19 +39,24 @@ const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H 'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片 'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML 'exportTool', // 导出 MD/HTML
'shortcutHelp', // 按 ? 弹出快捷键面板
] ]
/** /**
* 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)
const viewMode = useEditorStore(s => s.viewMode) const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode) const setViewMode = useEditorStore(s => s.setViewMode)
const setZenMode = useEditorStore(s => s.setZenMode)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null) const editorRef = useRef<MarkdownEditor | null>(null)
@@ -64,17 +71,39 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
const container = containerRef.current const container = containerRef.current
if (!container) return if (!container) return
const config: ExtendedEditorOptions = { const config = {
value: activeTab?.content ?? '', value: activeTab?.content ?? '',
mode: mapViewMode(viewMode), mode: mapViewMode(viewMode),
height: '100%', height: '100%',
toolbar: [ toolbar: [
'bold', 'italic', 'strikethrough', 'underline', 'code', '|', 'bold',
'h1', 'h2', 'h3', '|', 'italic',
'quote', 'ul', 'ol', 'indent', 'outdent', '|', 'strikethrough',
'link', 'image', 'table', 'hr', '|', 'underline',
'undo', 'redo', '|', 'code',
'edit', 'split', 'preview', 'fullscreen' '|',
'h1',
'h2',
'h3',
'|',
'quote',
'ul',
'ol',
'indent',
'outdent',
'|',
'link',
'image',
'table',
'hr',
'|',
'undo',
'redo',
'|',
'edit',
'split',
'preview',
'fullscreen',
], ],
locale: 'zh-CN', locale: 'zh-CN',
theme: themeMode as ThemeName, theme: themeMode as ThemeName,
@@ -87,8 +116,20 @@ 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.6.1: 专注模式宽度使用 0.4.1 官方 API — 100% 全宽(默认 960px 偏窄)
zenMaxWidth: '100%',
// v0.2.4 新增配置项
syncScroll: true,
wordWrap: true,
outline: false, // 使用自研 OutlinePanel
historyLimit: 100,
historyDebounce: 400,
// 使用 unified 管线渲染,保留图片路径修复能力 // 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
// v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
highlight: MeEditor.highlight,
render: (md: string) => { render: (md: string) => {
const tabId = activeTabIdRef.current const tabId = activeTabIdRef.current
const filePath = tabId const filePath = tabId
@@ -109,8 +150,8 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
}, },
// 模式切换 → 同步到 editorStore 并持久化 // 模式切换 → 同步到 editorStore 并持久化
onModeChange: (mode: EditMode) => { onModeChange: (mode: string) => {
const mapped = reverseMapMode(mode) const mapped = reverseMapMode(mode as EditMode)
setViewMode(mapped) setViewMode(mapped)
settingsRepository.save({ viewMode: mapped }) settingsRepository.save({ viewMode: mapped })
}, },
@@ -118,7 +159,22 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统) // Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
onSave: () => { onSave: () => {
onAppSave?.() onAppSave?.()
},
// v0.2.4 新增回调: 链接点击 → 安全打开外部链接
onLinkClick: (href: string) => {
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
} }
},
// v0.2.4 新增回调: 焦点事件(预留扩展点)
onFocus: () => {
// 编辑器获得焦点
},
onBlur: () => {
// 编辑器失去焦点
},
} }
const editor = MeEditor.create(container, config) const editor = MeEditor.create(container, config)
@@ -127,6 +183,25 @@ 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)
// v0.6.0: Zen 状态同步 → editorStoreToolbar 消费)
// 文档统计由编辑器自带底栏展示(wordCount: true),无需应用层同步
const syncZen = (zen: boolean) => {
setZenMode(Boolean(zen))
}
editor.on('zenChange', syncZen)
return () => { return () => {
editor.destroy() editor.destroy()
editorRef.current = null editorRef.current = null
@@ -43,11 +43,7 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="error-boundary-root" role="alert"> <div className="error-boundary-root" role="alert">
<h2 className="error-boundary-title"></h2> <h2 className="error-boundary-title"></h2>
{isDev && ( {isDev && <pre className="error-boundary-detail">{this.state.error?.message}</pre>}
<pre className="error-boundary-detail">
{this.state.error?.message}
</pre>
)}
<button className="error-boundary-reset" onClick={this.handleReset}> <button className="error-boundary-reset" onClick={this.handleReset}>
</button> </button>
@@ -18,7 +18,7 @@ export const FileTree = React.memo(function FileTree({
expandedDirs, expandedDirs,
toggleDir, toggleDir,
activeFilePath, activeFilePath,
onFileClick onFileClick,
}: FileTreeProps) { }: FileTreeProps) {
return ( return (
<> <>
@@ -30,7 +30,7 @@ export const FileTree = React.memo(function FileTree({
<React.Fragment key={node.path}> <React.Fragment key={node.path}>
<div <div
className={`tree-item ${isActive ? 'active' : ''}`} className={`tree-item ${isActive ? 'active' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }} style={{ paddingLeft: 8 + depth * 16 + 'px' }}
role="treeitem" role="treeitem"
aria-expanded={node.type === 'dir' ? isExpanded : undefined} aria-expanded={node.type === 'dir' ? isExpanded : undefined}
aria-selected={isActive} aria-selected={isActive}
@@ -43,7 +43,7 @@ export const FileTree = React.memo(function FileTree({
onFileClick(node.path) onFileClick(node.path)
} }
}} }}
onKeyDown={(e) => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
if (node.type === 'dir') { if (node.type === 'dir') {
+225 -64
View File
@@ -12,54 +12,88 @@ 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
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/> width={size}
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/> height={size}
<path d="M12 11h4" strokeDasharray="2 2"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1" />
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none" />
<path d="M12 11h4" strokeDasharray="2 2" />
</svg> </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
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> width={size}
<polyline points="17 21 17 13 7 13 7 21"/> height={size}
<polyline points="7 3 7 8 15 8"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg> </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
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> width={size}
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5" />
</svg> </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
<circle cx="12" cy="12" r="5"/> width={size}
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/> height={size}
<line x1="12" y1="1" x2="12" y2="3"/> viewBox="0 0 24 24"
<line x1="12" y1="21" x2="12" y2="23"/> fill="none"
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> stroke="currentColor"
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> strokeWidth="1.75"
<line x1="1" y1="12" x2="3" y2="12"/> strokeLinecap="round"
<line x1="21" y1="12" x2="23" y2="12"/> strokeLinejoin="round"
<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"/> <circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg> </svg>
) )
} }
@@ -68,17 +102,26 @@ export function Sun({ size = defaultProps.size }: IconProps) {
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
<circle cx="12" cy="12" r="10"/> width={size}
<line x1="12" y1="16" x2="12" y2="12"/> height={size}
<line x1="12" y1="8" x2="12.01" y2="8"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg> </svg>
) )
} }
@@ -86,18 +129,34 @@ export function Info({ size = defaultProps.size }: IconProps) {
// ===== 标签栏图标 ===== // ===== 标签栏图标 =====
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
<line x1="18" y1="6" x2="6" y2="18"/> width={size}
<line x1="6" y1="6" x2="18" y2="18"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg> </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
<line x1="12" y1="5" x2="12" y2="19"/> width={size}
<line x1="5" y1="12" x2="19" y2="12"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
) )
} }
@@ -105,38 +164,74 @@ export function Plus({ size = 14 }: IconProps) {
// ===== 侧边栏图标 ===== // ===== 侧边栏图标 =====
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
<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"/> width={size}
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/> height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4" />
</svg> </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
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> width={size}
<polyline points="14 2 14 8 20 8"/> height={size}
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/> viewBox="0 0 24 24"
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/> fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4" />
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3" />
</svg> </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
<polyline points="9 18 15 12 9 6"/> width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg> </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
<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"/> width={size}
<line x1="12" y1="11" x2="12" y2="17"/> height={size}
<line x1="9" y1="14" x2="15" y2="14"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg> </svg>
) )
} }
@@ -144,11 +239,59 @@ export function FolderPlus({ size = 14 }: IconProps) {
// ===== 拖拽覆盖层图标 ===== // ===== 拖拽覆盖层图标 =====
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
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/> width={size}
<polyline points="12 16 12 8"/> height={size}
<polyline points="8 12 12 8 16 12"/> viewBox="0 0 24 24"
<line x1="12" y1="16" x2="12" y2="22"/> fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<polyline points="12 16 12 8" />
<polyline points="8 12 12 8 16 12" />
<line x1="12" y1="16" x2="12" y2="22" />
</svg>
)
}
// ===== v0.6.0: 数据备份图标 =====
export function Download({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
export function Upload({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg> </svg>
) )
} }
@@ -156,21 +299,39 @@ export function UploadCloud({ size = 64 }: IconProps) {
// ===== 欢迎屏幕图标 ===== // ===== 欢迎屏幕图标 =====
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
<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"/> width={size}
<line x1="12" y1="11" x2="12" y2="17"/> height={size}
<line x1="9" y1="14" x2="15" y2="14"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg> </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
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> width={size}
<polyline points="14 2 14 8 20 8"/> height={size}
<line x1="12" y1="18" x2="12" y2="12"/> viewBox="0 0 24 24"
<line x1="9" y1="15" x2="15" y2="15"/> fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg> </svg>
) )
} }
@@ -1,70 +0,0 @@
import React from 'react'
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large'
label?: string
/** 是否全屏覆盖 */
overlay?: boolean
}
const sizeMap = {
small: 16,
medium: 24,
large: 36
}
/**
* UX-02: 通用加载指示器组件
*/
export const LoadingSpinner = React.memo(function LoadingSpinner({
size = 'medium',
label,
overlay = false
}: LoadingSpinnerProps) {
const px = sizeMap[size]
const spinner = (
<div
className={`loading-spinner loading-spinner-${size}`}
role="status"
aria-label={label || '加载中'}
>
<svg
className="loading-spinner-svg"
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
opacity="0.2"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{label && <span className="loading-spinner-label">{label}</span>}
</div>
)
if (!overlay) return spinner
return (
<div className="loading-overlay" aria-busy="true">
{spinner}
</div>
)
})
LoadingSpinner.displayName = 'LoadingSpinner'
@@ -1 +0,0 @@
export { LoadingSpinner } from './LoadingSpinner'
@@ -5,12 +5,19 @@ interface ModifiedBannerProps {
onDismiss: () => void onDismiss: () => void
} }
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) { export const ModifiedBanner = React.memo(function ModifiedBanner({
onReload,
onDismiss,
}: ModifiedBannerProps) {
return ( return (
<div id="modified-banner" role="alert" aria-live="assertive"> <div id="modified-banner" role="alert" aria-live="assertive">
<span></span> <span></span>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件"></button> <button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改"></button>
</button>
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">
</button>
</div> </div>
) )
}) })
@@ -20,7 +20,7 @@ const OutlineItem = memo(function OutlineItem({
heading, heading,
index, index,
isActive, isActive,
onNavigate onNavigate,
}: OutlineItemProps) { }: OutlineItemProps) {
return ( return (
<button <button
@@ -39,7 +39,7 @@ const OutlineItem = memo(function OutlineItem({
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 (
+53 -34
View File
@@ -58,10 +58,11 @@ export const Sidebar = React.memo(function Sidebar() {
const activeHeadingIndex = useActiveHeading( const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null }, viewMode === 'preview' ? previewRef : { current: null },
headings headings,
) )
// Navigate to heading in MetonaEditor // Navigate to heading in MetonaEditor
// v0.6.0: 使用官方 APIscrollToLine + setCursorPosition)替代 DOM hack
const handleHeadingNavigate = useCallback((heading: Heading) => { const handleHeadingNavigate = useCallback((heading: Heading) => {
const editor = getMetonaEditor() const editor = getMetonaEditor()
if (!editor) return if (!editor) return
@@ -69,38 +70,27 @@ export const Sidebar = React.memo(function Sidebar() {
try { try {
// 获取当前内容,查找标题文本在源代码中的位置 // 获取当前内容,查找标题文本在源代码中的位置
const content = editor.getValue() const content = editor.getValue()
const headingPattern = new RegExp( const headingPattern = new RegExp(`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm')
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
'm'
)
const match = headingPattern.exec(content) const match = headingPattern.exec(content)
if (!match) return if (!match) return
const pos = match.index // 按行号导航 — 官方 API 处理滚动与光标
const line = content.substring(0, match.index).split('\n').length
// 通过 DOM 操作滚动 textarea 到对应位置 editor.scrollToLine(line)
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null editor.setCursorPosition(line, 0)
if (!container) return editor.focus()
const textarea = container.querySelector('textarea')
if (!textarea) return
// 估算滚动位置(简单方法:按行数比例)
const linesBefore = content.substring(0, pos).split('\n').length
const lineHeight = 24 // 估算行高
textarea.scrollTop = linesBefore * lineHeight
// 设置光标位置
textarea.focus()
textarea.setSelectionRange(pos, pos)
} catch { } catch {
// 导航失败,静默忽略 // 导航失败,静默忽略
} }
}, []) }, [])
const handleFileClick = useCallback(async (path: string) => { const handleFileClick = useCallback(
async (path: string) => {
const existing = tabs.find(t => t.filePath === path) const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return } if (existing) {
switchToTab(existing.id)
return
}
if (!window.electronAPI) return if (!window.electronAPI) return
setLoading('file-open', true) setLoading('file-open', true)
try { try {
@@ -112,7 +102,9 @@ export const Sidebar = React.memo(function Sidebar() {
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
}, [tabs, switchToTab, createTab, setLoading]) },
[tabs, switchToTab, createTab, setLoading],
)
const independentFiles = tabs.filter(t => { const independentFiles = tabs.filter(t => {
if (!t.filePath) return false if (!t.filePath) return false
@@ -138,9 +130,12 @@ export const Sidebar = React.memo(function Sidebar() {
<nav id="sidebar-tree" role="tree" aria-label="文件树"> <nav id="sidebar-tree" role="tree" aria-label="文件树">
{independentFiles.length > 0 && ( {independentFiles.length > 0 && (
<div className="independent-files-section" role="group" aria-label="已打开的文件"> <div className="independent-files-section" role="group" aria-label="已打开的文件">
<div className="independent-files-header" id="independent-files-label"></div> <div className="independent-files-header" id="independent-files-label">
</div>
{independentFiles.map(tab => ( {independentFiles.map(tab => (
<div key={tab.id} <div
key={tab.id}
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`} className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
style={{ paddingLeft: '8px' }} style={{ paddingLeft: '8px' }}
role="treeitem" role="treeitem"
@@ -148,22 +143,46 @@ export const Sidebar = React.memo(function Sidebar() {
aria-selected={tab.id === activeTabId} aria-selected={tab.id === activeTabId}
aria-label={getFileName(tab.filePath!)} aria-label={getFileName(tab.filePath!)}
onClick={() => switchToTab(tab.id)} onClick={() => switchToTab(tab.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }} onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
switchToTab(tab.id)
}
}}
> >
<span className="tree-icon"><File size={14} /></span> <span className="tree-icon">
<File size={14} />
</span>
<span className="tree-name">{getFileName(tab.filePath!)}</span> <span className="tree-name">{getFileName(tab.filePath!)}</span>
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> </span>} {tab.isModified && (
<span className="independent-modified-dot" aria-label="已修改">
{' '}
</span>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
{rootPath && ( {rootPath && (
<> <>
<div className="sidebar-section-header" id="folder-tree-label"></div> <div className="sidebar-section-header" id="folder-tree-label">
</div>
<FileTree <FileTree
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]} nodes={[
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir} {
activeFilePath={activeFilePath} onFileClick={handleFileClick} name: rootPath.split(/[/\\]/).pop() || rootPath,
path: rootPath,
type: 'dir' as const,
children: tree,
},
]}
depth={0}
expandedDirs={expandedDirs}
toggleDir={toggleDir}
activeFilePath={activeFilePath}
onFileClick={handleFileClick}
/> />
</> </>
)} )}
+47 -45
View File
@@ -1,9 +1,8 @@
import React, { useCallback, useState, useEffect, useRef } from 'react' import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { useConfirm } from '../../hooks/useConfirm'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { MeToast } from '../../lib/toast'
import { Close, Plus } from '../Icons' import { Close, Plus } from '../Icons'
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
interface ContextMenuState { interface ContextMenuState {
visible: boolean visible: boolean
@@ -27,7 +26,6 @@ export const TabBar = React.memo(function TabBar() {
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' }) const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null) const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null) const dragTabIdRef = useRef<string | null>(null)
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签 // 滚动到活动标签
const scrollToActiveTab = useCallback(() => { const scrollToActiveTab = useCallback(() => {
@@ -41,11 +39,11 @@ export const TabBar = React.memo(function TabBar() {
// 如果标签在可视区域左侧之外 // 如果标签在可视区域左侧之外
if (tabRect.left < listRect.left) { if (tabRect.left < listRect.left) {
tabList.scrollLeft -= (listRect.left - tabRect.left + 20) tabList.scrollLeft -= listRect.left - tabRect.left + 20
} }
// 如果标签在可视区域右侧之外 // 如果标签在可视区域右侧之外
else if (tabRect.right > listRect.right) { else if (tabRect.right > listRect.right) {
tabList.scrollLeft += (tabRect.right - listRect.right + 20) tabList.scrollLeft += tabRect.right - listRect.right + 20
} }
}, []) }, [])
@@ -76,21 +74,23 @@ export const TabBar = React.memo(function TabBar() {
return () => tabList.removeEventListener('wheel', handleWheel) return () => tabList.removeEventListener('wheel', handleWheel)
}, []) }, [])
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => { const handleClose = useCallback(
async (e: React.MouseEvent, tabId: string) => {
e.stopPropagation() e.stopPropagation()
const tab = tabs.find(t => t.id === tabId) const tab = tabs.find(t => t.id === tabId)
if (tab?.isModified) { if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名' const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签', title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTab(tabId) closeTab(tabId)
}, [tabs, closeTab, confirm]) },
[tabs, closeTab],
)
// C-06: 右键菜单(带边界修正) // C-06: 右键菜单(带边界修正)
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
@@ -114,67 +114,65 @@ export const TabBar = React.memo(function TabBar() {
const tab = tabs.find(t => t.id === menu.tabId) const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) { if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名' const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签', title: '关闭标签',
message: `"${name}" 尚未保存,确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTab(menu.tabId) closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab, confirm]) }, [tabs, menu.tabId, closeTab])
const handleMenuCloseOthers = useCallback(async () => { const handleMenuCloseOthers = useCallback(async () => {
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified) const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
if (otherModified.length > 0) { if (otherModified.length > 0) {
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = otherModified
const confirmed = await confirm({ .map(t => (t.filePath ? getFileName(t.filePath) : '未命名'))
.join('、')
const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭其他标签', title: '关闭其他标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeOtherTabs(menu.tabId) closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs, confirm]) }, [tabs, menu.tabId, closeOtherTabs])
const handleMenuCloseAll = useCallback(async () => { const handleMenuCloseAll = useCallback(async () => {
const modified = tabs.filter(t => t.isModified) const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) { if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭全部标签', title: '关闭全部标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeAllTabs() closeAllTabs()
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs, confirm]) }, [tabs, closeAllTabs])
const handleMenuCloseRight = useCallback(async () => { const handleMenuCloseRight = useCallback(async () => {
const index = tabs.findIndex(t => t.id === menu.tabId) const index = tabs.findIndex(t => t.id === menu.tabId)
const rightTabs = tabs.slice(index + 1) const rightTabs = tabs.slice(index + 1)
const modified = rightTabs.filter(t => t.isModified) const modified = rightTabs.filter(t => t.isModified)
if (modified.length > 0) { if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、') const names = modified.map(t => (t.filePath ? getFileName(t.filePath) : '未命名')).join('、')
const confirmed = await confirm({ const confirmed = await MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭右侧标签', title: '关闭右侧标签',
message: `以下文件尚未保存:${names},确定要关闭吗?`, confirmText: '关闭',
variant: 'warning', cancelText: '取消',
confirmLabel: '关闭'
}) })
if (!confirmed) return if (!confirmed) return
} }
closeTabsToRight(menu.tabId) closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false })) setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm]) }, [tabs, menu.tabId, closeTabsToRight])
// D1: 拖拽排序事件处理 // D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
@@ -198,7 +196,8 @@ export const TabBar = React.memo(function TabBar() {
setDragOverIndex(null) setDragOverIndex(null)
}, []) }, [])
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => { const handleDrop = useCallback(
(e: React.DragEvent, toIndex: number) => {
e.preventDefault() e.preventDefault()
setDragOverIndex(null) setDragOverIndex(null)
const fromId = dragTabIdRef.current const fromId = dragTabIdRef.current
@@ -208,7 +207,9 @@ export const TabBar = React.memo(function TabBar() {
dragTabIdRef.current = null dragTabIdRef.current = null
// 清理 dragging 类 // 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging')) document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, [moveTab]) },
[moveTab],
)
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDragOverIndex(null) setDragOverIndex(null)
@@ -216,7 +217,9 @@ export const TabBar = React.memo(function TabBar() {
dragTabIdRef.current = null dragTabIdRef.current = null
}, []) }, [])
const hasRightTabs = menu.visible && (() => { const hasRightTabs =
menu.visible &&
(() => {
const index = tabs.findIndex(t => t.id === menu.tabId) const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1 return index < tabs.length - 1
})() })()
@@ -237,11 +240,11 @@ export const TabBar = React.memo(function TabBar() {
data-tab-id={tab.id} data-tab-id={tab.id}
draggable draggable
onClick={() => switchToTab(tab.id)} onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)} onContextMenu={e => handleContextMenu(e, tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)} onDragStart={e => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={e => handleDragOver(e, index)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)} onDrop={e => handleDrop(e, index)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<span className="tab-name"> <span className="tab-name">
@@ -249,7 +252,7 @@ export const TabBar = React.memo(function TabBar() {
</span> </span>
<button <button
className="tab-close" className="tab-close"
onClick={(e) => handleClose(e, tab.id)} onClick={e => handleClose(e, tab.id)}
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`} aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
> >
<Close size={10} /> <Close size={10} />
@@ -273,8 +276,8 @@ export const TabBar = React.memo(function TabBar() {
style={{ left: menu.x, top: menu.y }} style={{ left: menu.x, top: menu.y }}
role="menu" role="menu"
aria-label="标签操作" aria-label="标签操作"
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
> >
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}> <div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
@@ -296,7 +299,6 @@ export const TabBar = React.memo(function TabBar() {
</div> </div>
)} )}
</div> </div>
<ConfirmDialog {...confirmDialogProps} />
</> </>
) )
}) })
+113 -11
View File
@@ -1,5 +1,9 @@
import React from 'react' import React, { useCallback } from 'react'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons' import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { backupRepository } from '../../db/backupRepository'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { ThemeMode } from '../../types/settings' import type { ThemeMode } from '../../types/settings'
interface ToolbarProps { interface ToolbarProps {
@@ -20,23 +24,83 @@ const THEME_LABELS: Record<ThemeMode, string> = {
} }
/** /**
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。 * 应用顶层工具栏 — 文件操作、自动保存、主题循环、数据备份、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。 * 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/ */
export const Toolbar = React.memo(function Toolbar({ export const Toolbar = React.memo(function Toolbar({
onOpen, onSave, themeMode, onCycleTheme, onShowAbout, onOpen,
isAutoSaving, autoSaveEnabled, onToggleAutoSave onSave,
themeMode,
onCycleTheme,
onShowAbout,
isAutoSaving,
autoSaveEnabled,
onToggleAutoSave,
}: ToolbarProps) { }: ToolbarProps) {
const nextLabel = THEME_LABELS[themeMode] ?? '主题' const nextLabel = THEME_LABELS[themeMode] ?? '主题'
const zenMode = useEditorStore(s => s.zenMode)
// v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
const handleToggleZen = useCallback(() => {
const editor = getMetonaEditor()
if (!editor) return
editor.toggleZen()
}, [])
// v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
const handleExport = useCallback(async () => {
if (!window.electronAPI) return
const data = await backupRepository.exportAll()
if (!data) return
const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
if (result.success) {
showToast('备份已导出', 'success')
} else if (!result.canceled) {
showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
}
}, [])
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable
const handleImport = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.importData()
if (!result.success) return
if (result.canceled || !result.content) return
try {
const data = JSON.parse(result.content) as Record<string, Record<string, unknown>[]>
const ok = await backupRepository.importAll(data)
if (ok) {
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
showToast('备份已恢复', 'success')
setTimeout(() => window.location.reload(), 800)
} else {
showToast('恢复备份失败', 'error')
}
} catch (error) {
logError('解析备份文件失败', error)
showToast('备份文件格式无效', 'error')
}
}, [])
return ( 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>
@@ -44,13 +108,46 @@ export const Toolbar = React.memo(function Toolbar({
<button <button
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`} className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave} onClick={onToggleAutoSave}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')} title={
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')} 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
className={`toolbar-btn${zenMode ? ' active' : ''}`}
onClick={handleToggleZen}
title={zenMode ? '退出专注模式' : '专注模式 — 隐藏编辑器工具栏'}
aria-label={zenMode ? '退出专注模式' : '进入专注模式'}
aria-pressed={zenMode}
>
<span>🧘 {zenMode ? '专注中' : '专注'}</span>
</button>
<button
className="toolbar-btn"
onClick={handleExport}
title="导出数据备份"
aria-label="导出数据备份"
>
<Download size={16} />
</button>
<button
className="toolbar-btn"
onClick={handleImport}
title="导入数据备份"
aria-label="导入数据备份"
>
<Upload size={16} />
</button>
<button <button
className="toolbar-btn" className="toolbar-btn"
onClick={onCycleTheme} onClick={onCycleTheme}
@@ -60,7 +157,12 @@ export const Toolbar = React.memo(function Toolbar({
{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>
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons' import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository' import { recentFilesRepository } from '../../db/recentFilesRepository'
import { getDb } from '../../db/schema'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
interface WelcomeScreenProps { interface WelcomeScreenProps {
@@ -9,13 +10,47 @@ interface WelcomeScreenProps {
onOpenRecent?: (filePath: string) => void onOpenRecent?: (filePath: string) => void
} }
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) { export const WelcomeScreen = React.memo(function WelcomeScreen({
onOpen,
onNew,
onOpenRecent,
}: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState<string[]>([]) const [recentFiles, setRecentFiles] = useState<string[]>([])
// v0.6.0: 订阅 recentFiles 表变更,文件打开/删除时自动刷新
useEffect(() => { useEffect(() => {
recentFilesRepository.getAll(10).then((files: string[]) => { let cancelled = false
setRecentFiles(files) let unsubscribe: (() => void) | null = null
const refresh = async () => {
const files = await recentFilesRepository.getAll(10)
if (!cancelled) setRecentFiles(files)
}
refresh()
getDb()
.then(db => {
if (cancelled) return
unsubscribe = db.subscribe('recentFiles', event => {
if (
event.type === 'insert' ||
event.type === 'update' ||
event.type === 'delete' ||
event.type === 'external'
) {
refresh()
}
}) })
})
.catch(() => {
/* 订阅失败不阻塞页面 */
})
return () => {
cancelled = true
unsubscribe?.()
}
}, []) }, [])
return ( return (
@@ -51,7 +86,14 @@ export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew,
aria-label={`打开 ${getFileName(filePath)}`} aria-label={`打开 ${getFileName(filePath)}`}
> >
<span className="recent-icon" aria-hidden="true"> <span className="recent-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> <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" />
</svg> </svg>
@@ -0,0 +1,34 @@
import 'fake-indexeddb/auto'
import { describe, it, expect } from 'vitest'
import { recentFilesRepository } from '../recentFilesRepository'
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
it('should add and read recent files', async () => {
await recentFilesRepository.add('/test/a.md')
const files = await recentFilesRepository.getAll(10)
expect(files).toContain('/test/a.md')
})
it('should update lastOpened on re-add', async () => {
await recentFilesRepository.add('/test/b.md')
await new Promise(r => setTimeout(r, 5))
await recentFilesRepository.add('/test/b.md')
const files = await recentFilesRepository.getAll(10)
expect(files[0]).toBe('/test/b.md')
})
it('should enforce 50 item limit', async () => {
for (let i = 0; i < 55; i++) {
await recentFilesRepository.add(`/test/f${String(i).padStart(2, '0')}.md`)
}
const files = await recentFilesRepository.getAll(60)
expect(files.length).toBeLessThanOrEqual(50)
})
it('should remove a file', async () => {
await recentFilesRepository.add('/test/remove-me.md')
await recentFilesRepository.remove('/test/remove-me.md')
const files = await recentFilesRepository.getAll(60)
expect(files).not.toContain('/test/remove-me.md')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { getDb } from './schema'
import { logError } from '../lib/errorHandler'
/** v0.6.0: 备份数据格式 — { 表名: 行[] }(与 db.exportAll() 一致) */
export type BackupData = Record<string, Record<string, unknown>[]>
const BACKUP_TABLES = ['tabSnapshots', 'settings', 'recentFiles', 'activeTab']
/**
* v0.6.0: 数据备份/恢复 — 基于 sqlark 的 exportAll / importTable。
*/
export const backupRepository = {
async exportAll(): Promise<BackupData | null> {
try {
const db = await getDb()
return (await db.exportAll()) as BackupData
} catch (error) {
logError('导出备份失败', error)
return null
}
},
async importAll(data: BackupData): Promise<boolean> {
try {
const db = await getDb()
// 先清空现有四表,再按表导入(清空走事务保证原子性)
await db.transaction(async trx => {
for (const tableName of BACKUP_TABLES) {
await trx.table(tableName).clear()
}
})
for (const tableName of BACKUP_TABLES) {
const rows = data[tableName]
if (Array.isArray(rows) && rows.length > 0) {
await db.importTable(tableName, rows)
}
}
return true
} catch (error) {
logError('导入备份失败', error)
return false
}
},
}
+41 -15
View File
@@ -1,21 +1,41 @@
import { db, type RecentFile } from './schema' import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
import { getDb, type RecentFile } from './schema'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
/**
* v0.6.0: 本地写操作完成后 emit 表变更事件 —
* sqlark 的 Table 写操作只通过 BroadcastChannel 通知其他标签页,
* 本地订阅(subscribe)需要手动 emitWelcomeScreen 借此自动刷新最近文件。
*/
async function emitChange(db: MetonaSqlark, type: string): Promise<void> {
try {
db.emit('recentFiles', { type })
} catch {
// 通知失败不影响主流程
}
}
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()
} }
await emitChange(db, 'update')
} catch (error) { } catch (error) {
logError('添加最近文件失败', error) logError('添加最近文件失败', error)
} }
@@ -23,11 +43,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 +59,9 @@ 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()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('删除最近文件失败', error) logError('删除最近文件失败', error)
} }
@@ -45,9 +69,11 @@ 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()
await emitChange(db, 'delete')
} catch (error) { } catch (error) {
logError('清空最近文件失败', error) logError('清空最近文件失败', error)
} }
} },
} }
+205 -18
View File
@@ -1,6 +1,12 @@
import Dexie, { type EntityTable } from 'dexie' import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
import { logError } from '../lib/errorHandler'
import { MeToast } from '../lib/toast'
export interface TabSnapshot { // 注意: 使用 type 别名而非 interface —
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>
// type 别名具备隐式索引签名,interface 没有(TS 索引签名规则)
export type TabSnapshot = {
id: string id: string
filePath: string | null filePath: string | null
content: string content: string
@@ -11,12 +17,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 +30,205 @@ 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.6.1: 存储引擎回归 AriaEngineLSM-Tree + WAL + MVCC,功能最强)。
settings: EntityTable<SettingsRecord, 'id'> // sqlark 0.4.4 修复 SSTable 大 value 编码缺陷(v2 格式 "SSTC"
recentFiles: EntityTable<RecentFile, 'id'> // u32 长度字段 + UTF-8 字节精确估算 + 超大条目独立成块,兼容旧 v1 格式)。
activeTab: EntityTable<ActiveTabRecord, 'id'> const DB_NAME = 'MarkLiteV2'
/**
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
* 使用标准配置 version: 1 即可,不再需要手动解析 IDB 版本号。
* 未来 schema 变更: 在 createDatabase 的幂等建表段追加增量变更逻辑。
*/
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true },
filePath: { type: 'string' },
content: { type: 'string' },
scrollTop: { type: 'number' },
selectionStart: { type: 'number' },
selectionEnd: { type: 'number' },
isModified: { type: 'boolean' },
updatedAt: { type: 'number', index: true },
} }
db.version(1).stores({ 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.6.0: 数据损坏自愈 —
* 极端异常(断电/蓝屏)下 IDB 可能残留不完整数据导致打开失败。
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
*
* 注意:自愈(删库/重载)只允许在页面生命周期内首次初始化时触发;
* 运行中(如应用关闭流程的 flushSaveToDBgetDb 失败时直接抛错,
* 绝不删库/重载,避免关闭流程被数据库操作阻塞。
*/
let dbPromise: Promise<MetonaSqlark> | null = null
let initFailedNotified = false
let healTriggered = false
// v0.6.1: 关闭流程标记 — closeDatabase 后禁止 getDb 重新打开库,
// 避免 forceClose 前其他组件重新建立连接(未关闭即被进程终止)
let isClosing = false
// v0.6.1: 初始化串行链 — 防止 dbPromise 失败重置后多个 getDb 并发触发 createDatabase
let initChain: Promise<unknown> = Promise.resolve()
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
// AriaEngine 的 IndexedDBBackend 内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}`
/** 删除损坏数据库(无活动连接时立即成功) */
function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => {
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
req.onsuccess = () => resolve()
req.onerror = () => resolve()
req.onblocked = () => resolve()
})
}
/** 自愈入口:存在重置标记时删除损坏库(保留标记,create 成功后才清除) */
async function resetCorruptDatabaseIfNeeded(): Promise<void> {
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') return
await deleteStorageDatabase()
try {
MeToast?.info('数据库已重置(原数据损坏,无法恢复)')
} catch {
/* 提示失败不影响主流程 */
}
} catch {
/* 重置失败不阻塞启动 */
}
}
async function createDatabase(): Promise<MetonaSqlark> {
const db = await create({
name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离(sqlark 0.4.4 修复大 value 编码)
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
onError: (err: Error) => logError('数据库错误', err),
})
// 幂等建表(getTableNames 基于持久化 schema,重启后不会重复建表)
const tables = await db.getTableNames()
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
return db
}
async function initDb(): Promise<MetonaSqlark> {
// 页面生命周期内仅首次初始化走自愈流程(删损坏库 + 失败重载)
if (!healTriggered) {
healTriggered = true
await resetCorruptDatabaseIfNeeded()
try {
const db = await createDatabase()
// 创建成功:清除重置标记(自愈完成)
try {
localStorage.removeItem(RESET_PENDING_KEY)
} catch {
/* 忽略 */
}
return db
} catch (err) {
logError('数据库打开失败', err)
// 启动早期失败:标记 + 重载页面(干净环境删除损坏库重建)— 仅此一次
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
localStorage.setItem(RESET_PENDING_KEY, '1')
window.location.reload()
}
} catch {
/* 忽略 */
}
throw err
}
}
// 运行中(自愈已尝试过):不再删库/重载,直接尝试创建,失败抛给调用方
return createDatabase()
}
/**
* v0.6.1: 关闭数据库 — 应用退出前调用,确保 WAL/SSTable 干净落盘,
* 避免渲染进程被销毁时 flush 中断留下半写文件导致下次启动损坏。
*/
export async function closeDatabase(): Promise<void> {
isClosing = true
const current = dbPromise
dbPromise = null
if (!current) return
try {
const db = await current.catch(() => null)
if (db) await db.close()
} catch (err) {
logError('关闭数据库失败', err)
}
}
export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) {
if (isClosing) {
// 关闭流程中禁止重开(返回 rejected Promise,调用方 catch 吞掉)
return Promise.reject(new Error('数据库正在关闭'))
}
// 串行化初始化:失败后的重试排队执行,避免并发 createDatabase
initChain = initChain.catch(() => {
/* 前序失败不影响后续 */
})
dbPromise = initChain
.then(() => initDb())
.catch(err => {
// v0.6.1: 初始化失败不再静默 — 记录日志并提示用户(否则所有数据持久化静默失效)
logError('数据库初始化失败', err)
if (!initFailedNotified) {
initFailedNotified = true
try {
const code = (err as { code?: string })?.code ?? 'UNKNOWN'
const msg = err instanceof Error ? err.message : String(err)
// ARIA_OPEN_ERROR 等包装错误的具体原因在 details 里(原始异常)
const details = (err as { details?: { message?: string } })?.details?.message
const detailText = details ? ` | 详情: ${details}` : ''
setTimeout(() => {
MeToast?.error(`数据库初始化失败(${code}):${msg}${detailText}`)
}, 0)
} catch {
/* 提示失败不影响主流程 */
}
}
throw err
})
// 初始化失败时允许下次重试
dbPromise.catch(() => {
dbPromise = null
})
}
return dbPromise
}
+19 -6
View File
@@ -1,17 +1,20 @@
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,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode, viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed, sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
} }
} }
} catch (error) { } catch (error) {
@@ -20,15 +23,25 @@ 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)
} }
} },
} }
+26 -10
View File
@@ -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,9 @@ 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 +31,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 +40,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 +55,12 @@ 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,76 +0,0 @@
import { describe, it, expect } from 'vitest'
import { computeDocStats } from '../useDocStats'
describe('computeDocStats', () => {
it('should return zeros for undefined content', () => {
expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for null content', () => {
expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for empty string', () => {
expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should count single word', () => {
const result = computeDocStats('hello')
expect(result.words).toBe(1)
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(5)
expect(result.lines).toBe(1)
})
it('should count multiple words', () => {
const result = computeDocStats('hello world')
expect(result.words).toBe(2)
expect(result.chars).toBe(11) // 'hello world' = 11 chars
expect(result.charsNoSpace).toBe(10) // without the space
})
it('should count characters without spaces', () => {
const result = computeDocStats('a b c')
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(3)
})
it('should count lines', () => {
const result = computeDocStats('line1\nline2\nline3')
expect(result.lines).toBe(3)
expect(result.words).toBe(3)
})
it('should handle Windows line endings', () => {
const result = computeDocStats('line1\r\nline2\r\nline3')
expect(result.lines).toBe(3)
})
it('should handle trailing newline', () => {
const result = computeDocStats('hello\n')
expect(result.lines).toBe(2)
expect(result.words).toBe(1)
})
it('should count markdown content correctly', () => {
const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
const result = computeDocStats(md)
expect(result.words).toBe(17)
expect(result.lines).toBe(7)
expect(result.chars).toBe(md.length)
})
it('should handle whitespace-only content', () => {
const result = computeDocStats(' \n \n ')
expect(result.words).toBe(0)
expect(result.chars).toBe(9)
expect(result.charsNoSpace).toBe(0)
expect(result.lines).toBe(3)
})
it('should handle content with non-ASCII characters', () => {
const result = computeDocStats('中文测试 日本語 한국어')
expect(result.words).toBe(3)
expect(result.chars).toBe(12)
})
})
+3 -3
View File
@@ -13,7 +13,7 @@ import { useEffect, useState, useCallback } from 'react'
*/ */
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)
@@ -26,7 +26,7 @@ export function useActiveHeading(
// 收集容器内所有 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) {
@@ -48,7 +48,7 @@ export function useActiveHeading(
// 找到 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
} }
+7 -3
View File
@@ -23,7 +23,11 @@ export function toggleAutoSaveExternal(): void {
* 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)
@@ -50,7 +54,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
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()
@@ -79,7 +83,7 @@ export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean
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)
-71
View File
@@ -1,71 +0,0 @@
import { useState, useCallback, useRef } from 'react'
interface ConfirmOptions {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
}
interface ConfirmState extends ConfirmOptions {
open: boolean
resolve: ((value: boolean) => void) | null
}
/**
* UX-01: Promise-based hook
* confirm() Promise<boolean>
*/
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
open: false,
title: '',
message: '',
resolve: null
})
// 使用 ref 确保回调中能拿到最新的 resolve
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
setState({
open: true,
title: options.title,
message: options.message,
confirmLabel: options.confirmLabel,
cancelLabel: options.cancelLabel,
variant: options.variant,
resolve
})
})
}, [])
const handleConfirm = useCallback(() => {
resolveRef.current?.(true)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
const handleCancel = useCallback(() => {
resolveRef.current?.(false)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
return {
confirm,
confirmDialogProps: {
open: state.open,
title: state.title,
message: state.message,
confirmLabel: state.confirmLabel,
cancelLabel: state.cancelLabel,
variant: state.variant,
onConfirm: handleConfirm,
onCancel: handleCancel
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import { useMemo } from 'react'
export interface DocStats {
/** 单词数(按空白字符分割) */
words: number
/** 总字符数(含空白字符) */
chars: number
/** 字符数(不含空白字符) */
charsNoSpace: number
/** 行数 */
lines: number
}
/**
* /
* undefined
*/
export function computeDocStats(content: string | undefined | null): DocStats {
if (!content) {
return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 }
}
const chars = content.length
const charsNoSpace = content.replace(/\s/g, '').length
const words = content.trim()
? content.trim().split(/\s+/).length
: 0
const lines = content === '' ? 0 : content.split(/\r?\n/).length
return { words, chars, charsNoSpace, lines }
}
/**
* Hook
* 使 useMemo
*/
export function useDocStats(content: string | undefined | null): DocStats {
return useMemo(() => computeDocStats(content), [content])
}
+5 -2
View File
@@ -8,7 +8,8 @@ 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()
@@ -51,7 +52,9 @@ export function useDragDrop() {
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) => {
+40 -14
View File
@@ -3,12 +3,13 @@ import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore' import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository' import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler' import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast' import { MeToast, showToast } from '../lib/toast'
/** /**
* AR-01: App.tsx * AR-01: App.tsx
* UX-02: 添加 loading * UX-02: 添加 loading
* v0.1.9: handleSave onSave + Ctrl+S * v0.1.9: handleSave onSave + Ctrl+S
* v0.6.0: loading MeToast.loading MeToast.promise
*/ */
export function useFileOperations() { export function useFileOperations() {
const createTab = useTabStore(s => s.createTab) const createTab = useTabStore(s => s.createTab)
@@ -19,18 +20,22 @@ export function useFileOperations() {
const savingGate = useRef(false) const savingGate = useRef(false)
const handleOpenFile = useCallback(async (): Promise<void> => { const handleOpenFile = useCallback(async (): Promise<void> => {
try {
if (!window.electronAPI) return if (!window.electronAPI) return
const loading = MeToast.loading('打开文件...')
try {
setLoading('file-open', true) setLoading('file-open', true)
const result = await window.electronAPI.openFile() const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) { if (result && 'filePath' in result) {
createTab(result.filePath, result.content) createTab(result.filePath, result.content)
if (result.filePath) recentFilesRepository.add(result.filePath) if (result.filePath) recentFilesRepository.add(result.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100) setTimeout(() => useTabStore.getState().saveToDB(), 100)
loading.success('文件已打开')
} else {
loading.dismiss()
} }
} catch (error) { } catch (error) {
logError('打开文件失败', error) logError('打开文件失败', error)
showToast('打开文件失败', 'error') loading.error('打开文件失败')
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
@@ -43,20 +48,31 @@ export function useFileOperations() {
try { try {
const tab = getActiveTab() const tab = getActiveTab()
if (!tab || !window.electronAPI) return if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFile({ // v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
const result = await MeToast.promise(
window.electronAPI.saveFile({
filePath: tab.filePath, filePath: tab.filePath,
content: tab.content content: tab.content,
}) }),
{
loading: '保存中...',
success: '已保存',
error: '保存失败',
},
)
if (result.success) { if (result.success) {
useTabStore.getState().setModified(tab.id, false) useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success') } else {
// IPC 返回 success:false 不 rejectpromise 的 error 文案不会触发,需显式提示
showToast('保存失败', 'error')
} }
} catch (error) { } catch (error) {
logError('保存文件失败', error) logError('保存文件失败', error)
showToast('保存失败', 'error')
} finally { } finally {
// 300ms 后释放锁,允许下次保存 // 300ms 后释放锁,允许下次保存
setTimeout(() => { savingGate.current = false }, 300) setTimeout(() => {
savingGate.current = false
}, 300)
} }
}, [getActiveTab]) }, [getActiveTab])
@@ -64,18 +80,26 @@ export function useFileOperations() {
try { try {
const tab = getActiveTab() const tab = getActiveTab()
if (!tab || !window.electronAPI) return if (!tab || !window.electronAPI) return
const result = await window.electronAPI.saveFileAs({ content: tab.content }) const result = await MeToast.promise(
window.electronAPI.saveFileAs({ content: tab.content }),
{
loading: '另存为...',
success: '已保存',
error: '另存为失败',
},
)
if (result.success) { if (result.success) {
useTabStore.getState().setModified(tab.id, false) useTabStore.getState().setModified(tab.id, false)
showToast('已保存', 'success') } else if (!result.canceled) {
showToast('另存为失败', 'error')
} }
} catch (error) { } catch (error) {
logError('另存为失败', error) logError('另存为失败', error)
showToast('另存为失败', 'error')
} }
}, [getActiveTab]) }, [getActiveTab])
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => { const handleOpenRecent = useCallback(
async (filePath: string): Promise<void> => {
if (!window.electronAPI) return if (!window.electronAPI) return
setLoading('file-open', true) setLoading('file-open', true)
try { try {
@@ -88,7 +112,9 @@ export function useFileOperations() {
} finally { } finally {
setLoading('file-open', false) setLoading('file-open', false)
} }
}, [createTab, setLoading]) },
[createTab, setLoading],
)
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
} }
+31 -10
View File
@@ -7,17 +7,38 @@ import { useTabStore } from '../stores/tabStore'
* 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(
const handleKeydown = useCallback((e: KeyboardEvent) => { handleOpenFile: () => void,
handleSave: () => void,
handleSaveAs: () => void,
) {
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) {
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return } e.preventDefault()
handleSave()
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)
@@ -37,14 +58,14 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
} }
} }
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
? (idx - 1 + tabs.length) % 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)
+5 -4
View File
@@ -6,7 +6,7 @@ import { logError } from '../lib/errorHandler'
/** /**
* AR-04: 统一设置加载 hook * AR-04: 统一设置加载 hook
* IndexedDB store * MetonaSqlark store
* hook * hook
*/ */
export function useSettingsInit() { export function useSettingsInit() {
@@ -18,8 +18,9 @@ export function useSettingsInit() {
if (isInitialized.current) return if (isInitialized.current) return
isInitialized.current = true isInitialized.current = true
settingsRepository.load() settingsRepository
.then((settings) => { .load()
.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')
@@ -34,7 +35,7 @@ export function useSettingsInit() {
useSidebarStore.setState({ useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed, isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth, sidebarWidth: settings.sidebarWidth,
_loaded: true _loaded: true,
}) })
} }
}) })
+15 -8
View File
@@ -7,13 +7,16 @@ 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 {
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
const vars = MeEditor.themes.exportCSSVars() const vars = MeEditor.themes.exportCSSVars()
if (!vars || typeof vars !== 'object') return if (!vars || typeof vars !== 'object') return
const root = document.documentElement const root = document.documentElement
@@ -44,13 +47,17 @@ function syncAppColorsToEditor(): void {
set('--search-border', vars['--md-border']) set('--search-border', vars['--md-border'])
// 阴影根据主题适配 // 阴影根据主题适配
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff' const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
root.style.setProperty('--shadow', isDark root.style.setProperty(
? '0 1px 3px rgba(0,0,0,0.3)' '--shadow',
: '0 1px 3px rgba(0,0,0,0.08)') isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)',
root.style.setProperty('--shadow-lg', isDark )
? '0 4px 12px rgba(0,0,0,0.4)' root.style.setProperty(
: '0 4px 12px rgba(0,0,0,0.1)') '--shadow-lg',
} catch { /* 容错 */ } isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.1)',
)
} catch {
/* 容错 */
}
} }
const THEME_TO_TOAST: Record<ThemeMode, string> = { const THEME_TO_TOAST: Record<ThemeMode, string> = {
+1 -1
View File
@@ -8,7 +8,7 @@ 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
+93 -19
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { renderMarkdown } from '../markdown' import { renderMarkdown, renderMarkdownSync } from '../markdown'
describe('renderMarkdown', () => { describe('renderMarkdown (MetonaEditor 内置解析器)', () => {
it('should render a simple heading', async () => { it('should render a simple heading', async () => {
const result = await renderMarkdown('# Hello World') const result = await renderMarkdown('# Hello World')
expect(result).toContain('<h1') expect(result).toContain('<h1')
@@ -26,7 +26,7 @@ describe('renderMarkdown', () => {
expect(result).toContain('code') expect(result).toContain('code')
}) })
it('should render code blocks with syntax highlighting', async () => { it('should render code blocks', async () => {
const md = '```javascript\nconst x = 1;\n```' const md = '```javascript\nconst x = 1;\n```'
const result = await renderMarkdown(md) const result = await renderMarkdown(md)
expect(result).toContain('<pre') expect(result).toContain('<pre')
@@ -75,32 +75,106 @@ describe('renderMarkdown', () => {
expect(result).toContain('deleted') expect(result).toContain('deleted')
}) })
it('should render task list', async () => {
const result = await renderMarkdown('- [x] done\n- [ ] todo')
expect(result).toContain('me-task-item')
expect(result).toContain('checked')
})
it('should render mermaid as .me-mermaid container', async () => {
const md = '```mermaid\ngraph TD\nA-->B\n```'
const result = await renderMarkdown(md)
expect(result).toContain('me-mermaid')
expect(result).toContain('class="mermaid"')
expect(result).toContain('graph TD')
})
it('should handle empty content', async () => { it('should handle empty content', async () => {
const result = await renderMarkdown('') const result = await renderMarkdown('')
expect(result).toBe('') expect(result).toBe('')
}) })
it('should return error HTML on rendering failure with invalid input', async () => { it('should handle null and undefined filePath', async () => {
// unified should still handle gracefully, but verify error path works expect(await renderMarkdown('# No File', null)).toContain('No File')
const result = await renderMarkdown('normal text') expect(await renderMarkdown('# No File')).toContain('No File')
expect(result).not.toContain('渲染错误')
}) })
it('should cache processors for same filePath', async () => { it('should be sync-callable via renderMarkdownSync', () => {
// Call twice with same filePath to test caching const result = renderMarkdownSync('# Sync')
const result1 = await renderMarkdown('# Test', '/test/file.md') expect(result).toContain('<h1')
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
expect(result1).toContain('<h1')
expect(result2).toContain('Test 2')
}) })
})
it('should handle null filePath', async () => { describe('fixImageSrcs — 相对路径图片修复', () => {
const result = await renderMarkdown('# No File', null) const FILE = '/home/user/docs/note.md'
expect(result).toContain('No File')
it('should fix relative image paths to file://', async () => {
const result = await renderMarkdown('![img](./pic.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/pic.png"')
})
it('should fix nested relative paths', async () => {
const result = await renderMarkdown('![img](assets/a.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/assets/a.png"')
})
it('should fix unix absolute paths', async () => {
const result = await renderMarkdown('![img](/abs/pic.png)', FILE)
expect(result).toContain('src="file:///abs/pic.png"')
})
it('should not touch http/https/data:/file: URLs', async () => {
const md = '![a](https://x.com/a.png) ![b](data:image/png;base64,xx) ![c](file:///c.png)'
const result = await renderMarkdown(md, FILE)
expect(result).toContain('https://x.com/a.png')
expect(result).toContain('data:image/png;base64,xx')
expect(result).toContain('file:///c.png')
})
it('should skip out-of-directory traversal (../ and ../../)', async () => {
const md = '![a](../outside.png) ![b](../../outside.png)'
const result = await renderMarkdown(md, FILE)
// 越界路径保持原样(不注入 file://)
expect(result).not.toContain('file:')
expect(result).toContain('../outside.png')
expect(result).toContain('../../outside.png')
})
it('should not treat sibling directory with same prefix as inside', async () => {
// /home/user/docs-other/ 与 /home/user/docs/ 前缀相似但目录不同
const result = await renderMarkdown('![img](../docs-other/pic.png)', FILE)
expect(result).not.toContain('file:')
expect(result).toContain('../docs-other/pic.png')
})
it('should not touch images when filePath is null', async () => {
const result = await renderMarkdown('![img](./pic.png)')
expect(result).toContain('./pic.png')
expect(result).not.toContain('file:')
})
it('should fix relative image paths from root-level file', async () => {
const result = await renderMarkdown('![img](./pic.png)', '/note.md')
expect(result).toContain('src="file:///pic.png"')
})
it('should escape special chars in fixed path', async () => {
const result = await renderMarkdown('![img](./a&b.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/a&amp;b.png"')
})
})
describe('XSS 防护(内置 safeUrl', () => {
it('should not emit anchor for javascript: URLs', async () => {
const result = await renderMarkdown('[x](javascript:alert(1))')
// 内置解析器将危险 URL 转义原样输出(不生成 <a>),文本不构成可执行链接
expect(result).not.toContain('<a')
expect(result).not.toContain('href=')
}) })
it('should handle undefined filePath', async () => { it('should not emit img for javascript: image src', async () => {
const result = await renderMarkdown('# No File') const result = await renderMarkdown('![x](javascript:alert(1))')
expect(result).toContain('No File') expect(result).not.toContain('<img')
expect(result).not.toContain('src=')
}) })
}) })
+58 -114
View File
@@ -1,12 +1,9 @@
import { unified, type Plugin } from 'unified' import { parseMarkdown } from '@metona-team/metona-editor'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm' // v0.6.0: 渲染管线迁移至 MetonaEditor 内置解析器(parseMarkdown),
import remarkRehype from 'remark-rehype' // 移除 unified/remark/rehype 自研管线。
import rehypeRaw from 'rehype-raw' // 内置解析器原生支持:GFM / 任务列表 / 脚注 / 数学公式 / 定义列表 / emoji /
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' // mermaid(输出 .me-mermaid 容器)/ XSS 防护(escapeHTML + safeUrl)。
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块) // 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判 // E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
@@ -29,110 +26,65 @@ function resolveRelativePath(base: string, rel: string): string {
return parts.join('/') return parts.join('/')
} }
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 /** 属性值转义(与内置解析器 escapeAttr 行为一致) */
function rehypeFixImages(filePath: string | null): Plugin<[], Root> { function escapeAttr(s: string): string {
return () => (tree: Root) => { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (!filePath) return }
/**
* HTML
*
* v0.6.0: 内置解析器的 safeUrl file: 协议 sanitize
* parseMarkdown DOM
* img src escapeAttr
* src
*
* CQ-09: 保持与原 rehypeFixImages
* markdown
*/
function fixImageSrcs(html: string, filePath: string | null): string {
if (!filePath) return html
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '') const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
function visit(node: Element | Root): void { return html.replace(/<img\b[^>]*\bsrc="([^"]*)"/g, (full, src) => {
if (!node.children) return const raw = String(src).replace(/&amp;/g, '&') // 先还原转义,避免二次转义
for (const child of node.children) { if (!raw) return full
if (child.type === 'element' && child.tagName === 'img') { if (
const src = child.properties?.src as string | undefined raw.startsWith('http://') ||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { raw.startsWith('https://') ||
// 处理Unix风格绝对路径(以/开头) raw.startsWith('data:') ||
if (src.startsWith('/')) { raw.startsWith('file:')
child.properties = { ) {
...child.properties, return full
src: 'file://' + src
} }
continue // 处理 Unix 风格绝对路径(以 / 开头)
if (raw.startsWith('/')) {
return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
} }
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外 // 解析完整路径并检查是否越界
const resolvedPath = resolveRelativePath(dir, src) // 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
// E1: 统一规范化后比较,防止 ../ 路径越界 // 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
const resolvedPath = resolveRelativePath(filePath, raw)
const normalizedBase = normPath(dir) const normalizedBase = normPath(dir)
if (!normPath(resolvedPath).startsWith(normalizedBase)) return // 用 base + '/' 前缀判断,避免 /home/docs-other/ 误判为在 /home/docs/ 内
child.properties = { const boundary = normalizedBase === '' ? '/' : normalizedBase + '/'
...child.properties, if (!normPath(resolvedPath).startsWith(boundary)) return full
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/') const fixed = 'file://' + resolvedPath
} return full.replace(`src="${src}"`, `src="${escapeAttr(fixed)}"`)
}
}
if (child.type === 'element') {
visit(child as Element)
}
}
}
visit(tree)
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 20
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
function buildProcessor(filePath: string | null) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
.use(rehypeFixImages(filePath ?? null))
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
},
protocols: {
...(defaultSchema.protocols ?? {}),
src: [
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
'file:',
],
},
// H-05: 显式 strip 事件处理器属性,纵深防御
strip: ['script', 'on*', 'javascript:'],
}) })
.use(rehypeHighlight)
.use(rehypeStringify)
} }
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> { /**
const key: string = filePath ?? '__null__' * Markdown 使 MetonaEditor
*
if (processorCache.has(key)) { * @param content Markdown
const cached = processorCache.get(key)! * @param filePath null
processorCache.delete(key) */
processorCache.set(key, cached) export function renderMarkdownSync(content: string, filePath?: string | null): string {
return cached
}
const processor = buildProcessor(filePath)
if (processorCache.size >= MAX_CACHE_SIZE) {
const oldestKey = processorCache.keys().next().value
if (oldestKey !== undefined) {
processorCache.delete(oldestKey)
}
}
processorCache.set(key, processor)
return processor
}
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try { try {
const processor = getCachedProcessor(filePath ?? null) const html = parseMarkdown(content)
const result = await processor.process(content) return fixImageSrcs(html, filePath ?? null)
return String(result)
} catch (e) { } catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e) const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>` return `<p style="color:red">渲染错误: ${errorMsg}</p>`
@@ -140,16 +92,8 @@ export async function renderMarkdown(content: string, filePath?: string | null):
} }
/** /**
* MetonaEditor render 使 *
* unified render
*/ */
export function renderMarkdownSync(content: string, filePath?: string | null): string { export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
try { return renderMarkdownSync(content, filePath)
const processor = getCachedProcessor(filePath ?? null)
const result = processor.processSync(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
} }
+15
View File
@@ -1,10 +1,14 @@
import MeToast from '@metona-team/metona-toast' import MeToast from '@metona-team/metona-toast'
import { logError } from './errorHandler'
/** /**
* MeToast * MeToast
* *
* Toast components/Toast MeToast * Toast components/Toast MeToast
* MeToast DOM React * MeToast DOM React
*
* v0.5.0: 升级 MeToast 0.5.0 keyboard / accessibility
* v0.6.0: 接入 dedupe onError
*/ */
MeToast.configure({ MeToast.configure({
position: 'top-right', position: 'top-right',
@@ -17,8 +21,19 @@ MeToast.configure({
showProgress: true, showProgress: true,
draggable: true, draggable: true,
locale: 'zh-CN', locale: 'zh-CN',
width: 360,
// v0.6.0: 钩子/定时器异常统一走应用错误日志
onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
}) })
// 安装内置插件
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
MeToast.use('keyboard') // ESC 关闭所有 Toast
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
/** 与原 showToast 保持兼容的类型子集 */ /** 与原 showToast 保持兼容的类型子集 */
export type ToastType = 'success' | 'error' | 'warning' | 'info' export type ToastType = 'success' | 'error' | 'warning' | 'info'
+1 -1
View File
@@ -8,5 +8,5 @@ import './styles/markdown-body.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
) )
@@ -100,4 +100,17 @@ describe('editorStore', () => {
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true) expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
}) })
}) })
describe('editor live state (v0.6.0)', () => {
beforeEach(() => {
useEditorStore.setState({ zenMode: false })
})
it('should set zen mode', () => {
useEditorStore.getState().setZenMode(true)
expect(useEditorStore.getState().zenMode).toBe(true)
useEditorStore.getState().setZenMode(false)
expect(useEditorStore.getState().zenMode).toBe(false)
})
})
}) })
+2 -2
View File
@@ -17,8 +17,8 @@ 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),
})) }))
+9 -3
View File
@@ -23,6 +23,8 @@ interface EditorState {
externallyModified: { filePath: string } | null externallyModified: { filePath: string } | null
// UX-02: 全局加载状态 // UX-02: 全局加载状态
loadingStates: Record<string, boolean> loadingStates: Record<string, boolean>
// v0.6.0: Zen 模式状态(Toolbar 消费)
zenMode: boolean
setViewMode: (mode: ViewMode) => void setViewMode: (mode: ViewMode) => void
setThemeMode: (mode: ThemeMode) => void setThemeMode: (mode: ThemeMode) => void
@@ -31,6 +33,7 @@ interface EditorState {
// UX-02: 加载状态管理 // UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean isLoading: (key: string) => boolean
setZenMode: (zen: boolean) => void
} }
export const useEditorStore = create<EditorState>((set, get) => ({ export const useEditorStore = create<EditorState>((set, get) => ({
@@ -38,6 +41,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
themeMode: 'light', themeMode: 'light',
externallyModified: null, externallyModified: null,
loadingStates: {}, loadingStates: {},
zenMode: false,
setViewMode: (mode: ViewMode) => set({ viewMode: mode }), setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }), setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
@@ -49,8 +53,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
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) =>
loadingStates: { ...state.loadingStates, [key]: loading } set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading },
})), })),
isLoading: (key: string) => get().loadingStates[key] ?? false isLoading: (key: string) => get().loadingStates[key] ?? false,
setZenMode: (zenMode: boolean) => set({ zenMode }),
})) }))
+4 -6
View File
@@ -18,7 +18,7 @@ interface SidebarState {
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
} }
export const useSidebarStore = create<SidebarState>((set) => ({ export const useSidebarStore = create<SidebarState>(set => ({
isVisible: true, isVisible: true,
rootPath: null, rootPath: null,
tree: [], tree: [],
@@ -36,17 +36,15 @@ export const useSidebarStore = create<SidebarState>((set) => ({
set(state => ({ set(state => ({
expandedDirs: state.expandedDirs.includes(path) expandedDirs: state.expandedDirs.includes(path)
? state.expandedDirs.filter(p => p !== path) ? state.expandedDirs.filter(p => p !== path)
: [...state.expandedDirs, path] : [...state.expandedDirs, path],
})), })),
expandDirs: (paths: string[]) => expandDirs: (paths: string[]) =>
set(state => { set(state => {
const newDirs = paths.filter(p => !state.expandedDirs.includes(p)) const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
return newDirs.length > 0 return newDirs.length > 0 ? { expandedDirs: [...state.expandedDirs, ...newDirs] } : state
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
: state
}), }),
setSidebarWidth: (width: number) => { setSidebarWidth: (width: number) => {
set({ sidebarWidth: width }) set({ sidebarWidth: width })
settingsRepository.save({ sidebarWidth: width }) settingsRepository.save({ sidebarWidth: width })
} },
})) }))
+27 -20
View File
@@ -4,7 +4,10 @@ import type { Tab } from '../types/tab'
import { tabRepository } from '../db/tabRepository' import { tabRepository } from '../db/tabRepository'
// PF-08: 防抖工具函数 // PF-08: 防抖工具函数
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } { function debounce<F extends (...args: unknown[]) => void>(
fn: F,
delay: number,
): F & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
const debounced = ((...args: unknown[]) => { const debounced = ((...args: unknown[]) => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
@@ -38,13 +41,16 @@ interface TabState {
updateTabContent: (tabId: string, content: string) => void updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null getActiveTab: () => Tab | null
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void updateTabScroll: (
tabId: string,
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => void
loadFromDB: () => Promise<void> loadFromDB: () => Promise<void>
saveToDB: () => Promise<void> saveToDB: () => Promise<void>
} }
// PF-08: 模块级防抖保存函数(500ms延迟) // PF-08: 模块级防抖保存函数(500ms延迟)
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null let _debouncedSaveToDB: ((() => void) & { cancel: () => void }) | null = null
function getActualSaveToDB(get: () => TabState) { function getActualSaveToDB(get: () => TabState) {
return async () => { return async () => {
@@ -61,7 +67,7 @@ function getActualSaveToDB(get: () => TabState) {
scrollTop: t.scrollTop, scrollTop: t.scrollTop,
selectionStart: t.selectionStart, selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd, selectionEnd: t.selectionEnd,
updatedAt: Date.now() updatedAt: Date.now(),
})) }))
await tabRepository.saveAll(snapshots) await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId) await tabRepository.saveActiveTabId(get().activeTabId)
@@ -83,7 +89,7 @@ export const useTabStore = create<TabState>((set, get) => {
try { try {
const [snapshots, savedActiveTabId] = await Promise.all([ const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(), tabRepository.loadAll(),
tabRepository.loadActiveTabId() tabRepository.loadActiveTabId(),
]) ])
if (snapshots.length > 0) { if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({ const tabs: Tab[] = snapshots.map(s => ({
@@ -93,9 +99,10 @@ export const useTabStore = create<TabState>((set, get) => {
isModified: s.isModified, isModified: s.isModified,
scrollTop: s.scrollTop, scrollTop: s.scrollTop,
selectionStart: s.selectionStart, selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd selectionEnd: s.selectionEnd,
})) }))
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) const activeTabId =
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
? savedActiveTabId ? savedActiveTabId
: tabs[tabs.length - 1].id : tabs[tabs.length - 1].id
set({ tabs, activeTabId, _loaded: true }) set({ tabs, activeTabId, _loaded: true })
@@ -131,12 +138,12 @@ export const useTabStore = create<TabState>((set, get) => {
isModified: false, isModified: false,
scrollTop: 0, scrollTop: 0,
selectionStart: 0, selectionStart: 0,
selectionEnd: 0 selectionEnd: 0,
} }
set(state => ({ set(state => ({
tabs: [...state.tabs, tab], tabs: [...state.tabs, tab],
activeTabId: tab.id activeTabId: tab.id,
})) }))
_debouncedSaveToDB?.() _debouncedSaveToDB?.()
@@ -192,13 +199,14 @@ export const useTabStore = create<TabState>((set, get) => {
const index = state.tabs.findIndex(t => t.id === tabId) const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1) const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId) const newActiveId =
state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId ? state.activeTabId
: tabId : tabId
return { return {
tabs: newTabs, tabs: newTabs,
activeTabId: newActiveId, activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)) mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id)),
} }
}) })
_debouncedSaveToDB?.() _debouncedSaveToDB?.()
@@ -237,15 +245,13 @@ export const useTabStore = create<TabState>((set, get) => {
// A3: 内容未变时不变更对象引用(避免误标记 isModified // A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t if (t.content === content) return t
return { ...t, content, isModified: true } return { ...t, content, isModified: true }
}) }),
})) }))
}, },
setModified: (tabId: string, modified: boolean) => { setModified: (tabId: string, modified: boolean) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t => (t.id === tabId ? { ...t, isModified: modified } : t)),
t.id === tabId ? { ...t, isModified: modified } : t
)
})) }))
}, },
@@ -254,13 +260,14 @@ export const useTabStore = create<TabState>((set, get) => {
return tabs.find(t => t.id === activeTabId) ?? null return tabs.find(t => t.id === activeTabId) ?? null
}, },
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => { updateTabScroll: (
tabId: string,
scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>,
) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t => (t.id === tabId ? { ...t, ...scroll } : t)),
t.id === tabId ? { ...t, ...scroll } : t
)
})) }))
} },
} }
}) })
-168
View File
@@ -906,174 +906,6 @@ body {
background: var(--primary-dark); background: var(--primary-dark);
} }
/* ===== UX-01: ConfirmDialog ===== */
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
animation: fadeIn 0.15s ease;
}
.confirm-dialog {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 380px;
max-width: 90vw;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.confirm-header {
padding: 20px 24px 0;
}
.confirm-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.confirm-header.confirm-danger h3 {
color: #d93025;
}
.confirm-header.confirm-warning h3 {
color: #e37400;
}
:root.dark .confirm-header.confirm-warning h3 {
color: #fdd663;
}
.confirm-body {
padding: 12px 24px 20px;
}
.confirm-body p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
}
.confirm-btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg);
color: var(--text);
}
.confirm-btn-cancel:hover {
background: var(--bg-tertiary);
}
.confirm-btn-danger {
background: #d93025;
border-color: #d93025;
color: white;
}
.confirm-btn-danger:hover {
background: #b5271d;
}
.confirm-btn-warning {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-warning:hover {
background: var(--primary-dark);
}
.confirm-btn-info {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-info:hover {
background: var(--primary-dark);
}
.confirm-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ===== UX-02: LoadingSpinner ===== */
.loading-spinner {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
.loading-spinner-label {
font-size: 13px;
font-family: var(--font-ui);
color: var(--text-secondary);
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
:root.dark .loading-overlay {
background: rgba(30, 30, 30, 0.8);
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--text-tertiary);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ===== UX-07: 通用可访问性增强 ===== */ /* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */ /* Focus visible 为所有交互元素提供清晰的焦点指示 */
+45 -16
View File
@@ -7,8 +7,12 @@
word-wrap: break-word; word-wrap: break-word;
} }
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h1,
.markdown-body h4, .markdown-body h5, .markdown-body h6 { .markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px; margin-top: 24px;
margin-bottom: 16px; margin-bottom: 16px;
font-weight: 600; font-weight: 600;
@@ -28,10 +32,19 @@
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
} }
.markdown-body h3 { font-size: 1.25em; } .markdown-body h3 {
.markdown-body h4 { font-size: 1em; } font-size: 1.25em;
.markdown-body h5 { font-size: 0.875em; } }
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); } .markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
font-size: 0.85em;
color: var(--text-secondary);
}
.markdown-body p { .markdown-body p {
margin-top: 0; margin-top: 0;
@@ -44,8 +57,12 @@
cursor: pointer; cursor: pointer;
} }
.markdown-body a:hover { text-decoration: underline; } .markdown-body a:hover {
.markdown-body strong { font-weight: 600; } text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body img { .markdown-body img {
max-width: 100%; max-width: 100%;
@@ -71,16 +88,23 @@
border-radius: 0 var(--radius) var(--radius) 0; border-radius: 0 var(--radius) var(--radius) 0;
} }
.markdown-body blockquote p:last-child { margin-bottom: 0; } .markdown-body blockquote p:last-child {
margin-bottom: 0;
}
.markdown-body ul, .markdown-body ol { .markdown-body ul,
.markdown-body ol {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
padding-left: 2em; padding-left: 2em;
} }
.markdown-body li { margin-top: 4px; } .markdown-body li {
.markdown-body li + li { margin-top: 4px; } margin-top: 4px;
}
.markdown-body li + li {
margin-top: 4px;
}
.markdown-body code { .markdown-body code {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -91,7 +115,9 @@
color: #e83e8c; color: #e83e8c;
} }
:root.dark .markdown-body code { color: #f48fb1; } :root.dark .markdown-body code {
color: #f48fb1;
}
.markdown-body pre { .markdown-body pre {
margin-top: 0; margin-top: 0;
@@ -119,7 +145,8 @@
display: block; display: block;
} }
.markdown-body table th, .markdown-body table td { .markdown-body table th,
.markdown-body table td {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
text-align: left; text-align: left;
@@ -130,9 +157,11 @@
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); } .markdown-body table tr:nth-child(even) {
background: var(--bg-secondary);
}
.markdown-body input[type="checkbox"] { .markdown-body input[type='checkbox'] {
margin-right: 6px; margin-right: 6px;
accent-color: var(--primary); accent-color: var(--primary);
} }
+15 -1
View File
@@ -6,7 +6,7 @@ import type {
SaveAsPayload, SaveAsPayload,
ReloadFileResult, ReloadFileResult,
FileStatsResult, FileStatsResult,
ReadDirTreeResult ReadDirTreeResult,
} from '../../shared/types' } from '../../shared/types'
export interface IpcInvokeMap { export interface IpcInvokeMap {
@@ -24,6 +24,11 @@ export interface IpcInvokeMap {
'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]
'data:export': [
string,
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
]
'data:import': [void, { success: boolean; canceled?: boolean; content?: string; error?: string }]
} }
export type Unsubscribe = () => void export type Unsubscribe = () => void
@@ -44,6 +49,15 @@ export interface ElectronAPI {
openFolderDialog: () => Promise<string | null> openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void> watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void> unwatchDir: () => Promise<void>
exportData: (
content: string,
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
importData: () => Promise<{
success: boolean
canceled?: boolean
content?: string
error?: string
}>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe 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
+1 -1
View File
@@ -12,5 +12,5 @@ export const DEFAULT_SETTINGS: Settings = {
themeMode: 'light', themeMode: 'light',
viewMode: 'editor', viewMode: 'editor',
sidebarCollapsed: false, sidebarCollapsed: false,
sidebarWidth: 240 sidebarWidth: 240,
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11 -3
View File
@@ -1,8 +1,16 @@
// 共享常量 — 主进程和渲染进程共用 // 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.4.3' export const APP_VERSION = 'v0.6.1'
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',
'.next', '.nuxt', '__pycache__', '.DS_Store' '.git',
'.svn',
'.hg',
'dist',
'out',
'.next',
'.nuxt',
'__pycache__',
'.DS_Store',
]) ])
+5 -1
View File
@@ -16,11 +16,15 @@ export const IPC_CHANNELS = {
DIR_WATCH: 'dir:watch', DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch', DIR_UNWATCH: 'dir:unwatch',
// v0.6.0: 数据备份导出/导入(JSON
DATA_EXPORT: 'data:export',
DATA_IMPORT: 'data:import',
// 主进程 → 渲染进程 (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]