diff --git a/.npmrc.example b/.npmrc.example index 5ebd230..2d66759 100644 --- a/.npmrc.example +++ b/.npmrc.example @@ -1,15 +1,19 @@ -# MarkLite npm 配置模板 -# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置) -# -# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载: -# registry=https://registry.npmmirror.com -# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ -# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ -# -# 配置说明: -# registry — npm 包下载源 -# ELECTRON_MIRROR — Electron 二进制文件下载源 -# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源 -# -# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。 -# 详见 DEVSETUP.md +# MarkLite npm 配置模板 +# 复制本文件为 .npmrc 后生效(.npmrc 已被 .gitignore 忽略,不会提交个人配置) +# +# 国内开发者推荐使用 npmmirror 镜像源以加速依赖下载: +# registry=https://registry.npmmirror.com +# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ +# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ +# +# @metona-team 私有 registry(metona-editor / metona-toast / metona-sqlark): +# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ +# //git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true +# +# 配置说明: +# registry — npm 包下载源 +# ELECTRON_MIRROR — Electron 二进制文件下载源 +# ELECTRON_BUILDER_BINARIES_MIRROR — electron-builder 打包工具链下载源 +# +# 注意:修改 .npmrc 后务必新开一个命令行窗口,否则环境变量不生效。 +# 详见 DEVSETUP.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01c9c16..87b368a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,123 +1,123 @@ -# 贡献指南 - -感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。 - -## 开发环境 - -1. **前置要求** - - Node.js >= 18.x - - npm >= 9.x - - Windows 10/11 x64 - -2. **克隆并安装** - ```bash - git clone https://git.metona.cn/MetonaTeam/MarkLite.git - cd MarkLite - npm install - ``` - -3. **启动开发** - ```bash - npm run dev - ``` - -## 代码规范 - -- **TypeScript**: 严格模式,所有函数参数需显式类型标注 -- **ESLint**: 提交前确保 `npm run lint` 通过 -- **Prettier**: 提交前确保 `npm run format:check` 通过 -- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck - -## 分支策略 - -- `master` — 稳定版本 -- `dev` — 开发分支 -- `feature/*` — 功能分支 -- `fix/*` — 修复分支 - -## 提交规范 - -使用语义化提交信息: - -``` -(): - -[optional body] -``` - -类型(type): -- `feat`: 新功能 -- `fix`: 修复 Bug -- `docs`: 文档变更 -- `style`: 代码格式(不影响逻辑) -- `refactor`: 重构 -- `perf`: 性能优化 -- `test`: 测试相关 -- `chore`: 构建/工具链变更 - -示例: -``` -feat(editor): 添加代码折叠功能 -fix(tab): 修复关闭标签后焦点丢失问题 -perf(markdown): 引入 processor LRU 缓存 -``` - -## Pull Request 流程 - -1. Fork 仓库并创建功能分支 -2. 确保所有检查通过: - ```bash - npm run lint # ESLint - npm run typecheck # TypeScript 类型检查 - npm run test # 单元测试 - ``` -3. 编写清晰的 PR 描述,说明变更内容和原因 -4. 等待代码审查 - -## 添加测试 - -- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx` -- 使用 Vitest + React Testing Library -- 至少覆盖 stores、hooks、lib 目录下的核心模块 - -```bash -# 运行测试 -npm run test - -# 监听模式 -npm run test:watch - -# 覆盖率报告 -npm run test:coverage -``` - -## 项目结构 - -``` -src/ -├── main/ # 主进程 (Node.js) -├── preload/ # 预加载脚本 -├── renderer/ # 渲染进程 (React) -│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等) -│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore) -│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等) -│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast) -│ ├── db/ # IndexedDB 持久化(schema · repositories) -│ ├── types/ # TypeScript 类型定义 -│ └── styles/ # 全局样式(variables · global · markdown-body) -└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量) -``` - -### 编辑器架构 - -MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心: - -- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换 -- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮 -- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片) -- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage) -- **主题同步**:应用暗色模式与编辑器主题双向同步 - -## 许可证 - -贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。 +# 贡献指南 + +感谢你对 MarkLite 项目的关注!以下是参与贡献的指南。 + +## 开发环境 + +1. **前置要求** + - Node.js >= 18.x + - npm >= 9.x + - Windows 10/11 x64 + +2. **克隆并安装** + ```bash + git clone https://git.metona.cn/MetonaTeam/MarkLite.git + cd MarkLite + npm install + ``` + +3. **启动开发** + ```bash + npm run dev + ``` + +## 代码规范 + +- **TypeScript**: 严格模式,所有函数参数需显式类型标注 +- **ESLint**: 提交前确保 `npm run lint` 通过 +- **Prettier**: 提交前确保 `npm run format:check` 通过 +- **Git Hooks**: pre-commit 自动执行 ESLint + typecheck + +## 分支策略 + +- `master` — 稳定版本 +- `dev` — 开发分支 +- `feature/*` — 功能分支 +- `fix/*` — 修复分支 + +## 提交规范 + +使用语义化提交信息: + +``` +(): + +[optional body] +``` + +类型(type): +- `feat`: 新功能 +- `fix`: 修复 Bug +- `docs`: 文档变更 +- `style`: 代码格式(不影响逻辑) +- `refactor`: 重构 +- `perf`: 性能优化 +- `test`: 测试相关 +- `chore`: 构建/工具链变更 + +示例: +``` +feat(editor): 添加代码折叠功能 +fix(tab): 修复关闭标签后焦点丢失问题 +perf(markdown): 引入 processor LRU 缓存 +``` + +## Pull Request 流程 + +1. Fork 仓库并创建功能分支 +2. 确保所有检查通过: + ```bash + npm run lint # ESLint + npm run typecheck # TypeScript 类型检查 + npm run test # 单元测试 + ``` +3. 编写清晰的 PR 描述,说明变更内容和原因 +4. 等待代码审查 + +## 添加测试 + +- 测试文件放在 `__tests__/` 目录下,命名为 `*.test.ts` 或 `*.test.tsx` +- 使用 Vitest + React Testing Library +- 至少覆盖 stores、hooks、lib 目录下的核心模块 + +```bash +# 运行测试 +npm run test + +# 监听模式 +npm run test:watch + +# 覆盖率报告 +npm run test:coverage +``` + +## 项目结构 + +``` +src/ +├── main/ # 主进程 (Node.js) +├── preload/ # 预加载脚本 +├── renderer/ # 渲染进程 (React) +│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等) +│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore) +│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等) +│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast) +│ ├── db/ # IndexedDB 持久化(schema · repositories) +│ ├── types/ # TypeScript 类型定义 +│ └── styles/ # 全局样式(variables · global · markdown-body) +└── shared/ # 主进程/渲染进程共享(IPC 通道 · 共享类型 · 常量) +``` + +### 编辑器架构 + +MarkLite 使用 [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.3 作为编辑器核心: + +- **三模式视图**(编辑 / 分屏 / 预览),由 MetonaEditor 内置工具栏切换 +- **渲染管线**:通过 `render` 钩子接入 unified/rehype 管线,处理图片路径修复和代码高亮 +- **插件**:searchReplace(搜索替换)+ imagePaste(粘贴图片) +- **自动保存**:自定义 `useAutoSave` hook,通过 Electron IPC 保存到文件系统(非 localStorage) +- **主题同步**:应用暗色模式与编辑器主题双向同步 + +## 许可证 + +贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。 diff --git a/DESIGN.md b/DESIGN.md index 030e59c..a5ba0a1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,420 +1,433 @@ -# MarkLite v0.4.3 — 架构设计文档 - -## 1. 项目概述 - -MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。 - -### 1.1 核心原则 - -1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义 -2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types) -3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize -4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦 - -## 2. 技术架构 - -### 2.1 技术栈 - -| 组件 | 技术 | 版本 | 说明 | -|------|------|------|------| -| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | -| 前端框架 | React | v18 | 函数组件 + Hooks | -| 类型系统 | TypeScript | v5.6 | 全量类型安全 | -| 编辑器 | MetonaEditor | v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 | -| 状态管理 | Zustand | v5 | 轻量级状态管理 | -| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 | -| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) | -| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js | -| Toast | @metona-team/metona-toast | v2.0.1 | 通知提示组件 | -| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 | -| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 | -| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 | - -### 2.2 进程架构 - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Main Process (src/main/) 5 文件 │ -│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │ -│ ipc-handlers.ts 所有 ipcMain.handle 注册 │ -│ file-system.ts 文件读写、目录树构建、BOM 剥离 │ -│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │ -│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │ -└───────────────────────┬──────────────────────────────────────┘ - │ contextBridge (安全隔离) -┌───────────────────────▼──────────────────────────────────────┐ -│ Preload Script (src/preload/) 1 文件 │ -│ index.ts contextBridge 类型安全暴露 │ -│ electronAPI 22 个方法/事件的类型安全接口 │ -└───────────────────────┬──────────────────────────────────────┘ - │ -┌───────────────────────▼──────────────────────────────────────┐ -│ Renderer Process (src/renderer/) React 18 │ -│ │ -│ components/ Toolbar · TabBar · Editor · Sidebar │ -│ FileTree · OutlinePanel · WelcomeScreen │ -│ ConfirmDialog · ModifiedBanner │ -│ DropOverlay · ErrorBoundary · AboutDialog│ -│ LoadingSpinner · Icons │ -│ │ -│ stores/ (4) tabStore · editorStore · sidebarStore │ -│ autoSaveStore │ -│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │ -│ useDragDrop · useFileWatch · useAutoSave │ -│ useIpcListeners · useFileOperations ... │ -│ lib/ (4) markdown · fileUtils · errorHandler │ -│ toast · constants │ -│ db/ (4) schema · tabRepository · settingsRepo │ -│ recentFilesRepository │ -│ types/ (5) tab · file · settings · ipc · index │ -│ styles/ (3) variables · global · markdown-body │ -└──────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────┐ -│ Shared (src/shared/) 3 文件 │ -│ ipc-channels.ts IPC 通道名常量 │ -│ types.ts 共享类型定义 │ -│ constants.ts 共享常量 (版本号、文件大小限制等) │ -└──────────────────────────────────────────────────────────────┘ -``` - -### 2.3 安全模型 - -| 层级 | 措施 | 说明 | -|------|------|------| -| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 | -| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | -| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 | -| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 | -| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 | -| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | -| 路径 | `validatePath()` | 防止路径遍历攻击 | - -## 3. 状态管理架构 - -### 3.1 Zustand Stores - -``` -┌─────────────────────────────────────────────────────────┐ -│ App.tsx (根组件) │ -├─────────┬──────────┬──────────┬──────────────────────────┤ -│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │ -├─────────┴──────────┴──────────┴──────────────────────────┤ -│ Zustand Stores │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ -│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │ -│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │ -│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │ -│ │ - mru │ │- extMod │ │- rootPath │ │ │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ -│ │ │ │ │ │ -│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │ -│ │ IndexedDB (Dexie.js) │ │ -│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │ -│ └───────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 3.2 tabStore — 标签页状态 - -```typescript -interface TabState { - tabs: Tab[] // 所有标签页 - activeTabId: string | null // 当前活动标签 ID - mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换) - - createTab(filePath?, content?) // 创建标签(同文件不重复打开) - closeTab(tabId) // 关闭标签(自动切换到相邻标签) - switchToTab(tabId) // 切换标签 - updateTabContent(tabId, content) // 更新内容(标记修改) - setModified(tabId, modified) // 设置修改状态 - getActiveTab() // 获取当前标签 - updateTabScroll(tabId, scroll) // 更新滚动/光标位置 - saveToDB() // 保存到 IndexedDB(防抖 500ms) - loadFromDB() // 从 IndexedDB 加载 -} -``` - -### 3.3 editorStore — 编辑器状态 - -```typescript -interface EditorState { - viewMode: 'editor' | 'preview' | 'source' // 视图模式 - darkMode: boolean // 暗色主题 - externallyModified: { filePath: string } | null // 外部修改检测状态 - loadingStates: Record // 全局加载状态 -} - -// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例 -function getMetonaEditor(): MarkdownEditor | null -function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void -``` - -### 3.4 sidebarStore — 侧边栏状态 - -```typescript -interface SidebarState { - isVisible: boolean // 是否显示 - rootPath: string | null // 当前打开的文件夹路径 - tree: FileNode[] // 目录树数据 - expandedDirs: string[] // 已展开的目录集合 - sidebarWidth: number // 侧边栏宽度 (180~500) -} -``` - -## 4. 数据持久化 — IndexedDB - -通过 Dexie.js 封装 IndexedDB: - -### 4.1 数据库 Schema - -```typescript -const db = new Dexie('MarkLite') - -db.version(1).stores({ - tabSnapshots: 'id, filePath, updatedAt', // 标签页快照 - settings: 'id', // 用户设置 - recentFiles: '++id, filePath, lastOpened', // 最近打开文件 - activeTab: 'id' // 当前活动标签 -}) -``` - -### 4.2 数据模型 - -| Store | 字段 | 说明 | -|-------|------|------| -| `tabSnapshots` | id, filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt | 标签页状态快照 | -| `settings` | id, darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 | -| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 | -| `activeTab` | id, activeTabId | 当前活动标签 ID | - -## 5. IPC 通信设计 - -### 5.1 渲染进程 → 主进程(invoke) - -| 通道 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 | -| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 | -| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 | -| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 | -| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 | -| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 | -| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 | -| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 | -| `window:forceClose` | 无 | `void` | 强制关闭窗口 | -| `window:cancelClose` | 无 | `void` | 取消关闭 | -| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 | -| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 | -| `dir:watch` | `dirPath` | `void` | 监听目录变化 | -| `dir:unwatch` | 无 | `void` | 停止监听目录变化 | - -### 5.2 主进程 → 渲染进程(send) - -| 通道 | 数据 | 说明 | -|------|------|------| -| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 | -| `file:externallyModified` | `filePath` | 文件被外部修改 | -| `window:confirmClose` | 无 | 请求确认关闭 | -| `sidebar:dirChanged` | 无 | 目录结构变化 | - -## 6. 编辑器架构 — MetonaEditor v0.1.14 - -### 6.1 三模式视图 - -- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码 -- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动 -- **预览模式 (preview)**:仅显示渲染后的 HTML - -MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。 - -### 6.2 内置功能 - -| 功能 | 说明 | -|------|------| -| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr | -| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 | -| 历史栈 | undo / redo,防抖合并,可配置上限 | -| 主题 | light / dark / auto / warm,CSS 变量驱动 | -| 国际化 | zh-CN / en-US 完整翻译 | -| 全屏模式 | 编辑器全屏展示 | - -### 6.3 插件体系 - -通过 `plugins` 配置数组安装 MetonaEditor 预设插件: - -| 插件 | 说明 | -|------|------| -| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 | -| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 | - -> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。 - -### 6.4 渲染管线集成 - -通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现: - -- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径 -- **XSS 防护**:rehype-sanitize 过滤危险标签 -- **代码高亮**:rehype-highlight 语法高亮 -- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶 - -``` -Markdown 源码 - │ - ▼ -unified 管线(renderMarkdownSync) - ├── remark-parse 解析为 MDAST - ├── remark-gfm GFM 扩展 - ├── remark-rehype 转换为 HAST - ├── rehype-raw 解析内联 HTML - ├── rehype-sanitize 安全过滤 - ├── rehype-fixImages 相对路径 → file:// - ├── rehype-highlight 代码高亮 - └── rehype-stringify 序列化为 HTML - │ - ▼ -MetonaEditor 预览区渲染 -``` - -### 6.5 主题切换 - -MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程: - -1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage -2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`) -3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题 - -### 6.6 内容同步 - -- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified` -- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange -- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置 - -## 7. Markdown 渲染管线 - -``` -Markdown 文本 - │ - ▼ -remark-parse 解析为 MDAST - │ - ▼ -remark-gfm 扩展 GFM 语法 - │ - ▼ -remark-rehype 转换为 HAST - │ - ▼ -rehype-raw 解析内联 HTML - │ - ▼ -rehype-sanitize 安全过滤 - │ - ▼ -rehype-fixImages 相对路径图片转 file:// URL - │ - ▼ -rehype-highlight 代码语法高亮 - │ - ▼ -rehype-stringify 序列化为 HTML - │ - ▼ -Renderer (MetonaEditor preview / Preview component) -``` - -## 8. UI 设计 - -### 8.1 色彩方案 - -#### 亮色主题 - -| 角色 | CSS 变量 | 色值 | -|------|----------|------| -| 主色调 | `--primary` | `#1a73e8` | -| 背景色 | `--bg` | `#ffffff` | -| 次级背景 | `--bg-secondary` | `#f8f9fa` | -| 文字色 | `--text` | `#333333` | -| 边框色 | `--border` | `#e1e4e8` | - -#### 暗色主题 - -| 角色 | CSS 变量 | 色值 | -|------|----------|------| -| 主色调 | `--primary` | `#8ab4f8` | -| 背景色 | `--bg` | `#1e1e1e` | -| 次级背景 | `--bg-secondary` | `#252526` | -| 文字色 | `--text` | `#d4d4d4` | -| 边框色 | `--border` | `#3e3e3e` | - -### 8.2 布局 - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ MarkLite - filename.md ─ □ ✕ │ -├──────────────────────────────────────────────────────────────────────────┤ -│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │ -├──────────────────────────────────────────────────────────────────────────┤ -│ [file1.md] [file2.md] [未命名] [+] │ -├──────────┬───────────────────────────────────────────────────────────────┤ -│ 资源管理器 │ │ -│ ▼ project │ ┌─────────────────────────────────────────────┐ │ -│ 📁 src │ │ B I S U │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │ -│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │ -│ 📄 file2│ ├─────────────────────────────────────────────┤ │ -│ │ │ # Title │ Title │ │ -│ 文档大纲 │ │ │ ─────── │ │ -│ · Title │ │ content... │ content... │ │ -├──────────┴──┴─────────────────────────────────────────────┴──────────────┤ -│ (MetonaEditor 底栏: 字数/行数/阅读时间) │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -## 9. 构建与发布 - -### 9.1 开发模式 - -```bash -npm run dev # electron-vite dev(HMR 热更新) -``` - -### 9.2 生产构建 - -```bash -npm run build # electron-vite build + electron-builder --win -npm run build:portable # 便携版(免安装) -``` - -### 9.3 打包配置 - -- 输出格式:NSIS 安装包(.exe) -- 目标平台:Windows x64 -- 应用图标:assets/icon.ico -- 文件关联:`.md` / `.markdown` / `.txt` -- 支持自定义安装目录、桌面/开始菜单快捷方式 - -## 10. 依赖清单 - -### 运行时依赖 - -| 包名 | 版本 | 用途 | -|------|------|------| -| react / react-dom | ^18.3 | UI 框架 | -| zustand | ^5.0 | 状态管理 | -| dexie | ^4.0 | IndexedDB 封装 | -| nanoid | ^5.0 | 唯一 ID 生成 | -| @metona-team/metona-editor | 0.1.14 | Markdown 编辑器(零依赖) | -| @metona-team/metona-toast | ^2.0.1 | Toast 通知组件 | -| unified / remark / rehype | ^11.0 | Markdown 渲染管线 | -| rehype-highlight | ^7.0 | 代码语法高亮 | - -### 开发依赖 - -| 包名 | 版本 | 用途 | -|------|------|------| -| electron | ^28.0 | 桌面框架 | -| electron-builder | ^25.0 | 打包工具 | -| electron-vite | ^3.0 | 构建工具 | -| typescript | ^5.6 | 类型系统 | -| eslint | ^9.0 | 代码检查 | +# MarkLite v0.4.3 — 架构设计文档 + +## 1. 项目概述 + +MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。 + +### 1.1 核心原则 + +1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义 +2. **模块化** — 源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types) +3. **安全隔离** — contextIsolation + nodeIntegration:false + CSP + rehype-sanitize +4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦 + +## 2. 技术架构 + +### 2.1 技术栈 + +| 组件 | 技术 | 版本 | 说明 | +|------|------|------|------| +| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | +| 前端框架 | React | v18 | 函数组件 + Hooks | +| 类型系统 | TypeScript | v5.6 | 全量类型安全 | +| 编辑器 | MetonaEditor | v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 | +| 状态管理 | Zustand | v5 | 轻量级状态管理 | +| 持久化 | MetonaSqlark (IndexedDB) | v0.4.1 | 标签页状态 / 用户设置 / 最近文件(AriaEngine) | +| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) | +| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js | +| Toast | @metona-team/metona-toast | v0.5.0 | 通知提示组件 | +| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 | +| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 | +| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 | + +### 2.2 进程架构 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Main Process (src/main/) 5 文件 │ +│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │ +│ ipc-handlers.ts 所有 ipcMain.handle 注册 │ +│ file-system.ts 文件读写、目录树构建、BOM 剥离 │ +│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │ +│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │ +└───────────────────────┬──────────────────────────────────────┘ + │ contextBridge (安全隔离) +┌───────────────────────▼──────────────────────────────────────┐ +│ Preload Script (src/preload/) 1 文件 │ +│ index.ts contextBridge 类型安全暴露 │ +│ electronAPI 22 个方法/事件的类型安全接口 │ +└───────────────────────┬──────────────────────────────────────┘ + │ +┌───────────────────────▼──────────────────────────────────────┐ +│ Renderer Process (src/renderer/) React 18 │ +│ │ +│ components/ Toolbar · TabBar · Editor · Sidebar │ +│ FileTree · OutlinePanel · WelcomeScreen │ +│ ConfirmDialog · ModifiedBanner │ +│ DropOverlay · ErrorBoundary · AboutDialog│ +│ LoadingSpinner · Icons │ +│ │ +│ stores/ (4) tabStore · editorStore · sidebarStore │ +│ autoSaveStore │ +│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │ +│ useDragDrop · useFileWatch · useAutoSave │ +│ useIpcListeners · useFileOperations ... │ +│ lib/ (4) markdown · fileUtils · errorHandler │ +│ toast · constants │ +│ db/ (4) schema · tabRepository · settingsRepo │ +│ recentFilesRepository │ +│ types/ (5) tab · file · settings · ipc · index │ +│ styles/ (3) variables · global · markdown-body │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ Shared (src/shared/) 3 文件 │ +│ ipc-channels.ts IPC 通道名常量 │ +│ types.ts 共享类型定义 │ +│ constants.ts 共享常量 (版本号、文件大小限制等) │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 2.3 安全模型 + +| 层级 | 措施 | 说明 | +|------|------|------| +| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 | +| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | +| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 | +| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 | +| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 | +| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | +| 路径 | `validatePath()` | 防止路径遍历攻击 | + +## 3. 状态管理架构 + +### 3.1 Zustand Stores + +``` +┌─────────────────────────────────────────────────────────┐ +│ App.tsx (根组件) │ +├─────────┬──────────┬──────────┬──────────────────────────┤ +│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │ +├─────────┴──────────┴──────────┴──────────────────────────┤ +│ Zustand Stores │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ +│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │ +│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │ +│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │ +│ │ - mru │ │- extMod │ │- rootPath │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │ +│ │ │ │ │ │ +│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │ +│ │ IndexedDB (MetonaSqlark) │ │ +│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 tabStore — 标签页状态 + +```typescript +interface TabState { + tabs: Tab[] // 所有标签页 + activeTabId: string | null // 当前活动标签 ID + mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换) + + createTab(filePath?, content?) // 创建标签(同文件不重复打开) + closeTab(tabId) // 关闭标签(自动切换到相邻标签) + switchToTab(tabId) // 切换标签 + updateTabContent(tabId, content) // 更新内容(标记修改) + setModified(tabId, modified) // 设置修改状态 + getActiveTab() // 获取当前标签 + updateTabScroll(tabId, scroll) // 更新滚动/光标位置 + saveToDB() // 保存到 IndexedDB(防抖 500ms) + loadFromDB() // 从 IndexedDB 加载 +} +``` + +### 3.3 editorStore — 编辑器状态 + +```typescript +interface EditorState { + viewMode: 'editor' | 'preview' | 'source' // 视图模式 + darkMode: boolean // 暗色主题 + externallyModified: { filePath: string } | null // 外部修改检测状态 + loadingStates: Record // 全局加载状态 +} + +// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例 +function getMetonaEditor(): MarkdownEditor | null +function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void +``` + +### 3.4 sidebarStore — 侧边栏状态 + +```typescript +interface SidebarState { + isVisible: boolean // 是否显示 + rootPath: string | null // 当前打开的文件夹路径 + tree: FileNode[] // 目录树数据 + expandedDirs: string[] // 已展开的目录集合 + sidebarWidth: number // 侧边栏宽度 (180~500) +} +``` + +## 4. 数据持久化 — IndexedDB + +v0.5.0: 由 Dexie.js 迁移至 MetonaSqlark(AriaEngine:自研 LSM-Tree + WAL + MVCC 存储引擎,对标 SQLite)。 +MetonaSqlark 的 create() 为异步,采用懒加载单例(getDb()), +表结构幂等创建(查表名后 defineTable),数据库名更换为 MarkLiteV2(旧 Dexie 数据已放弃)。 + +### 4.1 数据库 Schema + +```typescript +// schema.ts — 懒加载单例 +let dbPromise: Promise | null = null +export function getDb(): Promise { + if (!dbPromise) { + dbPromise = MetonaSqlark.create({ + name: 'MarkLiteV2', + 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 数据模型 + +| Store | 字段 | 说明 | +|-------|------|------| +| `tabSnapshots` | id(PK), filePath, content, scrollTop, selectionStart, selectionEnd, isModified, updatedAt(index) | 标签页状态快照 | +| `settings` | id(PK), themeMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 | +| `recentFiles` | filePath(PK), lastOpened(index) | 最近打开文件列表(v0.5.0 改用 filePath 主键,sqlark 无自增) | +| `activeTab` | id(PK), activeTabId | 当前活动标签 ID | + +## 5. IPC 通信设计 + +### 5.1 渲染进程 → 主进程(invoke) + +| 通道 | 参数 | 返回值 | 说明 | +|------|------|--------|------| +| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 | +| `file:read` | `filePath` | `ReadFileResult` | 读取文件内容 | +| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 | +| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 | +| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 | +| `file:stats` | `filePath` | `FileStatsResult` | 获取文件元信息 | +| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 | +| `tab:switched` | `filePath \| null` | `void` | 通知主进程切换活动文件 | +| `window:forceClose` | 无 | `void` | 强制关闭窗口 | +| `window:cancelClose` | 无 | `void` | 取消关闭 | +| `dir:readTree` | `dirPath` | `ReadDirTreeResult` | 递归读取目录树 | +| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 | +| `dir:watch` | `dirPath` | `void` | 监听目录变化 | +| `dir:unwatch` | 无 | `void` | 停止监听目录变化 | + +### 5.2 主进程 → 渲染进程(send) + +| 通道 | 数据 | 说明 | +|------|------|------| +| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 | +| `file:externallyModified` | `filePath` | 文件被外部修改 | +| `window:confirmClose` | 无 | 请求确认关闭 | +| `sidebar:dirChanged` | 无 | 目录结构变化 | + +## 6. 编辑器架构 — MetonaEditor v0.1.14 + +### 6.1 三模式视图 + +- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码 +- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动 +- **预览模式 (preview)**:仅显示渲染后的 HTML + +MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。 + +### 6.2 内置功能 + +| 功能 | 说明 | +|------|------| +| 格式化工具栏 | bold / italic / strikethrough / underline / code / h1-h3 / quote / ul / ol / indent / outdent / link / image / table / hr | +| 搜索替换 | Ctrl+F / Ctrl+H,支持正则、大小写敏感 | +| 历史栈 | undo / redo,防抖合并,可配置上限 | +| 主题 | light / dark / auto / warm,CSS 变量驱动 | +| 国际化 | zh-CN / en-US 完整翻译 | +| 全屏模式 | 编辑器全屏展示 | + +### 6.3 插件体系 + +通过 `plugins` 配置数组安装 MetonaEditor 预设插件: + +| 插件 | 说明 | +|------|------| +| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 | +| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 | + +> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。 + +### 6.4 渲染管线集成 + +通过 MetonaEditor 的 `render` 钩子接入 unified/rehype 管线,实现: + +- **相对路径图片解析**:将相对路径转换为 `file://` 绝对路径 +- **XSS 防护**:rehype-sanitize 过滤危险标签 +- **代码高亮**:rehype-highlight 语法高亮 +- **处理器缓存**:LRU 缓存(最多 20 个),按文件路径分桶 + +``` +Markdown 源码 + │ + ▼ +unified 管线(renderMarkdownSync) + ├── remark-parse 解析为 MDAST + ├── remark-gfm GFM 扩展 + ├── remark-rehype 转换为 HAST + ├── rehype-raw 解析内联 HTML + ├── rehype-sanitize 安全过滤 + ├── rehype-fixImages 相对路径 → file:// + ├── rehype-highlight 代码高亮 + └── rehype-stringify 序列化为 HTML + │ + ▼ +MetonaEditor 预览区渲染 +``` + +### 6.5 主题切换 + +MetonaEditor 的 CSS 样式通过 wrapper 元素上的 inline `--md-*` CSS 变量驱动。主题切换流程: + +1. `MeEditor.setTheme(dark/light)` — 更新 documentElement 全局变量 + localStorage +2. 手动覆写 `.me-wrapper` 上的 inline CSS 变量(`style.setProperty`) +3. 双向同步:应用工具栏暗色按钮 ⇄ 编辑器主题 + +### 6.6 内容同步 + +- **编辑 → 存储**:`onChange` 回调 → `updateTabContent` + `setModified` +- **标签切换**:`setValue(content, { silent: true })` 静默更新,避免重复触发 onChange +- **滚动持久化**:切换标签时通过 DOM 查询 `textarea` / `.me-preview` 保存/恢复滚动位置 + +## 7. Markdown 渲染管线 + +``` +Markdown 文本 + │ + ▼ +remark-parse 解析为 MDAST + │ + ▼ +remark-gfm 扩展 GFM 语法 + │ + ▼ +remark-rehype 转换为 HAST + │ + ▼ +rehype-raw 解析内联 HTML + │ + ▼ +rehype-sanitize 安全过滤 + │ + ▼ +rehype-fixImages 相对路径图片转 file:// URL + │ + ▼ +rehype-highlight 代码语法高亮 + │ + ▼ +rehype-stringify 序列化为 HTML + │ + ▼ +Renderer (MetonaEditor preview / Preview component) +``` + +## 8. UI 设计 + +### 8.1 色彩方案 + +#### 亮色主题 + +| 角色 | CSS 变量 | 色值 | +|------|----------|------| +| 主色调 | `--primary` | `#1a73e8` | +| 背景色 | `--bg` | `#ffffff` | +| 次级背景 | `--bg-secondary` | `#f8f9fa` | +| 文字色 | `--text` | `#333333` | +| 边框色 | `--border` | `#e1e4e8` | + +#### 暗色主题 + +| 角色 | CSS 变量 | 色值 | +|------|----------|------| +| 主色调 | `--primary` | `#8ab4f8` | +| 背景色 | `--bg` | `#1e1e1e` | +| 次级背景 | `--bg-secondary` | `#252526` | +| 文字色 | `--text` | `#d4d4d4` | +| 边框色 | `--border` | `#3e3e3e` | + +### 8.2 布局 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ MarkLite - filename.md ─ □ ✕ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ [file1.md] [file2.md] [未命名] [+] │ +├──────────┬───────────────────────────────────────────────────────────────┤ +│ 资源管理器 │ │ +│ ▼ project │ ┌─────────────────────────────────────────────┐ │ +│ 📁 src │ │ B I S U │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │ +│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │ +│ 📄 file2│ ├─────────────────────────────────────────────┤ │ +│ │ │ # Title │ Title │ │ +│ 文档大纲 │ │ │ ─────── │ │ +│ · Title │ │ content... │ content... │ │ +├──────────┴──┴─────────────────────────────────────────────┴──────────────┤ +│ (MetonaEditor 底栏: 字数/行数/阅读时间) │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## 9. 构建与发布 + +### 9.1 开发模式 + +```bash +npm run dev # electron-vite dev(HMR 热更新) +``` + +### 9.2 生产构建 + +```bash +npm run build # electron-vite build + electron-builder --win +npm run build:portable # 便携版(免安装) +``` + +### 9.3 打包配置 + +- 输出格式:NSIS 安装包(.exe) +- 目标平台:Windows x64 +- 应用图标:assets/icon.ico +- 文件关联:`.md` / `.markdown` / `.txt` +- 支持自定义安装目录、桌面/开始菜单快捷方式 + +## 10. 依赖清单 + +### 运行时依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| react / react-dom | ^18.3 | UI 框架 | +| zustand | ^5.0 | 状态管理 | +| @metona-team/metona-sqlark | 0.4.1 | 前端关系型数据库(IndexedDB) | +| nanoid | ^5.0 | 唯一 ID 生成 | +| @metona-team/metona-editor | 0.4.0 | Markdown 编辑器(零依赖) | +| @metona-team/metona-toast | 0.5.0 | Toast 通知组件 | +| unified / remark / rehype | ^11.0 | Markdown 渲染管线 | +| rehype-highlight | ^7.0 | 代码语法高亮 | + +### 开发依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| electron | ^28.0 | 桌面框架 | +| electron-builder | ^25.0 | 打包工具 | +| electron-vite | ^3.0 | 构建工具 | +| typescript | ^5.6 | 类型系统 | +| eslint | ^9.0 | 代码检查 | diff --git a/DEVSETUP.md b/DEVSETUP.md index 2d92b9f..fdd3b26 100644 --- a/DEVSETUP.md +++ b/DEVSETUP.md @@ -1,93 +1,93 @@ -# MarkLite v0.4.3 — 开发环境配置指南 - -以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。 - -## 一、Node 版本管理(nvm) - -安装 nvm-windows 后,使用以下命令管理 Node 版本: - -```bash -# 安装需要的版本(以 Node 18 为例) -nvm install 18 - -# 设为当前终端使用的版本 -nvm use 18 - -# 设为默认版本(新终端自动生效) -nvm alias default 18 -``` - -## 二、配置镜像源 - -npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。 - -### 项目级 .npmrc(推荐) - -在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容: - -``` -registry=https://registry.npmmirror.com -@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ -//git.metona.cn/api/packages/MetonaTeam/npm/:_auth= -//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true - -# Electron 二进制下载镜像 -ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ -ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ -``` - -> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。 - -如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。 - -## 三、配置文件说明 - -| 配置项 | 作用 | -|:-------|:-----| -| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 | -| `@metona-team:registry` | MetonaTeam 私有包 scope registry | -| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 | -| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 | - -## 四、开始打包 - -> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。 - -```bash -# 1. 清除旧缓存,确保不走之前的错误源 -npm cache clean --force - -# 2. 安装依赖(如果还没装或 node_modules 不完整) -npm install - -# 3. 执行构建 -npm run build -``` - -构建成功后,安装包会生成在 `dist/` 目录下。 - -## 五、一次性完整操作步骤总结 - -```bash -# ① 确保 Node 版本正确 -nvm use 18 - -# ② 在项目根目录创建 .npmrc,写入: -# registry=https://registry.npmmirror.com -# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ -# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth= -# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ -# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ - -# ③ 关闭当前终端,新开一个终端,然后执行: -npm cache clean --force -npm install -npm run build -``` - -## 六、常见问题排查 - -- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。 -- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。 -- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。 -- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。 +# MarkLite v0.5.0 — 开发环境配置指南 + +以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。 + +## 一、Node 版本管理(nvm) + +安装 nvm-windows 后,使用以下命令管理 Node 版本: + +```bash +# 安装需要的版本(以 Node 18 为例) +nvm install 18 + +# 设为当前终端使用的版本 +nvm use 18 + +# 设为默认版本(新终端自动生效) +nvm alias default 18 +``` + +## 二、配置镜像源 + +npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。 + +### 项目级 .npmrc(推荐) + +在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容: + +``` +registry=https://registry.npmmirror.com +@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ +//git.metona.cn/api/packages/MetonaTeam/npm/:_auth= +//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true + +# Electron 二进制下载镜像 +ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ +ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ +``` + +> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。 + +如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。 + +## 三、配置文件说明 + +| 配置项 | 作用 | +|:-------|:-----| +| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 | +| `@metona-team:registry` | MetonaTeam 私有包 scope registry | +| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 | +| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 | + +## 四、开始打包 + +> **关键:** 保存 `.npmrc` 后务必新开一个命令行窗口,否则环境变量不生效。 + +```bash +# 1. 清除旧缓存,确保不走之前的错误源 +npm cache clean --force + +# 2. 安装依赖(如果还没装或 node_modules 不完整) +npm install + +# 3. 执行构建 +npm run build +``` + +构建成功后,安装包会生成在 `dist/` 目录下。 + +## 五、一次性完整操作步骤总结 + +```bash +# ① 确保 Node 版本正确 +nvm use 18 + +# ② 在项目根目录创建 .npmrc,写入: +# registry=https://registry.npmmirror.com +# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ +# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth= +# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ +# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ + +# ③ 关闭当前终端,新开一个终端,然后执行: +npm cache clean --force +npm install +npm run build +``` + +## 六、常见问题排查 + +- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。 +- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。 +- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。 +- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。 diff --git a/README.md b/README.md index e09e162..11d1726 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ TypeScript React License - Version + Version

基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。
- 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。 + 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · Mermaid 图表 · 状态持久化。

--- @@ -30,16 +30,17 @@ |------|------| | 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | | 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | -| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 | +| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、浮动格式栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 | | 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 | | 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 | -| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB),编辑器主题同步切换 | +| 🌙 **暗色主题** | 一键切换亮色/暗色/暖色主题,偏好自动记忆(MetonaSqlark),编辑器主题同步切换 | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 | | 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | -| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | +| 📊 **Mermaid 图表** | 代码块中渲染 Mermaid 流程图 / 时序图 / 甘特图等 | +| 💾 **状态持久化** | 标签页状态、用户设置通过 MetonaSqlark(AriaEngine)持久化,关闭后可恢复 | | ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | | 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | | 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 | @@ -134,7 +135,9 @@ npm run test:coverage | `Ctrl + H` | 搜索并替换 | | `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 | | `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) | +| `Ctrl + E` / `Ctrl + K` / `Ctrl + Q` | 行内代码 / 链接 / 引用(编辑器内置) | | `Ctrl + 1/2/3` | 切换模式(编辑器内置) | +| `?` | 快捷键帮助面板(编辑器内置) | ## 🛠️ 技术栈 @@ -143,12 +146,12 @@ npm run test:coverage | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | | 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | | 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | -| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 | +| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.4.0 | 零依赖 Markdown 编辑器,三模式视图 + 浮动格式栏 + 插件系统 | | 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | -| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | +| 持久化 | [MetonaSqlark](https://git.metona.cn/MetonaTeam/MetonaSqlark) v0.4.1 (IndexedDB, AriaEngine) | 标签页状态 & 用户设置持久化 | | Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | | 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | -| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v2.0.1 | 通知提示组件 | +| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v0.5.0 | 通知提示组件 | | 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | | 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | | 样式 | CSS Variables | 主题驱动,亮色/暗色切换 | @@ -223,8 +226,8 @@ MarkLite/ │ │ │ ├── toast.ts # Toast 通知(MetonaToast) │ │ │ └── constants.ts # 常量定义 │ │ │ -│ │ ├── db/ # IndexedDB 持久化层 -│ │ │ ├── schema.ts # Dexie 数据库定义 +│ │ ├── db/ # MetonaSqlark 持久化层 +│ │ │ ├── schema.ts # 数据库定义(懒初始化单例) │ │ │ ├── tabRepository.ts # 标签页 CRUD │ │ │ ├── settingsRepository.ts # 设置 CRUD │ │ │ └── recentFilesRepository.ts # 最近文件 @@ -259,6 +262,7 @@ MarkLite/ - ✅ 引用块 - ✅ 水平线 - ✅ Emoji 短码 `:smile:` `:rocket:` +- ✅ Mermaid 图表 ` ```mermaid ` - ✅ HTML 内联元素 - ✅ GFM(GitHub Flavored Markdown) diff --git a/docs/metona-editor-demo.html b/docs/metona-editor-demo.html index 5281b54..c454678 100644 --- a/docs/metona-editor-demo.html +++ b/docs/metona-editor-demo.html @@ -4,7 +4,7 @@ -MetonaEditor v0.2.4 — 全功能演示 +MetonaEditor v0.4.0 — 全功能演示 + + + +
+ + +
Memory 模式 — v0.4.1
+ + +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ 📊 查询结果 + 0 行 +
+
+
+
+
执行 SQL 查询以查看结果
+
Ctrl + Enter 快捷执行 · 支持多语句
+
+
+
+
+ + + + + diff --git a/docs/metona-sqlark-docs.html b/docs/metona-sqlark-docs.html new file mode 100644 index 0000000..9b3de74 --- /dev/null +++ b/docs/metona-sqlark-docs.html @@ -0,0 +1,850 @@ + + + + + +📖 API 文档 — MetonaSqlark v0.4.1 + + + + + +
+
+ + +
+
+ +
+ + +
+ +

📦 安装

+

MetonaSqlark 支持多种引入方式,覆盖 npm、CDN、ESM、CJS 所有常见场景。

+ +

npm 安装(推荐)

+
# 配置 Gitea Registry(一次性)
+npm config set @metona-team:registry https://git.metona.cn/api/packages/MetonaTeam/npm/
+
+# 安装
+npm install @metona-team/metona-sqlark
+ +

CDN / UMD

+
<!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark -->
+<script src="https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"></script>
+<script>
+  const db = await window.MetonaSqlark.create({...});
+  // 或 window.MeSqlark.create(...)  — 完全等价
+</script>
+ +

ESM

+
import { MetonaSqlark, MeSqlark } from '@metona-team/metona-sqlark';
+// MeSqlark 是 MetonaSqlark 的别名,行为完全一致
+ +

CJS

+
const { MetonaSqlark } = require('@metona-team/metona-sqlark');
+ +

输出文件说明:

+ + + + + + + +
文件格式用途
metona-sqlark.jsUMD浏览器开发版(含 sourcemap)
metona-sqlark.min.jsUMD (minified)生产环境(~105KB / ~27KB gzip)
metona-sqlark.esm.jsES Module现代打包工具 / 浏览器 ESM
metona-sqlark.cjsCommonJSNode.js require()
metona-sqlark.d.tsTypeScript 声明类型提示
+ +

🏗 创建数据库

+

MetonaSqlark.create(config) — 工厂函数,自动创建并初始化数据库实例。

+ +
const db = await MetonaSqlark.create({
+  name: 'my-app',
+  mode: 'hybrid',       // 'memory' | 'disk' | 'hybrid' | 'aria' 🆕
+  diskEngine: 'indexeddb', // 'indexeddb' | 'opfs'(仅 disk/hybrid 生效)
+  version: 1,
+  plugins: [],            // MetonaPlugin[]
+  onReady: (db) => {},    // 就绪回调
+  onError: (err) => {},   // 错误回调
+});
+
+// 也可手动实例化
+const db2 = new MetonaSqlark(config);
+await db2.init();
+
+// 检查状态
+db.isReady();  // true
+
+// 关闭
+await db.close();
+ +

📋 定义表

+

使用 db.defineTable(name, columns) 定义表结构。

+ +
await db.defineTable('users', {
+  id:       { type: 'string',  primaryKey: true },
+  name:     { type: 'string',  required: true },
+  email:    { type: 'string',  unique: true, index: true },
+  age:      { type: 'number',  default: 0, min: 0, max: 150 },
+  active:   { type: 'boolean', default: true },
+  birthday: { type: 'date' },
+  meta:     { type: 'json' },
+  dept_id:  { type: 'number',  references: 'departments.id' },
+});
+
+// 表操作
+await db.getTableNames();         // ['users']
+await db.dropTable('users');     // 删除表
+ + + + + + + + + + + + + + +
ColumnDef 属性类型说明
type'string'|'number'|'boolean'|'date'|'json'数据类型
primaryKeyboolean主键(每表至少一个)
requiredboolean是否必填
uniqueboolean唯一约束(自动建索引)
indexboolean创建哈希索引,O(1) 加速查询
defaultunknown默认值
maxLengthnumber字符串最大长度
min/maxnumber数值范围
referencesstring外键引用 'table.column'
onDelete'CASCADE'\|'SET NULL'\|'RESTRICT'删除级联 🆕
onUpdate'CASCADE'\|'SET NULL'\|'RESTRICT'更新级联 🆕
+ +

🔍 SQL 查询

+

db.query(sql) — 执行标准 SQL 字符串,返回查询结果。

+

db.queryStream(sql, onRow) — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退物化。

+ +
// 流式查询 — 大表逐行处理
+let count = 0;
+await db.queryStream("SELECT * FROM logs WHERE level = 'error'", (row) => {
+  count++;
+  processRow(row);
+});
+
+// 派生表 / COUNT(DISTINCT) / NULLS 排序(v0.4.0)
+const top = await db.query(`SELECT dept, total FROM
+  (SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t
+  WHERE total > 100 ORDER BY total DESC`);
+await db.query("SELECT COUNT(DISTINCT city) AS n FROM users");
+await db.query("SELECT name FROM users ORDER BY age ASC NULLS FIRST");
+ +

完整 SQL 语法支持

+
// SELECT — 核心查询
+const rows = await db.query(`SELECT * FROM users
+  WHERE age > 18
+  ORDER BY name ASC
+  LIMIT 10 OFFSET 0`);
+
+// INSERT — 插入数据
+await db.query("INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)");
+await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
+// 支持多行插入
+await db.query("INSERT INTO users VALUES ('3', 'C'), ('4', 'D')");
+
+// UPDATE — 更新数据
+await db.query("UPDATE users SET age = 31, active = true WHERE id = '1'");
+
+// DELETE — 删除数据
+await db.query("DELETE FROM users WHERE id = '1'");
+
+// DDL — 表结构操作
+await db.query(`CREATE TABLE products (
+  id STRING PRIMARY KEY,
+  name STRING NOT NULL,
+  price NUMBER DEFAULT 0
+)`);
+await db.query('DROP TABLE products');
+
+-- ALTER TABLE — 动态修改表结构 (v0.2.5)
+await db.query('ALTER TABLE users ADD COLUMN phone STRING');
+await db.query('ALTER TABLE users DROP COLUMN phone');
+
+-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)
+await db.query('TRUNCATE TABLE old_logs');
+ +

SQL 扩展 (v0.3.0+)

+
// 多语句 — 分号分隔一次执行(返回最后一条结果)
+await db.query(`CREATE TABLE t (id STRING PRIMARY KEY);
+  INSERT INTO t VALUES ('1'); INSERT INTO t VALUES ('2')`);
+
+// 事务语句 — BEGIN / COMMIT / ROLLBACK
+await db.query('BEGIN');
+await db.query("INSERT INTO t VALUES ('3')");
+await db.query('ROLLBACK'); // 回滚
+
+// INSERT INTO ... SELECT — 查询结果写入
+await db.query('INSERT INTO t SELECT id FROM t2 WHERE x > 1');
+
+// UNION / UNION ALL — 合并查询
+const merged = await db.query(
+  `SELECT name FROM users WHERE city = 'Beijing'
+   UNION SELECT name FROM users WHERE age < 30`);
+
+// EXISTS / NOT EXISTS — 关联子查询
+const hasOrders = await db.query(`SELECT * FROM users u
+  WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`);
+
+// CASE WHEN — SELECT 列 / WHERE / 聚合 (v0.3.1 / v0.3.2)
+const labeled = await db.query(`SELECT name,
+  CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`);
+const adults = await db.query(`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) FROM users`);
+
+// CREATE / DROP INDEX — 动态二级索引
+await db.query('CREATE INDEX idx_users_city ON users (city)');
+await db.query('DROP INDEX idx_users_city ON users (city)');
+ +

条件表达式

+
// 比较运算符
+`WHERE age > 18 AND name LIKE 'A%'`
+`WHERE salary >= 5000 OR dept = 'Engineering'`
+
+// IN / NOT IN
+`WHERE dept IN ('Engineering', 'Sales')`
+
+// NULL 检查
+`WHERE email IS NULL`
+`WHERE email IS NOT NULL`
+
+// NOT 取反
+`WHERE NOT (age < 18 OR age > 65)`
+
+// 嵌套条件
+`WHERE (age > 18 AND active = true) OR role = 'admin'`
+ +

⛓ Query Builder

+

链式 API,TypeScript 友好,享受 IDE 自动补全。

+ +

SELECT

+
const users = db.table('users');
+
+// 全量查询
+await users.select().execute();
+
+// 指定列 + 条件 + 排序 + 分页
+const result = await users
+  .select(['name', 'age', 'email'])
+  .where({
+    age: { $gt: 18 },
+    name: { $like: 'A%' },
+  })
+  .orderBy('age', 'desc')
+  .limit(10)
+  .offset(0)
+  .execute();
+ +

INSERT

+
// 单行插入 — 返回主键值
+const pk = await users.insert({ id: '1', name: 'Alice', age: 30 });
+
+// 批量插入 — 返回主键值数组
+const pks = await users.insertMany([
+  { id: '2', name: 'Bob', age: 25 },
+  { id: '3', name: 'Charlie', age: 35 },
+]);
+ +

UPDATE / DELETE

+
// 更新 — 返回影响行数
+const updated = await users
+  .update({ age: 31, active: false })
+  .where({ id: '1' })
+  .execute();  // 1
+
+// 删除 — 返回影响行数
+const deleted = await users
+  .delete()
+  .where({ id: '1' })
+  .execute();  // 1
+
+// 全表删除
+await users.delete().execute();
+ +

🔗 JOIN 查询

+

支持 INNER / LEFT / RIGHT / CROSS JOIN,SQL 和 Query Builder 两种方式。

+ +
// SQL 方式 — 支持表别名
+await db.query(`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`);
+
+// Query Builder 方式
+await db.table('users').select()
+  .as('u')
+  .innerJoin('departments', { 'u.dept_id': { $col: 'd.id' } }, 'd')
+  .leftJoin('orders', { 'u.id': { $col: 'o.user_id' } }, 'o')
+  .execute();
+
+// CROSS JOIN — 笛卡尔积
+.crossJoin('metadata', 'm')
+ +

📊 GROUP BY & 聚合

+

五大聚合函数 + GROUP BY + HAVING + DISTINCT,完整的数据分析能力。

+ +
// GROUP BY + 聚合
+await db.query(`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`);
+
+// DISTINCT 去重
+await db.query('SELECT DISTINCT dept FROM employees');
+
+// 聚合函数别名
+await db.query('SELECT COUNT(*) AS total_users, AVG(age) AS avg_age FROM users');
+ +

🎯 WHERE 操作符

+

Query Builder 使用 $ 前缀操作符,支持逻辑组合。

+ + + + + + + + + + + + + +
操作符含义示例
直接值 / $eq等于{ age: 30 } / { age: { $eq: 30 } }
$ne不等于{ age: { $ne: 30 } }
$gt / $gte大于 / 大于等于{ age: { $gt: 18 } }
$lt / $lte小于 / 小于等于{ age: { $lt: 65 } }
$in / $nin在列表中 / 不在{ dept: { $in: ['IT','HR'] } }
$like模糊匹配{ name: { $like: 'A%' } }
$and逻辑与{ $and: [{...}, {...}] }
$or逻辑或{ $or: [{...}, {...}] }
$not逻辑非{ age: { $not: { $gt: 18 } } }
$col列引用(JOIN ON){ 'a.id': { $col: 'b.a_id' } }
+ +
// 复合条件
+.where({
+  $or: [
+    { age: { $lt: 18 } },
+    { age: { $gt: 65 } },
+  ],
+  active: true,
+  name: { $like: 'A%', $ne: 'Admin' },
+})
+ +

🔒 事务 & 回滚

+

v0.1.13 起支持真正的自动回滚:事务内任何一步失败,所有变更自动撤销。

+ +
await db.transaction(async (trx) => {
+  // trx.table() 获取事务内表操作对象
+  await trx.table('users').insert({ id: '3', name: 'Charlie' });
+  await trx.table('orders').insert({ id: 'o1', userId: '3', amount: 99 });
+  // ✅ 全部成功 → 自动 commit
+  // ❌ 任何一步失败 → 自动 rollback,数据恢复原状
+  // Memory 引擎:快照回滚 | IndexedDB 引擎:延迟写入
+});
+ +

🔍 子查询

+

v0.1.13 新增子查询支持,可在 WHERE 条件中嵌套 SELECT。

+ +
// IN 子查询 — 查询有高额订单的用户
+await db.query(`SELECT * FROM users
+  WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)`);
+
+// 标量子查询 — 查询年龄等于平均年龄的用户
+await db.query(`SELECT * FROM users
+  WHERE age = (SELECT AVG(age) FROM users)`);
+
+// NOT IN 子查询
+await db.query(`SELECT * FROM users
+  WHERE id NOT IN (SELECT user_id FROM orders)`);
+ +

🏗 ALTER TABLE (🆕 v0.2.5)

+

v0.2.5 新增 ALTER TABLE 语法,支持动态添加和删除列。

+ +

ADD COLUMN

+
-- 添加新列
+await db.query('ALTER TABLE users ADD COLUMN phone STRING');
+
+-- 带约束的添加
+await db.query('ALTER TABLE users ADD COLUMN email STRING UNIQUE');
+
+-- 带可选 COLUMN 关键字
+await db.query('ALTER TABLE users ADD COLUMN age NUMBER DEFAULT 0');
+ +

DROP COLUMN

+
-- 删除列
+await db.query('ALTER TABLE users DROP COLUMN phone');
+
+-- 带可选 COLUMN 关键字
+await db.query('ALTER TABLE users DROP COLUMN email');
+ + + + + +
语法说明
ALTER TABLE name ADD COLUMN col type [constraints]添加列(COLUMN 可选)
ALTER TABLE name DROP COLUMN col删除列(COLUMN 可选)
+ +

🗑 TRUNCATE TABLE (🆕 v0.2.5)

+

v0.2.5 新增 TRUNCATE TABLE 语法,快速清空表数据(保留表结构)。

+ +
-- 快速清空表数据
+await db.query('TRUNCATE TABLE old_logs');
+
+-- 等价于 DELETE FROM old_logs,但语义更清晰
+ + + + +
语法说明
TRUNCATE TABLE name清空表数据,保留表结构
+ +

🔗 外键级联

+

v0.1.13 支持外键级联操作,定义表时可指定 ON DELETE / ON UPDATE 行为。

+ +
// 定义时指定外键 + 级联策略
+await db.defineTable('orders', {
+  id: { type: 'string', primaryKey: true },
+  user_id: {
+    type: 'string',
+    references: 'users.id',
+    onDelete: 'CASCADE',  // 删除用户时级联删除订单
+    onUpdate: 'RESTRICT', // 禁止更新被引用的用户 ID
+  },
+  amount: { type: 'number' },
+});
+
+// SQL DDL 同样支持
+await db.query(`CREATE TABLE orders (
+  id STRING PRIMARY KEY,
+  user_id STRING REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
+  amount NUMBER
+)`);
+
+// 删除用户 → 其所有订单自动删除
+await db.query("DELETE FROM users WHERE id = '1'");
+ + + + + + +
级联选项行为
CASCADE级联删除/更新子表中的匹配行
SET NULL将子表中的外键列设为 NULL
RESTRICT禁止操作(默认行为)
+ +

🏊 连接池

+

v0.1.13 新增连接池管理器,避免重复创建同名数据库实例。

+ +
// connect() — 获取或创建实例(单例复用)
+const db1 = await MetonaSqlark.connect({ name: 'my-app', mode: 'hybrid' });
+const db2 = await MetonaSqlark.connect({ name: 'my-app' });
+// db1 === db2 — 复用已有实例,避免重复 open IndexedDB
+
+// disconnect() — 释放连接(引用计数 -1)
+await db2.disconnect(); // 引用计数: 2 → 1
+await db1.disconnect(); // 引用计数: 1 → 0,自动 close()
+
+// disconnectAll() — 强制关闭所有连接
+await MetonaSqlark.disconnectAll();
+
+// getActiveConnections() — 查看活跃连接
+MetonaSqlark.getActiveConnections(); // ['my-app']
+ +

🔄 数据迁移

+

按版本号管理表结构变更。

+ +
// 注册迁移
+db.addMigration(2, async (db) => {
+  await db.defineTable('products', {
+    id: { type: 'string', primaryKey: true },
+    name: { type: 'string', required: true },
+  });
+});
+
+db.addMigration(3, async (db) => {
+  // 添加新列、数据迁移等
+  await db.query("UPDATE users SET role = 'user' WHERE role IS NULL");
+});
+
+// 执行迁移到目标版本
+await db.migrateTo(3);  // 依次执行 v2, v3 的迁移函数
+ +

📤 导入导出

+
// 导出单表 — 返回 JSON 数组
+const userData = await db.exportTable('users');
+// [{ id: '1', name: 'Alice', ... }, ...]
+
+// 导出全库 — 返回 { tableName: rows[] }
+const allData = await db.exportAll();
+// { users: [...], orders: [...], products: [...] }
+
+// 导入数据 — 返回主键列表
+const pks = await db.importTable('users', userData);
+ +

🧩 插件 & 钩子

+

14 种生命周期钩子,支持插件机制。

+ + + + + + + + + + + + + + + + + +
钩子名称触发时机参数
beforeCreateTable创建表前schema
afterCreateTable创建表后schema
beforeDropTable删除表前tableName
afterDropTable删除表后tableName
beforeInsert插入前rows[]
afterInsert插入后rows[]
beforeUpdate更新前query, updates
afterUpdate更新后query, updates, count
beforeDelete删除前query
afterDelete删除后query, count
beforeQuerySQL 查询前sql
afterQuerySQL 查询后sql, result
beforeTransaction事务开始前-
afterTransaction事务完成后-
+ +
// 注册钩子
+db.on('beforeInsert', async (row) => {
+  console.log('即将插入:', row);
+  // 可在此校验、转换数据
+});
+
+db.on('afterQuery', async (sql, result) => {
+  console.log(`查询完成 [${sql}] → ${(result as any[]).length} 行`);
+});
+
+// 注册自定义插件
+const loggerPlugin = {
+  name: 'logger',
+  version: '1.0.0',
+  description: '记录所有数据库操作',
+  priority: 100,
+  install(db) {
+    db.on('beforeQuery', (sql) => console.log('SQL:', sql));
+  },
+  destroy() { /* 清理 */ },
+};
+
+// 在 create 配置中注册
+const db = await MetonaSqlark.create({
+  name: 'my-app',
+  plugins: [loggerPlugin],
+});
+ +

📡 发布订阅

+
// 订阅表变更
+const unsubscribe = db.subscribe('users', (event) => {
+  // event.type: 'insert' | 'update' | 'delete'
+  // event.row:  被操作的行数据
+  console.log(`users 表 ${event.type}`, event.row);
+});
+
+// 手动触发变更
+db.emit('users', { type: 'insert', row: { id: '1', name: 'Alice' } });
+
+// 取消订阅
+unsubscribe();
+ +

多标签页同步 (v0.3.2)

+
// 启用 multiTabSync 后,其他标签页的写操作会广播到此标签页
+const db = await MetonaSqlark.create({
+  name: 'my-app',
+  mode: 'hybrid',
+  multiTabSync: true,
+});
+
+// 订阅其他标签页的变更(event.type === 'external')
+db.subscribe('users', (event) => {
+  if (event.type === 'external') {
+    // Hybrid 模式已自动从磁盘重载,此处可刷新 UI
+    refreshList();
+  }
+});
+
+// 手动广播(Table API 已自动广播;自定义写入可调用)
+db.broadcastChange('users');
+ +

⚛️ React 集成

+
import { useQuery, useTable, useDatabase } from '@metona-team/metona-sqlark/react';
+import { db } from './db';
+
+function UserList() {
+  // 执行 SQL 查询,自动响应 db 变化
+  const { data, loading, error, refresh } = useQuery(
+    db,
+    'SELECT * FROM users WHERE age > 18',
+    [/* deps */]
+  );
+
+  if (loading) return <div>Loading...</div>;
+  if (error) return <div>Error: {error.message}</div>;
+
+  return (
+    <div>
+      {data.map(u => <div key={u.id}>{u.name} ({u.age})</div>)}
+      <button onClick={refresh}>刷新</button>
+    </div>
+  );
+}
+
+// 便捷 hook — 查询整张表
+const { data, loading, refresh } = useTable(db, 'users');
+
+// 管理数据库生命周期
+function App() {
+  const { db, ready, error } = useDatabase({
+    name: 'my-app',
+    mode: 'hybrid',
+  });
+  if (!ready) return <div>Initializing...</div>;
+  return <UserList />;
+}
+ +

🟢 Vue 集成

+
import { useSqlarkQuery, useSqlarkTable, useSqlarkDatabase }
+  from '@metona-team/metona-sqlark/vue';
+import { db } from './db';
+
+// useSqlarkQuery — 执行 SQL 查询
+const { data, loading, error, refresh } = useSqlarkQuery(
+  db,
+  'SELECT * FROM users WHERE age > 18'
+);
+
+// useSqlarkTable — 获取全表数据
+const { data, loading, refresh } = useSqlarkTable(db, 'users');
+
+// useSqlarkDatabase — 管理数据库生命周期
+const { db, ready, error } = useSqlarkDatabase({
+  name: 'my-app',
+  mode: 'hybrid',
+});
+ +

🔷 TypeScript 泛型

+
interface User {
+  id: string;
+  name: string;
+  age: number;
+  email?: string;
+}
+
+// 泛型表操作 — 类型安全的 insert/select
+const users = db.table<User>('users');
+
+// ✅ 类型检查通过
+await users.insert({ id: '1', name: 'Alice', age: 30 });
+
+// ❌ TypeScript 报错:缺少 name
+// await users.insert({ id: '2', age: 25 });
+ +

⚙️ 完整配置项

+ + + + + + + + + + + + +
属性类型默认值说明
namestring'metona-sqlark'数据库名称(必填)
mode'memory'|'disk'|'hybrid'|'aria''hybrid'存储模式 🆕 aria
diskEngine'indexeddb'|'opfs''indexeddb'磁盘引擎类型
versionnumber1数据库版本号
pluginsMetonaPlugin[][]初始插件列表
onReady(db) => void-初始化完成回调
onError(err) => void-错误回调(v0.2.5 接入执行路径)
maxRowsPerQuerynumber0查询结果行数上限(0=不限制)✅ v0.2.5 生效
multiTabSyncbooleanfalse多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2
+ +

💾 存储引擎

+ + + + + + + + +
引擎模式持久化索引事务适用场景
MemoryEnginememory哈希快照回滚临时数据、缓存、测试
IndexedDBEnginedisk✅ IDBIDB 索引延迟写入通用持久化,兼容性最好
OPFSEnginedisk✅ OPFS哈希快照回滚现代浏览器,文件级存储
HybridEnginehybrid✅ Write-Through哈希双引擎代理生产推荐,读写均走内存
AriaEnginearia✅ WAL + SSTableLSM-TreeMVCC 快照隔离自研引擎:大表、高并发、需崩溃恢复
+ +

🌲 AriaEngine 自研存储引擎

+

v0.2.0 新增 — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。
+v0.2.4 生产级 — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。
+v0.3.2 表达式与并发 — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。
+v0.4.1 Aria 级联与演示页引擎切换 — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。

+ +

存储模式对比

+ + + + + + + +
特性MemoryDisk(IDB)Disk(OPFS)HybridAria
持久化✅ IDB✅ OPFS✅ 双写✅ 后端决定
事务✅ 快照✅ 原子✅ 快照✅ 双引擎✅ MVCC
索引HashHashHashHashLSM二级
上限内存~2GB磁盘~2GB内存
浏览器全部全部Chromium全部全部
+ +

核心特性

+ + + + + + + + + +
特性说明
LSM-Tree 索引MemTable (红黑树) + 多级 SSTable,写优化,支持范围扫描
Slotted Page 格式4KB 固定页面,Slot Directory + Tuple 二进制序列化
Buffer PoolLRU 页面缓存,可控内存占用(默认 256 页 ≈ 1MB)
WAL 日志Write-Ahead Log 保证崩溃恢复,支持 full/batch/none 三种同步模式(full 模式真正同步 ✅ v0.2.5),16MB 阈值自动 checkpoint
MVCC 事务快照隔离 (Snapshot Isolation),读写不互斥,版本链 + GC,读写路径接入版本链 ✅ v0.2.5
Bloom Filter快速判定 key 不存在,减少无效磁盘 I/O,SSTableReader 二分查找统一 ✅ v0.2.5
LZ4 压缩可选页面级压缩,空间效率提升
+ +

使用方式

+
// 激活 AriaEngine
+	const db = await MetonaSqlark.create({
+	  name: 'my-app',
+	  mode: 'aria',             // 🆕 AriaEngine 模式
+	  diskEngine: 'indexeddb',   // 底层存储后端(indexeddb | opfs | memory)
+	});
+
+	// 或直接实例化 — 支持细粒度配置
+	import { AriaEngine } from '@metona-team/metona-sqlark';
+	const engine = new AriaEngine({
+	  pageSize: 4096,               // 页面大小
+	  bufferPoolPages: 256,       // 缓存页数
+	  memtableSizeThreshold: 4194304, // MemTable 刷盘阈值 4MB
+	  walEnabled: true,            // 启用 WAL
+	  walSyncMode: 'batch',        // 'full' | 'batch' | 'none'
+	  storageBackend: 'indexeddb', // 存储后端
+	});
+ +

AriaEngine 配置项

+ + + + + + + + + + + + + +
属性类型默认值说明
pageSizenumber4096页面大小(字节)
bufferPoolPagesnumber256Buffer Pool 页面数量
memtableSizeThresholdnumber4194304MemTable 刷盘阈值(字节)
levelSizeMultipliernumber10LSM 层级容量倍数
bloomFilterBitsPerKeynumber10Bloom Filter 每 key 位数
walEnabledbooleantrue是否启用 WAL
walSyncMode'full'|'batch'|'none''batch'WAL 同步策略(full 模式真正同步 ✅ v0.2.5)
checkpointIntervalnumber1000Checkpoint 触发间隔(操作数)
walSizeThresholdnumber16777216WAL 大小阈值(字节),超阈值触发 checkpoint ✅ v0.2.5
compressionbooleanfalse是否启用页面压缩
storageBackend'indexeddb'|'opfs'|'memory''indexeddb'存储后端类型
+ +

⚠️ 错误处理

+

所有错误抛出 DatabaseError 实例。

+ +
try {
+  await db.query('SELECT * FROM nonexistent');
+} catch (err) {
+  if (err instanceof DatabaseError) {
+    console.log(err.message);  // 'Table "nonexistent" does not exist'
+    console.log(err.code);    // 'TABLE_NOT_FOUND'
+    console.log(err.details); // 附加信息
+  }
+}
+ + + + + + + + + + + + + + + +
错误码触发场景
TABLE_NOT_FOUND表不存在
TABLE_EXISTS表已存在
DUPLICATE_KEY主键重复
UNIQUE_VIOLATION唯一约束冲突
VALIDATION_ERROR数据校验失败
TYPE_ERROR字段类型错误
SCHEMA_ERROR表结构定义错误
DB_NOT_READY数据库未初始化
PARSE_ERRORSQL 语法错误
TRANSACTION_ERROR事务执行失败
COMPILE_ERROR编译 AST 到查询计划失败
CONFIG_ERROR配置错误
+ +
+
+ + + + diff --git a/docs/metona-toast-demo.html b/docs/metona-toast-demo.html index cc0e8ea..96a16f1 100644 --- a/docs/metona-toast-demo.html +++ b/docs/metona-toast-demo.html @@ -7,51 +7,78 @@ 在线演示 — MetonaToast
-

在线演示

+

在线演示

覆盖项目全部功能的交互演示 — 点击即可体验

@@ -81,10 +108,10 @@
- + - +
@@ -92,16 +119,17 @@
-
🔄 Loading 链式转换
+
🔄 Loading 链式转换 id 稳定
- - - - + + + +
+

转换后返回的 toast 与 loading 是同一个实例(id 不变)

- +
💬 对话框
@@ -116,8 +144,8 @@
📊 进度 & 倒计时
- - + +
@@ -136,7 +164,7 @@
-
⚡ Action Toast
+
⚡ Action Toast close:false 不关闭
@@ -146,7 +174,7 @@
-
📁 分组管理
+
📁 分组管理
@@ -157,7 +185,7 @@
-
🎭 11种动画效果
+
🎭 11 种动画效果
@@ -171,9 +199,14 @@
+
+
+ +
+
- +
📍 全部位置
@@ -186,7 +219,7 @@
- +
🎨 主题切换 & 自定义
@@ -194,24 +227,43 @@ - +
- +
-
🔌 插件系统
+
🔌 插件系统 4 款预设
- - - + + + +
+
+
+ +
+
- +
-
🔥 高级特性 resetTimer · onUpdate · render · notify
+
🔗 钩子系统 beforeShow 拦截演示
+
+ + + + + +
+

钩子:未注册

+
+ + +
+
🔥 高级特性
@@ -220,7 +272,7 @@
- +
🔧 Toast 管理
@@ -230,7 +282,7 @@ - +
@@ -238,32 +290,34 @@ +
- +
-
🛡️ onError 错误捕获 v0.2.1
+
🛡️ onError 错误捕获
- +
✋ 交互特性
- - - - + + + + +
- +
🌍 国际化 & 格式化
@@ -273,16 +327,19 @@
- +
-
🎨 107种预设图标 — 点击任意图标发送Toast
-
+
🎨 107 种预设图标 — 点击任意图标发送 Toast
+
@@ -290,27 +347,41 @@ MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true }); // === Loading === - function demoLoading() { const l=MeToast.loading('正在提交...'); setTimeout(()=>l.success('提交成功!'),2000); } - function demoLoadingFail() { const l=MeToast.loading('正在处理...'); setTimeout(()=>l.error('处理失败'),2000); } - function demoLoadingWarn() { const l=MeToast.loading('检查中...'); setTimeout(()=>l.warning('发现潜在问题'),2000); } - 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); } + function demoLoading(mode) { + const l = MeToast.loading('正在处理...'); + const showId = () => document.getElementById('loading-status').textContent = 'loading id: ' + l.id; + showId(); + setTimeout(() => { + const map = { success:['提交成功!','success'], error:['处理失败','error'], warning:['发现潜在问题','warning'], update:null }; + if (mode === 'update') { + l.update({ message: '进度 50%' }); + setTimeout(() => l.success('完成!'), 1000); + return; + } + const [msg, type] = map[mode]; + const t = l[type](msg); + if (t) document.getElementById('loading-status').textContent = '转换后 id: ' + t.id + '(' + (t.id === l.id ? '同一实例 ✓' : '不同实例 ✗') + ')'; + }, 1600); + } // === Promise / confirm / prompt === async function demoPromise() { - const p=new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000)); - try{await MeToast.promise(p,{loading:'处理中...',success:'成功!',error:'失败'});}catch(_){} + const p = new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000)); + try{ await MeToast.promise(p,{loading:'处理中...',success:'成功!',error:'失败'}); }catch(_){} } async function demoConfirm() { const ok=await MeToast.confirm('确定删除?',{confirmText:'删除',confirmColor:'#ef4444'}); if(ok)MeToast.success('已删除'); } async function demoPrompt() { const v=await MeToast.prompt('请输入姓名',{placeholder:'请输入...'}); if(v) MeToast.info('你好,'+v+'!'); } async function demoPromptPassword() { const v=await MeToast.prompt('请输入密码',{placeholder:'密码',inputType:'password',submitText:'登录'}); if(v) MeToast.info('密码已输入'); } // === Progress & Countdown === - function demoProgress() { 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 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); } + function demoProgress(dir) { + const p=MeToast.progress('上传中...',{progressColor: dir==='vertical'?'#10b981':'#3b82f6', progressDirection: dir}); + let v=0; const t=setInterval(()=>{v+=Math.random()*25; if(v>=100){clearInterval(t);p.complete('上传完成!');}else p.setProgress(v);},400); + } function demoCountdown() { MeToast.countdown('{seconds} 秒后执行',5,{onComplete:()=>MeToast.success('已执行')}); } function demoCountdownPause() { const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')}); - setTimeout(()=>{c.pause();MeToast.warning('已暂停3秒后恢复',{duration:3000})},4000); + setTimeout(()=>{c.pause();MeToast.warning('已暂停 3 秒后恢复',{duration:3000})},4000); setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000); } @@ -321,14 +392,15 @@ setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500); } function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); } - function demoStackMixed() { - MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150}); - } + function demoStackMixed() { MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150}); } // === Action === function demoActionDelete() { MeToast.action('文件 data.json 已删除',[{text:'撤销',onClick:()=>MeToast.success('已撤销'),color:'#3b82f6'}]); } function demoActionMulti() { MeToast.action('邮件已发送',[{text:'查看',onClick:()=>MeToast.info('打开邮件详情'),color:'#3b82f6'},{text:'撤回',onClick:()=>MeToast.warning('已撤回'),color:'#f59e0b'}]); } - function demoActionNoClose() { 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 === const group = MeToast.group('demo-group'); @@ -342,32 +414,71 @@ // === Animation === function anim(name) { MeToast.success('动画: '+name,{animation:name,duration:3000,showProgress:false}); } + function demoCustomAnim() { + MeToast.animations.register('swing', { + enter: { transform: 'rotate(-40deg) scale(0.3)', opacity: 0 }, + leave: { transform: 'rotate(20deg) scale(0.5)', opacity: 0 }, + duration: 600, + easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + }); + MeToast.success('自定义 swing 动画!', { animation: 'swing', duration: 3000 }); + } // === Theme === function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); } function demoRegisterTheme() { try { MeToast.themes.registerTheme('sunset',{bg:'rgba(255,248,240,0.96)',text:'#92400e',border:'rgba(249,115,22,0.2)',shadow:'0 10px 36px -10px rgba(249,115,22,0.2)',hoverShadow:'0 14px 48px -10px rgba(249,115,22,0.3)',progressBg:'rgba(249,115,22,0.1)',closeHoverBg:'rgba(249,115,22,0.1)'}); - MeToast.themes.switchTheme('sunset'); - MeToast.configure({theme:'sunset'}); + MeToast.themes.switchTheme('sunset'); MeToast.configure({theme:'sunset'}); MeToast.success('已切换到 sunset 主题',{theme:'sunset'}); } catch(_) { MeToast.warning('主题已注册,已切换'); } } // === Plugins === function demoCustomPlugin() { - MeToast.use({name:'click-counter',version:'1.0',install(){let c=0;Toast.on('afterShow',t=>{c++;setTimeout(()=>t.update({message:t.message.replace(/\\(\\d+\\)/,'')+' (总通知#'+c+')'}),1);});}}); + MeToast.use({name:'click-counter',version:'1.0',install(){let c=0;Toast.on('afterShow',t=>{c++;setTimeout(()=>t.update({message:t.message.replace(/\(\d+\)/,'')+' (总通知#'+c+')'}),1);});}}); MeToast.info('自定义插件已安装(消息后追加计数)'); } + function demoDedupe() { + if (!MeToast.plugins.has('dedupe')) MeToast.use('dedupe'); + MeToast.info('同一条消息'); + MeToast.info('同一条消息'); + MeToast.info('同一条消息'); + MeToast.info('当前仅 ' + MeToast.count() + ' 条 — 相同消息自动合并'); + } + + // === Hooks === + let hookOff = null; + function demoHookBlock() { + if (hookOff) hookOff(); + hookOff = Toast.on('beforeShow', (toast) => { + if (toast.type === 'error') { document.getElementById('hook-status').textContent = '钩子:error 已被拦截 ✋'; return false; } + }); + document.getElementById('hook-status').textContent = '钩子:已注册(error 类型将被拦截)'; + MeToast.info('拦截器已开启 — 试试触发 error'); + } + function demoHookUnblock() { + if (hookOff) { hookOff(); hookOff = null; } + document.getElementById('hook-status').textContent = '钩子:已移除'; + MeToast.info('拦截器已关闭'); + } + function demoHookCount() { + const stats = []; + ['afterShow','afterClose','configChange','themeChange'].forEach(name => { + const list = Toast._hooks.get(name); + stats.push(name + ':' + (list ? list.length : 0)); + }); + MeToast.info('钩子数量 — ' + stats.join(' '), { duration: 5000 }); + } // === 高级特性 === function demoResetTimer() { - const t=MeToast.info('这条消息不会自动消失(resetTimer)',{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); + const t=MeToast.info('这条不会自动消失(每次更新重置 3 秒)',{duration:3000,resetTimerOnUpdate:true,closeButton:true}); + let n=0; const iv=setInterval(()=>{n++;if(n>8){clearInterval(iv);t.close();return;}t.update({message:'重置计时器 #'+n});},1000); } - function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); 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() { - MeToast.show({render:toast=>'
🟢 '+toast.message+'
# custom render by thzxx
',message:'自定义渲染内容',duration:5000}); + MeToast.show({render:toast=>'
🟢 '+toast.message+'
# custom render
',message:'自定义渲染内容',duration:5000}); } function demoNotify() { if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;} @@ -382,48 +493,38 @@ } function demoGetAll() { const all=MeToast.getAll(); MeToast.info('当前共 '+all.size+' 条 toast'); } function demoClearByPos() { MeToast.error('右下角临时消息',{position:'bottom-right',duration:3000}); setTimeout(()=>{MeToast.clear('bottom-right');MeToast.info('右下角已清除');},500); } - function demoGetConfig() { 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 方法');} } - - // === 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: '' - }); - } + function demoGetConfig() { const c=MeToast.getConfig(); MeToast.info('配置: '+JSON.stringify({position:c.position,duration:c.duration,theme:c.theme,animation:c.animation,locale:c.locale}).replace(/"/g,''),{duration:5000}); } + function demoRemove() { const t=MeToast.warning('这条将被立即移除(无动画)'); setTimeout(()=>{MeToast.removeToast(t.id);MeToast.info('已立即移除');},800); } // === onError === function demoOnError() { - MeToast.configure({ - onError: function(info) { - MeToast.error('⚠️ 捕获错误: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' }); - } - }); - MeToast.info('已启用 onError 监控(钩子/定时器异常将被捕获)', { duration: 3000 }); - // 触发一个钩子错误来演示 + MeToast.configure({ onError: function(info) { MeToast.error('⚠️ 捕获: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' }); } }); + MeToast.info('已启用 onError 监控', { duration: 3000 }); var badFn = function() { throw new Error('demo-error'); }; Toast.on('afterShow', badFn); - MeToast.success('这条消息会触发错误', { duration: 2000 }); + MeToast.success('这条会触发错误回调', { duration: 2000 }); setTimeout(function() { Toast.off('afterShow', badFn); }, 3000); } - function demoOnErrorReset() { - MeToast.configure({ onError: null }); - MeToast.info('onError 已重置为 null'); + function demoOnErrorReset() { MeToast.configure({ onError: null }); MeToast.info('onError 已重置'); } + + // === Interaction === + function demoDraggable() { MeToast.info('← 拖动我超过 120px 关闭',{draggable:true,duration:8000,closeButton:true}); } + function demoDragThreshold() { MeToast.info('← 拖动阈值 300px(要拖更远)',{draggable:true,dragThreshold:300,duration:8000,closeButton:true}); } + function demoPauseHover() { MeToast.info('悬停时倒计时会暂停',{pauseOnHover:true,duration:8000}); } + function demoCloseOnClick() { MeToast.warning('点击我即可关闭',{closeOnClick:true,duration:10000}); } + function demoWidth() { MeToast.info('宽 550px',{width:550}); setTimeout(()=>MeToast.info('宽 280px(窄)',{width:280}),500); } + function demoClassName() { MeToast.success('带自定义 className',{className:'demo-custom-class'}); } + function demoCustomIcon() { + MeToast.info('通用通知', { iconHTML: '' }); } // === i18n === function switchLocale(loc) { - try{MeToast.i18n.switchLocale(loc);MeToast.configure({locale:loc});MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English',{theme:'dark'});} - catch(e){MeToast.info('已切换语言');} + MeToast.i18n.switchLocale(loc); MeToast.configure({locale:loc}); + MeToast.success(loc==='zh-CN'?'已切换到中文':'Switched to English'); } function demoFormat() { - try{ - 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');} + MeToast.info('数字: '+MeToast.i18n.formatNumber(1234567)+' | 货币: '+MeToast.i18n.formatCurrency(99.5,'CNY')+' | 日期: '+MeToast.i18n.formatDate(new Date()),{duration:8000}); } // === 图标网格 === diff --git a/docs/metona-toast-docs.html b/docs/metona-toast-docs.html index fdb48c9..000e275 100644 --- a/docs/metona-toast-docs.html +++ b/docs/metona-toast-docs.html @@ -7,448 +7,442 @@ API 文档 — MetonaToast

API 文档

-

MetonaToast v0.2.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 配置项 作为可选第二参数。

+

MetonaToast v0.5.0 完整 API 参考。所有基础通知方法支持字符串与对象两种调用形式,均可传入任何配置项作为可选参数。

show(message, opts?)

-

显示默认类型的 Toast 通知,无特定颜色和图标。

- - - - -
参数类型默认值说明
messagestring | object消息字符串,或包含 title/message 等属性的配置对象
optsobject{}可选配置对象,覆盖 全部配置项。仅当 message 为字符串时有效
+

显示默认类型 Toast,无特定颜色与图标。

+
show.js
// 字符串形式
 MeToast.show('默认消息');
-MeToast.show('自定义', { duration: 2000, position: 'bottom-center' });
-// 对象形式(所有 opts 作为一级属性)
-MeToast.show({ title: '标题', message: '内容', duration: 3000 });
+MeToast.show('自定义', { duration: 2000, position: 'bottom-center' }); +// 对象形式 +MeToast.show({ title: '标题', message: '内容', duration: 3000 });

success(message, opts?)

-

显示成功通知。绿色对勾图标 #10b981,type = success。

- - - - -
参数类型默认值说明
messagestring | object消息字符串或配置对象
optsobject{}可选配置覆盖。type 固定为 success
+

绿色对勾图标 #10b981,type = success。

+
success.js
MeToast.success('保存成功!');
 MeToast.success({ title: '已保存', message: '数据已同步' });
-Met.success('MeToast和Met等价');
+Met.success('Met 与 MeToast 等价');

error(message, opts?)

-

显示错误通知。红色叉号图标 #ef4444,type = error。aria-live 设为 assertive。

- - - - -
参数类型默认值说明
messagestring | object消息字符串或配置对象
optsobject{}可选配置覆盖。type 固定为 error
+

红色叉号图标 #ef4444,type = error。aria-live = assertive。

+
error.js
MeToast.error('网络错误,请重试');
-MeToast.error({ title: '提交失败', message: '服务器不可达' });
+MeToast.error({ title: '提交失败', message: '服务器不可达' });

warning(message, opts?)

-

显示警告通知。黄色三角图标 #f59e0b,type = warning。

- - - - -
参数类型默认值说明
messagestring | object消息字符串或配置对象
optsobject{}可选配置覆盖。type 固定为 warning
-
MeToast.warning('请注意检查输入内容');
+

黄色三角图标 #f59e0b,type = warning。

+
warning.js
+
MeToast.warning('请注意检查输入内容');

info(message, opts?)

-

显示信息通知。蓝色圆形图标 #3b82f6,type = info。

- - - - -
参数类型默认值说明
messagestring | object消息字符串或配置对象
optsobject{}可选配置覆盖。type 固定为 info
-
MeToast.info('系统将于 22:00 维护');
+

蓝色圆形图标 #3b82f6,type = info。

+
info.js
+
MeToast.info('系统将于 22:00 维护');

loading(message, opts?)

-

显示加载状态。type = loading,duration 强制为 0(不自动关闭),closeButton 和 showProgress 强制为 false。返回 LoadingControl 对象。

- - - - -
参数类型默认值说明
messagestring | object加载提示文本或配置对象
optsobject{}可选配置覆盖
+

type = loading,duration 强制 0(不自动关闭)。返回 LoadingControl。

+
loading.js
const loading = MeToast.loading('正在提交...');
-setTimeout(() => loading.success('提交成功!'), 2000);
+setTimeout(() => loading.success('提交成功!'), 2000);
-

返回 LoadingControl

+

LoadingControl(链式转换 id 稳定)

- - - - - - - + + + +
方法签名说明
success(msg, opts?) => ToastInstance关闭加载 toast,原地替换为 success 类型
error(msg, opts?) => ToastInstance替换为 error 类型
info(msg, opts?) => ToastInstance替换为 info 类型
warning(msg, opts?) => ToastInstance替换为 warning 类型
update(partial) => MeToast不关闭加载 toast,原地更新其内容(支持 resetTimerOnUpdate)
dismiss()直接关闭加载 toast 不替换
方法说明
success/error/info/warning原地更新为对应类型(同一实例,id 不变),duration 自动恢复默认值
update(partial)原地更新内容,不改变类型
dismiss()直接关闭,不转换

promise(promise, opts)

-

监听 Promise 生命周期。自动显示 loading → 根据 resolve/reject 自动切换 success/error。返回原 Promise,支持 await 获取结果。

+

监听 Promise 生命周期,自动 loading → resolve/reject 切换 success/error,返回原 Promise。

- - - - - + + +
参数类型默认值说明
promisePromise要监听的 Promise 对象。非 Promise 会打印错误并返回 rejected Promise
opts.loadingstring"加载中..."加载中显示的文本
opts.successstring"操作成功"resolve 后显示的文本
opts.errorstring"操作失败"reject 后显示的文本
参数类型默认说明
promisePromise要监听的 Promise。非 Promise 打印错误并返回 rejected Promise
opts.loading / success / errorstring加载中... / 操作成功 / 操作失败各阶段文本
+
promise.js
try {
   await MeToast.promise(fetch('/api/data'), {
-    loading: '加载中...',
-    success: '加载完成!',
-    error: '加载失败',
+    loading: '加载中...', success: '完成!', error: '失败',
   });
-} catch (e) { /* promise reject 会继续抛出 */ }
+} catch (e) { /* reject 会继续抛出 */ }

confirm(message, opts?)

-

确认对话框。返回 Promise<boolean>。内置 10 秒安全超时,超时自动 resolve(false)。

+

确认对话框,返回 Promise<boolean>,内置 10 秒安全超时自动 resolve(false)。

- - - - - - - + + + +
参数类型默认值说明
messagestring对话框消息。非字符串会打印错误并 resolve(false)
opts.confirmTextstring"确认"确认按钮文字
opts.confirmColorstring"#10b981"确认按钮背景色
opts.cancelTextstring"取消"取消按钮文字
opts.cancelColorstring"#6b7280"取消按钮背景色
opts.typestring"warning"Toast 类型(影响图标和颜色)
参数默认说明
confirmText / confirmColor确认 / #10b981确认按钮文字与颜色
cancelText / cancelColor取消 / #6b7280取消按钮文字与颜色
typewarningToast 类型
-
const ok = await MeToast.confirm('确定删除?', {
-  confirmText: '删除',
-  confirmColor: '#ef4444',
-  cancelText: '保留',
-});
-if (ok) MeToast.success('已删除');
+
confirm.js
+
const ok = await MeToast.confirm('确定删除?', { confirmText: '删除', confirmColor: '#ef4444' });
+if (ok) MeToast.success('已删除');

prompt(message, opts?)

-

输入对话框。返回 Promise<string|null>。按 Enter 或点击提交按钮返回输入值,取消返回 null。内置 10 秒安全超时。

+

输入对话框,返回 Promise<string|null>。Enter / 提交返回输入值,取消返回 null,10 秒超时。

- - - - - - - - - - + + + + +
参数类型默认值说明
messagestring对话框消息。非字符串会打印错误并 resolve(null)
opts.placeholderstring""输入框占位文字
opts.defaultValuestring""输入框默认值
opts.inputTypestring"text"input 标签 type 属性(如 password/email/number)
opts.submitTextstring"确认"提交按钮文字
opts.submitColorstring"#3b82f6"提交按钮背景色
opts.cancelTextstring"取消"取消按钮文字
opts.cancelColorstring"#6b7280"取消按钮背景色
opts.typestring"info"Toast 类型
参数默认说明
placeholder / defaultValue""输入框占位与默认值
inputTypetextinput 的 type(password/email/number 等)
submitText / submitColor确认 / #3b82f6提交按钮
cancelText / cancelColor取消 / #6b7280取消按钮
-
const name = await MeToast.prompt('请输入姓名', {
-  placeholder: '请输入...',
-  defaultValue: '张三',
-  submitText: '确定',
-});
-if (name) MeToast.info('你好,' + name);
+
prompt.js
+
const name = await MeToast.prompt('请输入姓名', { placeholder: '请输入...' });
+if (name) MeToast.info('你好,' + name);

progress(message, opts?)

-

进度条通知。不自动关闭。返回 ProgressControl 对象以手动更新进度。

+

进度条通知,不自动关闭。返回 ProgressControl。

- - - - + + + + +
参数类型默认值说明
messagestring | object进度提示文本或配置对象
optsobject{}可选配置。type 默认 info
opts.progressColorstring"#3b82f6"进度条填充颜色
方法说明
setProgress(percent)设置进度 0~100,自动 clamp
complete(message?)跳 100%,300ms 后转 success,1s 后自动关闭
error(message?)转 error,2s 后自动关闭
dismiss()直接关闭
+
progress.js
const p = MeToast.progress('上传中...', { progressColor: '#10b981' });
-p.setProgress(45); // → 45%
-p.setProgress(90); // → 90%
-p.complete('上传完成!'); // → 100% → success
-// 或
-p.error('上传失败');
-p.dismiss();
-
-

返回 ProgressControl

- - - - - - -
方法签名说明
setProgress(percent: number)设置进度 0~100,自动 clamp。更新进度条宽度和百分比文字
complete(message?: string)跳到 100%,300ms 后替换为 success toast,1s 后自动关闭
error(message?: string)替换为 error toast,2s 后自动关闭
dismiss()直接关闭
-
+p.setProgress(45); +p.complete('上传完成!');

countdown(message, seconds, opts?)

-

倒计时 Toast。{seconds} 占位符每秒自动替换为剩余秒数。

+

倒计时 Toast,{seconds} 占位符每秒自动替换。

- - - - - + + +
参数类型默认值说明
messagestring消息文本。支持 {seconds} 占位符。非字符串打印错误并返回空控制对象
secondsnumber10倒计时秒数,最小 1
opts.onCompletefunction倒计时归零时的回调
opts.typestring"warning"Toast 类型
参数默认说明
seconds10倒计时秒数,最小 1
opts.onComplete归零回调
-
MeToast.countdown('{seconds} 秒后执行', 5, {
-  onComplete: () => MeToast.success('已执行'),
-});
-
-

返回 CountdownControl

- - - - - -
方法说明
cancel()清除计时器并关闭 toast
pause()暂停倒计时
resume()恢复倒计时
-
+
countdown.js
+
MeToast.countdown('{seconds} 秒后执行', 5, { onComplete: () => MeToast.success('已执行') });
+// 返回 { cancel(), pause(), resume() }

queue(messages, opts?)

-

顺序逐个显示消息队列。前一条关闭后延时显示下一条。返回 QueueControl(thenable + cancel)。

+

顺序逐个显示消息队列,返回 thenable + cancel。支持 await。

- - - - - - + + +
参数类型默认值说明
messagesArray<string|object>消息数组。可为字符串或带 message/type/duration/onClose 的对象。非数组打印错误并返回空控制对象
opts.delaynumber1000每条消息关闭后到显示下一条的间隔(ms)
opts.durationnumber3000每条消息显示时长(ms),可被消息级 duration 覆盖
opts.onClosefunction全部队列完成后的回调。消息级 onClose 和队列级 onClose 都会依次调用
opts.typestring默认类型
参数默认说明
opts.delay1000每条关闭后到下一条的间隔(ms)
opts.duration3000每条显示时长,可被消息级 duration 覆盖
-
const q = MeToast.queue([
-  '步骤一',
-  { message: '步骤二', duration: 5000, type: 'warning' },
-  '步骤三',
-], { delay: 800, duration: 2000, type: 'info' });
-q.cancel(); // 中途取消
-await q;  // 等待完成(thenable 支持 await)
-
-

返回 QueueControl (thenable)

- - - - - -
方法说明
.then(fn, rj)Promise.then 代理,支持 await
.catch(rj)Promise.catch 代理
.cancel()设置取消标志,不再显示下一条消息
-
+
queue.js
+
const q = MeToast.queue(['步骤一', '步骤二', '步骤三'], { delay: 800, duration: 2000 });
+q.cancel();  // 中途取消
+await q;      // 等待完成

stack(messages, opts?)

-

同时错峰显示多条消息。每条间隔 stagger 毫秒依次出现,全部叠加在屏幕上。

- - - - - -
参数类型默认值说明
messagesArray<string|object>消息数组。可为字符串或带 message/type/duration 的对象。非数组打印错误
opts.staggernumber100每条消息之间的显示间隔(ms)
opts.typestring默认类型
-
MeToast.stack(['消息1', '消息2', { message: '警告', type: 'warning' }], {
-  stagger: 150,
-  type: 'info',
-});
+

同时错峰显示多条消息。

+
stack.js
+
MeToast.stack(['消息1', '消息2', { message: '警告', type: 'warning' }], { stagger: 150 });

action(message, actions, opts?)

-

Action Toast:内嵌操作按钮。默认 duration=0 不自动关闭,closeButton=true。

+

内嵌操作按钮。默认 duration=0 不自动关闭。点击按钮不会误触 closeOnClick(已隔离冒泡)。

- - - - -
参数类型默认值说明
messagestring | object消息字符串或配置对象
actionsActionButton[][]按钮数组。每个按钮可配 text/onClick/color/style/close
optsobject{}可选配置。duration 默认 0,closeButton 默认 true
- - - - - - - + + + + +
ActionButton 字段类型默认值说明
textstring—(必填)按钮显示文字
onClick(toast) => void—(必填)点击回调函数,参数为当前 toast 实例
colorstring"#6366f1"按钮背景色
styleobject{}按钮内联样式,可覆盖 color(style.background 优先)
closebooleantrue点击后是否自动关闭 toast。设为 false 可多次点击
ActionButton默认说明
text—(必填)按钮文字
onClick—(必填)点击回调,参数为当前 toast
color / style#6366f1背景色 / 内联样式(style.background 优先)
closetrue点击后是否关闭。false 可多次点击
+
action.js
MeToast.action('文件已删除', [
   { text: '撤销', onClick: () => restore(), color: '#3b82f6' },
-  { text: '查看详情', onClick: (t) => openFile(), color: '#10b981', close: false },
-]);
+ { text: '查看', onClick: () => open(), color: '#10b981', close: false }, +]);

group(name)

-

创建 Toast 分组,返回 GroupAPI 对象。该对象所有方法自动传入 group: name,按组管理。

- - - -
参数类型说明
namestring分组名称。GroupAPI 和 dismissGroup 通过此名称关联
+

创建分组,返回 GroupAPI。所有方法自动注入 group: name

+
group.js
const orders = MeToast.group('orders');
 orders.success('订单已创建');
-orders.error('支付失败', { duration: 5000 });
-orders.count();   // 该组当前 toast 数量
-orders.dismiss(); // 关闭该组全部 toast
-// 也支持主对象关闭
-MeToast.dismissGroup('orders');
-
-

返回 GroupAPI

- - - - - - - - - - - -
方法说明
show(msg, opts?)等价 MeToast.show,自动注入 group
success(msg, opts?)等价 MeToast.success
error(msg, opts?)等价 MeToast.error
warning(msg, opts?)等价 MeToast.warning
info(msg, opts?)等价 MeToast.info
loading(msg, opts?)等价 MeToast.loading
action(msg, actions, opts?)等价 MeToast.action
dismiss()关闭该组所有 toast
count()返回该组当前 toast 数量
-
+orders.count(); // 该组数量 +orders.dismiss(); // 关闭整组 +MeToast.dismissGroup('orders');

dismiss(id?)

-

关闭 Toast。无参数则关闭全部。

- - - -
参数类型说明
idstring可选。Toast 的 id,不传则关闭所有
-
MeToast.dismiss();          // 关闭所有
-MeToast.dismiss(toast.id);  // 关闭指定
+

关闭 Toast。无参数关闭全部,传入 id 关闭指定。

+
dismiss.js
+
MeToast.dismiss();         // 全部
+MeToast.dismiss(toast.id); // 指定

clear(position?)

-

按位置清除 Toast。不传参数清除所有位置。

- - - -
参数类型说明
positionstring可选。位置如 'top-right',不传则清除全部
-
MeToast.clear();                      // 全部
-MeToast.clear('bottom-right'); // 仅右下角
+

按位置清除。

+
clear.js
+
MeToast.clear();                    // 全部
+MeToast.clear('bottom-right'); // 仅右下角
+ +

updatePosition(position) v0.3

+

运行时将 Toast 移动到新位置容器,立即生效。

+
position.js
+
const t = MeToast.info('可移动的 Toast');
+t.updatePosition('bottom-left');
+ +

remove() / removeToast(id) 立即移除

+

立即从 DOM 和内存移除,不触发离场动画。适用于无动画快速清除。

+
remove.js
+
t.remove();                  // 实例方法
+MeToast.removeToast(t.id);    // 按 id(等价)

configure(opts)

-

全局配置,影响后续所有 Toast。theme 和 locale 变化会触发相应副作用(应用主题 CSS / 切换语言)。

- - - -
参数类型说明
optsobject包含任意 配置项 的对象
+

全局配置,影响后续所有 Toast。theme/locale 变化触发副作用。

+
configure.js
MeToast.configure({
-  position: 'top-right',
-  duration: 4000,
-  theme: 'dark',
-  animation: 'slide',
-  locale: 'zh-CN',
-});
+ position: 'top-right', duration: 4000, theme: 'dark', + animation: 'slide', locale: 'zh-CN', +});

use(plugin)

-

安装插件。支持字符串(内置预设名)或插件对象。已被拒绝注册的无效插件会打印错误。

- - - -
参数类型说明
pluginstring | object预设名 'keyboard'/'persistence'/'accessibility' 或自定义插件对象 { name, version?, install, uninstall? }
-
MeToast.use('keyboard');      // ESC 关闭所有 Toast
-MeToast.use('persistence');   // 配置自动保存到 localStorage
-MeToast.use('accessibility'); // 屏幕阅读器实时朗读 toast 内容
-

内置插件详情:keyboard 监听 keydown Escape 键;persistence 将配置写入 localStorage 并在页面加载时恢复;accessibility 在 afterShow/afterUpdate 钩子中通过 aria-live 区域朗读 toast 内容。

+

安装插件:字符串预设名或插件对象。

+
use.js
+
MeToast.use('keyboard');      // ESC 关闭所有
+MeToast.use('persistence');   // 配置持久化 localStorage
+MeToast.use('accessibility'); // 屏幕阅读器朗读
+MeToast.use('dedupe');        // 相同 type+message 自动去重
+

dedupe:检测已有相同 toast,更新它并阻止重复弹出(beforeShow 钩子实现,支持 uninstall 卸载钩子)。

destroy()

-

完全销毁。关闭所有 Toast、移除所有 DOM 容器和注入的样式标签、清空内存缓存。

-
MeToast.destroy();
+

完全销毁:关闭所有 Toast、移除全部 DOM 容器与注入样式、清空内存缓存与钩子。支持重复调用,init() 可恢复。

+
destroy.js
+
MeToast.destroy();
+MeToast.init({ config: { duration: 3000 } });  // 恢复
+ + +

钩子系统 v0.3

+

Toast.on(name, handler) 注册钩子并返回取消函数。beforeShow / beforeClose / beforeUpdate 的 handler 返回 false 可拦截对应操作。

+ + + + + + + + + + + + + +
钩子触发时机拦截
beforeInit / afterInitinit() 前后
beforeDestroy / afterDestroydestroy() 前后
beforeShow / afterShow显示前后✅ 返回 false 阻止
beforeClose / afterClose关闭前后✅ 返回 false 阻止
beforeUpdate / afterUpdateupdate() 前后✅ 返回 false 阻止
configChangeconfigure / updateConfig / resetConfig
themeChange / localeChange主题 / 语言切换
click / hover点击 / 悬停进出
dragStart / dragEnd拖拽开始 / 结束
animationStart / animationEnd入场动画开始 / 结束
progressStart / progressEnd倒计时开始 / 归零
+
hooks.js
+
import MeToast, { Toast } from '@metona-team/metona-toast';
+
+// 拦截:非允许时段禁止错误提示
+const off = Toast.on('beforeShow', (toast) => {
+  if (toast.type === 'error' && !isAllowed) return false;
+});
+// ...
+off();  // 取消注册
+ + +

React 适配器 v0.4

+

主包保持零依赖。通过子路径 @metona-team/metona-toast/react 导入,react 为 optional peerDependency。

+
react.tsx
+
import { useToast, Toast } from '@metona-team/metona-toast/react';
+
+function SubmitButton() {
+  const toast = useToast();  // 组件卸载自动清理本组件创建的 Toast
+  const submit = async () => {
+    const loading = toast.loading('正在提交...');
+    try { await api(); loading.success('完成!'); }
+    catch (e) { loading.error('失败'); }
+  };
+  return <button onClick={submit}>提交</button>;
+}
+
+// 声明式 Toast:props 变化更新,卸载自动移除(autoClose 默认 true)
+function SaveIndicator({ saving }) {
+  return saving ? <Toast type="info" message="正在保存..." /> : null;
+}

全部配置项

-

以下配置可用于 configure()init() 或单个 Toast 方法的 opts 参数。

+

可用于 configure()init() 或单个 Toast 方法的 opts。

- - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +
配置项类型默认值说明
positionstring'top-right'位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right
durationnumber4000显示时长(ms),0=不自动关闭
maxnumber6同一位置最多同时显示条数,超出则关闭最早的
gapnumber12Toast 之间的间距(px)
offsetnumber24容器到屏幕边缘的距离(px)
pauseOnHoverbooleantrue鼠标悬停时暂停 duration 倒计时和进度条
closeOnClickbooleantrue点击 Toast 任意位置关闭。关闭按钮始终触发关闭
draggablebooleantrue允许拖拽关闭。拖拽超过 120px 触发关闭
showProgressbooleantrue显示 duration 倒计时进度条
progressDirectionstring'horizontal'进度条方向:horizontal(底部水平) / vertical(右侧垂直)
iconbooleantrue显示类型对应图标(success→对勾等)
closeButtonbooleantrue显示右上角关闭 × 按钮
themestring'auto'主题:light / dark / auto(跟随系统)/ warm / 自定义注册名
animationstring'slide'入场动画名称,支持 CSS 动画列表见下
zIndexnumber9999容器 CSS z-index
widthnumber|string360Toast 宽度。数字表示 px
classNamestring''附加到 Toast 元素上的 CSS 类名
styleobject{}附加到 Toast 元素上的内联样式对象
localestring'zh-CN'语言代码(zh-CN / en-US 等)
resetTimerOnUpdatebooleanfalse调用 update() 时重置 duration 倒计时
notifyWhenHiddenbooleanfalse页面不可见时自动通过 Notification API 发送系统通知
renderfunction自定义渲染函数 (toast) => htmlString,完全接管 DOM 构建
onErrorfunction全局错误回调 ({ hook, source, error, toast }) => void,钩子异常或定时器错误时触发
positionstring'top-right'6 个位置之一(RTL 自动翻转)
durationnumber4000显示时长(ms),0 = 不自动关闭
maxnumber6同位置最多条数,超出真正关闭最早的
gapnumber12Toast 间距(px)
offsetnumber24容器距屏幕边缘(px)
pauseOnHoverbooleantrue悬停暂停倒计时
closeOnClickbooleantrue点击关闭
draggablebooleantrue允许拖拽关闭
dragThresholdnumber120拖拽关闭阈值(px)
showProgressbooleantrue显示倒计时进度条
progressDirectionstring'horizontal'horizontal / vertical
iconbooleantrue显示类型图标
closeButtonbooleantrue显示关闭 × 按钮
themestring'auto'light / dark / auto / warm / 自定义名
animationstring'slide'11 种内置或自定义动画名
zIndexnumber9999容器 z-index
widthnumber|string360宽度,数字表示 px
classNamestring''附加 CSS 类名
styleobject{}附加内联样式
localestring'zh-CN'语言代码
resetTimerOnUpdatebooleanfalseupdate() 时重置倒计时
notifyWhenHiddenbooleanfalse页面不可见时发系统通知
renderfunction自定义渲染函数,完全接管 DOM
onBeforeShowfunction返回 false 阻止显示
onErrorfunction钩子/定时器异常全局回调

回调函数

- - - - - + + + + + +
回调签名触发时机
onShow(toast: ToastInstance) => voidToast DOM 创建并播放入场动画后
onClose(toast: ToastInstance) => voidToast DOM 被移除后(离场动画完成时)
onClick(toast: ToastInstance) => voidToast 被点击时(closeOnClick 为 true 时还会自动关闭)
onUpdate(toast: ToastInstance) => voidToast 内容通过 update() 更新后
onError({ hook, source, error, toast }) => void钩子回调或定时器发生异常时(全局配置,适用于接入错误监控)
onBeforeShow(toast) => boolean | voidDOM 创建前,返回 false 阻止显示
onShow(toast) => void创建并播放入场动画后
onClose(toast) => voidDOM 移除后(离场完成)
onClick(toast) => void点击时(closeOnClick=true 时还会关闭)
onUpdate(toast) => voidupdate() 后
onError({ hook, source, error, toast }) => void钩子/定时器异常时

动画列表

-

配置 animation 的值,支持以下 CSS 动画。未在此列表中的值将 fallback 到 slide。

+

未注册的动画名自动 fallback 到 slide。

@@ -458,20 +452,13 @@ MeToast.use('accessibility'); - - - - + +
名称效果时长
slide从右侧滑入 + 回弹400ms
flip3D 翻转入场 + 回摆500ms
rotate旋转摇摆进入500ms
zoom从中心爆发式弹出500ms
slideUp从下方弹入400ms
slideDown从上方弹入400ms
slideLeft从左侧滑入400ms
slideRight从右侧滑入400ms
slideUp / slideDown从下方 / 上方弹入400ms
slideLeft / slideRight从左侧 / 右侧滑入400ms

主题参考

-
// 内置主题
-light   — 白色玻璃质感
-dark    — 深色玻璃质感
-auto    — 跟随系统主题设置
-warm    — 暖色调
-
-// 注册自定义主题
+
theme.js
+
// 内置主题:light · dark · auto(跟随系统)· warm
 MeToast.themes.registerTheme('ocean', {
   bg: 'rgba(240,249,255,0.96)',
   text: '#0c4a6e',
@@ -481,19 +468,38 @@ MeToast.themes.registerTheme('ocean'progressBg: 'rgba(14,165,233,0.1)',
   closeHoverBg: 'rgba(14,165,233,0.1)',
 });
-MeToast.themes.switchTheme('ocean');
+MeToast.themes.switchTheme('ocean');
- \ No newline at end of file + diff --git a/package.json b/package.json index e86af3c..6993dad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.4.5", + "version": "0.5.0", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { @@ -28,9 +28,9 @@ "author": "MarkLite", "license": "MIT", "dependencies": { - "@metona-team/metona-editor": "0.2.4", - "@metona-team/metona-toast": "0.2.1", - "dexie": "^4.0.11", + "@metona-team/metona-editor": "^0.4.0", + "@metona-team/metona-sqlark": "^0.4.1", + "@metona-team/metona-toast": "^0.5.0", "mermaid": "^10.9.6", "nanoid": "^5.1.5", "react": "^18.3.1", diff --git a/src/preload/index.ts b/src/preload/index.ts index 21db7d0..bc905e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,64 +1,64 @@ -import { contextBridge, ipcRenderer, shell } from 'electron' -import { IPC_CHANNELS } from '../shared/ipc-channels' -import type { ElectronAPI } from '../renderer/types/ipc' - -// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致 -const api: ElectronAPI = { - // File operations - openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), - readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), - saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), - saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), - getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), - getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), - reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), - - // Tab management - tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath), - - // Window control - forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE), - cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE), - - // Shell — 仅允许 http/https 协议 - openExternal: (url: string) => { - try { - const parsed = new URL(url) - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - shell.openExternal(url) - } - } catch { - // 无效 URL,忽略 - } - }, - - // File Tree (Sidebar) - readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath), - openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG), - watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath), - unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), - - // Events from main process — 返回取消订阅函数 - onFileOpenInTab: (callback) => { - const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) - ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) - return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } - }, - onExternalModification: (callback) => { - const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) - ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) - return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } - }, - onDirChanged: (callback) => { - const handler = () => callback() - ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) - return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } - }, - onConfirmClose: (callback) => { - const handler = () => callback() - ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) - return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } - } -} - -contextBridge.exposeInMainWorld('electronAPI', api) +import { contextBridge, ipcRenderer, shell } from 'electron' +import { IPC_CHANNELS } from '../shared/ipc-channels' +import type { ElectronAPI } from '../renderer/types/ipc' + +// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致 +const api: ElectronAPI = { + // File operations + openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), + readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), + saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), + saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), + getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), + getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), + reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), + + // Tab management + tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath), + + // Window control + forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE), + cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE), + + // Shell — 仅允许 http/https 协议 + openExternal: (url: string) => { + try { + const parsed = new URL(url) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url) + } + } catch { + // 无效 URL,忽略 + } + }, + + // File Tree (Sidebar) + readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath), + openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG), + watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath), + unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), + + // Events from main process — 返回取消订阅函数 + onFileOpenInTab: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) + ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } + }, + onExternalModification: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) + ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } + }, + onDirChanged: (callback) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } + }, + onConfirmClose: (callback) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } + } +} + +contextBridge.exposeInMainWorld('electronAPI', api) diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts index e337233..615e1d9 100644 --- a/src/preload/preload.d.ts +++ b/src/preload/preload.d.ts @@ -1,16 +1,16 @@ -/** - * DX-02: Preload 类型安全声明 - * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义, - * preload/index.ts 引入该类型做编译期契约检查。 - * 本文件仅保留全局 Window 增强声明。 - */ - -import type { ElectronAPI } from '../renderer/types/ipc' - -declare global { - interface Window { - electronAPI: ElectronAPI - } -} - -export {} +/** + * DX-02: Preload 类型安全声明 + * ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义, + * preload/index.ts 引入该类型做编译期契约检查。 + * 本文件仅保留全局 Window 增强声明。 + */ + +import type { ElectronAPI } from '../renderer/types/ipc' + +declare global { + interface Window { + electronAPI: ElectronAPI + } +} + +export {} diff --git a/src/renderer/assets.d.ts b/src/renderer/assets.d.ts index 0465136..95beae6 100644 --- a/src/renderer/assets.d.ts +++ b/src/renderer/assets.d.ts @@ -1,14 +1,14 @@ -declare module '*.png' { - const src: string - export default src -} - -declare module '*.svg' { - const src: string - export default src -} - -declare module '*.ico' { - const src: string - export default src -} +declare module '*.png' { + const src: string + export default src +} + +declare module '*.svg' { + const src: string + export default src +} + +declare module '*.ico' { + const src: string + export default src +} diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx index a2933b6..ade4e05 100644 --- a/src/renderer/components/AboutDialog/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog/AboutDialog.tsx @@ -35,6 +35,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia 搜索替换 文件树 文档大纲 + 浮动格式栏 状态持久化
@@ -44,6 +45,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia git.metona.cn/MetonaTeam/MarkLite

基于 Electron + React + TypeScript 构建

+

MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1

© 2026 thzxx

diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 8dbec23..edfb591 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import MeEditor from '@metona-team/metona-editor' import type { MarkdownEditor } from '@metona-team/metona-editor' import mermaid from 'mermaid' @@ -8,7 +8,7 @@ import { settingsRepository } from '../../db/settingsRepository' import { renderMarkdownSync } from '../../lib/markdown' import type { ThemeMode } from '../../types/settings' -// v0.2.4: 这些类型不再作为命名导出暴露,本地声明以保持类型安全 +// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全 type EditMode = 'edit' | 'split' | 'preview' type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string @@ -46,8 +46,14 @@ const EDITOR_PLUGINS: string[] = [ * Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。 */ 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) + // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), + // 后者每次返回新对象引用导致 Zustand 无条件重渲染 + const activeTab = useMemo( + () => tabs.find(t => t.id === activeTabId) ?? null, + [tabs, activeTabId] + ) const updateTabContent = useTabStore(s => s.updateTabContent) const setModified = useTabStore(s => s.setModified) const updateTabScroll = useTabStore(s => s.updateTabScroll) @@ -90,6 +96,8 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito autoBrackets: true, readOnly: viewMode === 'preview', plugins: EDITOR_PLUGINS, + // v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮) + floatingToolbar: true, // v0.2.4 新增配置项 syncScroll: true, wordWrap: true, @@ -151,7 +159,7 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito setMetonaEditorGetter(() => editor) currentContentRef.current = activeTab?.content ?? '' - // v0.4.5: 绑定 afterRender → 触发 Mermaid 图表渲染 + // v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染 const renderMermaid = () => { try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ } } diff --git a/src/renderer/components/Icons.tsx b/src/renderer/components/Icons.tsx index 6d78f76..8c8a32a 100644 --- a/src/renderer/components/Icons.tsx +++ b/src/renderer/components/Icons.tsx @@ -1,176 +1,176 @@ -import React from 'react' -import appIconUrl from '../assets/icon.png' - -// 统一图标 Props -interface IconProps { - size?: number - className?: string - style?: React.CSSProperties -} - -const defaultProps: Partial = { size: 18 } - -// ===== 应用图标 ===== -export function AppIcon({ size = 80 }: IconProps) { - return ( - MarkLite - ) -} - -// ===== 工具栏图标 ===== -export function FolderOpen({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -export function Save({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -export function Moon({ size = defaultProps.size }: IconProps) { - return ( - - - - - ) -} - -export function Sun({ size = defaultProps.size }: IconProps) { - return ( - - - - - - - - - - - - - ) -} - -// ===== 工具栏右侧图标 ===== -export function Gitee({ size = defaultProps.size }: IconProps) { - return ( - - - - ) -} - -export function Info({ size = defaultProps.size }: IconProps) { - return ( - - - - - - ) -} - -// ===== 标签栏图标 ===== -export function Close({ size = 10 }: IconProps) { - return ( - - - - - ) -} - -export function Plus({ size = 14 }: IconProps) { - return ( - - - - - ) -} - -// ===== 侧边栏图标 ===== -export function Folder({ size = 14 }: IconProps) { - return ( - - - - - ) -} - -export function File({ size = 14 }: IconProps) { - return ( - - - - - - - ) -} - -export function ChevronRight({ size = 10 }: IconProps) { - return ( - - - - ) -} - -export function FolderPlus({ size = 14 }: IconProps) { - return ( - - - - - - ) -} - -// ===== 拖拽覆盖层图标 ===== -export function UploadCloud({ size = 64 }: IconProps) { - return ( - - - - - - - ) -} - -// ===== 欢迎屏幕图标 ===== -export function WelcomeFile({ size = 20 }: IconProps) { - return ( - - - - - - ) -} - -export function WelcomeNew({ size = 20 }: IconProps) { - return ( - - - - - - - ) -} +import React from 'react' +import appIconUrl from '../assets/icon.png' + +// 统一图标 Props +interface IconProps { + size?: number + className?: string + style?: React.CSSProperties +} + +const defaultProps: Partial = { size: 18 } + +// ===== 应用图标 ===== +export function AppIcon({ size = 80 }: IconProps) { + return ( + MarkLite + ) +} + +// ===== 工具栏图标 ===== +export function FolderOpen({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +export function Save({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +export function Moon({ size = defaultProps.size }: IconProps) { + return ( + + + + + ) +} + +export function Sun({ size = defaultProps.size }: IconProps) { + return ( + + + + + + + + + + + + + ) +} + +// ===== 工具栏右侧图标 ===== +export function Gitee({ size = defaultProps.size }: IconProps) { + return ( + + + + ) +} + +export function Info({ size = defaultProps.size }: IconProps) { + return ( + + + + + + ) +} + +// ===== 标签栏图标 ===== +export function Close({ size = 10 }: IconProps) { + return ( + + + + + ) +} + +export function Plus({ size = 14 }: IconProps) { + return ( + + + + + ) +} + +// ===== 侧边栏图标 ===== +export function Folder({ size = 14 }: IconProps) { + return ( + + + + + ) +} + +export function File({ size = 14 }: IconProps) { + return ( + + + + + + + ) +} + +export function ChevronRight({ size = 10 }: IconProps) { + return ( + + + + ) +} + +export function FolderPlus({ size = 14 }: IconProps) { + return ( + + + + + + ) +} + +// ===== 拖拽覆盖层图标 ===== +export function UploadCloud({ size = 64 }: IconProps) { + return ( + + + + + + + ) +} + +// ===== 欢迎屏幕图标 ===== +export function WelcomeFile({ size = 20 }: IconProps) { + return ( + + + + + + ) +} + +export function WelcomeNew({ size = 20 }: IconProps) { + return ( + + + + + + + ) +} diff --git a/src/renderer/components/OutlinePanel/OutlinePanel.tsx b/src/renderer/components/OutlinePanel/OutlinePanel.tsx index ee45fbc..904fee7 100644 --- a/src/renderer/components/OutlinePanel/OutlinePanel.tsx +++ b/src/renderer/components/OutlinePanel/OutlinePanel.tsx @@ -1,71 +1,71 @@ -import React, { memo } from 'react' -import type { Heading } from './outlineUtils' - -// --- Component --- - -interface OutlinePanelProps { - headings: Heading[] - onNavigate: (heading: Heading, index: number) => void - activeHeadingIndex: number | null -} - -interface OutlineItemProps { - heading: Heading - index: number - isActive: boolean - onNavigate: (heading: Heading, index: number) => void -} - -const OutlineItem = memo(function OutlineItem({ - heading, - index, - isActive, - onNavigate -}: OutlineItemProps) { - return ( - - ) -}) - -export const OutlinePanel = memo(function OutlinePanel({ - headings, - onNavigate, - activeHeadingIndex -}: OutlinePanelProps) { - if (headings.length === 0) { - return ( -
-
文档大纲
-
当前文档无标题
-
- ) - } - - return ( -
-
文档大纲
-
- {headings.map((h, i) => ( - - ))} -
-
- ) -}) - -OutlinePanel.displayName = 'OutlinePanel' +import React, { memo } from 'react' +import type { Heading } from './outlineUtils' + +// --- Component --- + +interface OutlinePanelProps { + headings: Heading[] + onNavigate: (heading: Heading, index: number) => void + activeHeadingIndex: number | null +} + +interface OutlineItemProps { + heading: Heading + index: number + isActive: boolean + onNavigate: (heading: Heading, index: number) => void +} + +const OutlineItem = memo(function OutlineItem({ + heading, + index, + isActive, + onNavigate +}: OutlineItemProps) { + return ( + + ) +}) + +export const OutlinePanel = memo(function OutlinePanel({ + headings, + onNavigate, + activeHeadingIndex +}: OutlinePanelProps) { + if (headings.length === 0) { + return ( +
+
文档大纲
+
当前文档无标题
+
+ ) + } + + return ( +
+
文档大纲
+
+ {headings.map((h, i) => ( + + ))} +
+
+ ) +}) + +OutlinePanel.displayName = 'OutlinePanel' diff --git a/src/renderer/components/OutlinePanel/index.ts b/src/renderer/components/OutlinePanel/index.ts index 148c75c..4c2f808 100644 --- a/src/renderer/components/OutlinePanel/index.ts +++ b/src/renderer/components/OutlinePanel/index.ts @@ -1,3 +1,3 @@ -export { OutlinePanel } from './OutlinePanel' -export { parseHeadings } from './outlineUtils' -export type { Heading } from './outlineUtils' +export { OutlinePanel } from './OutlinePanel' +export { parseHeadings } from './outlineUtils' +export type { Heading } from './outlineUtils' diff --git a/src/renderer/components/OutlinePanel/outlineUtils.ts b/src/renderer/components/OutlinePanel/outlineUtils.ts index 8b66e6a..53c6807 100644 --- a/src/renderer/components/OutlinePanel/outlineUtils.ts +++ b/src/renderer/components/OutlinePanel/outlineUtils.ts @@ -1,27 +1,27 @@ -export interface Heading { - level: number - text: string - /** Position in document (character offset from markdown source) */ - pos: number -} - -const HEADING_RE = /^(#{1,6})\s+(.+)$/gm - -/** - * Parse headings from raw markdown content using regex. - */ -export function parseHeadings(markdown: string): Heading[] { - const headings: Heading[] = [] - let match: RegExpExecArray | null - - // Reset regex state - HEADING_RE.lastIndex = 0 - - while ((match = HEADING_RE.exec(markdown)) !== null) { - const level = match[1].length - const text = match[2].trim() - headings.push({ level, text, pos: match.index }) - } - - return headings -} +export interface Heading { + level: number + text: string + /** Position in document (character offset from markdown source) */ + pos: number +} + +const HEADING_RE = /^(#{1,6})\s+(.+)$/gm + +/** + * Parse headings from raw markdown content using regex. + */ +export function parseHeadings(markdown: string): Heading[] { + const headings: Heading[] = [] + let match: RegExpExecArray | null + + // Reset regex state + HEADING_RE.lastIndex = 0 + + while ((match = HEADING_RE.exec(markdown)) !== null) { + const level = match[1].length + const text = match[2].trim() + headings.push({ level, text, pos: match.index }) + } + + return headings +} diff --git a/src/renderer/components/Toolbar/Toolbar.tsx b/src/renderer/components/Toolbar/Toolbar.tsx index 59fb05f..7aaf677 100644 --- a/src/renderer/components/Toolbar/Toolbar.tsx +++ b/src/renderer/components/Toolbar/Toolbar.tsx @@ -1,71 +1,71 @@ -import React from 'react' -import { FolderOpen, Save, Moon, Sun, Info } from '../Icons' -import type { ThemeMode } from '../../types/settings' - -interface ToolbarProps { - onOpen: () => void - onSave: () => void - themeMode: ThemeMode - onCycleTheme: () => void - onShowAbout: () => void - isAutoSaving: boolean - autoSaveEnabled: boolean - onToggleAutoSave: () => void -} - -const THEME_LABELS: Record = { - light: '亮色', - dark: '暗色', - warm: '暖色', -} - -/** - * 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。 - * 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。 - */ -export const Toolbar = React.memo(function Toolbar({ - onOpen, onSave, themeMode, onCycleTheme, onShowAbout, - isAutoSaving, autoSaveEnabled, onToggleAutoSave -}: ToolbarProps) { - const nextLabel = THEME_LABELS[themeMode] ?? '主题' - - return ( -