Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbbb7ff70 | ||
|
|
1dd22be0a8 | ||
|
|
20b6d03617 | ||
|
|
7bd625b9ce | ||
|
|
10cfe0066b | ||
|
|
05e5667e48 | ||
|
|
43b0778849 | ||
|
|
77fdf03ed7 | ||
|
|
ed505d48bf |
@@ -0,0 +1,15 @@
|
|||||||
|
# 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
|
||||||
+20
-10
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
2. **克隆并安装**
|
2. **克隆并安装**
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitee.com/thzxx/MarkLite.git
|
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||||
cd MarkLite
|
cd MarkLite
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 分支策略
|
## 分支策略
|
||||||
|
|
||||||
- `main` — 稳定版本
|
- `master` — 稳定版本
|
||||||
- `dev` — 开发分支
|
- `dev` — 开发分支
|
||||||
- `feature/*` — 功能分支
|
- `feature/*` — 功能分支
|
||||||
- `fix/*` — 修复分支
|
- `fix/*` — 修复分支
|
||||||
@@ -98,16 +98,26 @@ src/
|
|||||||
├── main/ # 主进程 (Node.js)
|
├── main/ # 主进程 (Node.js)
|
||||||
├── preload/ # 预加载脚本
|
├── preload/ # 预加载脚本
|
||||||
├── renderer/ # 渲染进程 (React)
|
├── renderer/ # 渲染进程 (React)
|
||||||
│ ├── components/ # UI 组件
|
│ ├── components/ # UI 组件(Toolbar · TabBar · Editor · Sidebar 等)
|
||||||
│ ├── stores/ # Zustand 状态管理
|
│ ├── stores/ # Zustand 状态管理(tabStore · editorStore · sidebarStore · autoSaveStore)
|
||||||
│ ├── hooks/ # 自定义 Hooks
|
│ ├── hooks/ # 自定义 Hooks(useTheme · useAutoSave · useKeyboard 等)
|
||||||
│ ├── lib/ # 工具库
|
│ ├── lib/ # 工具库(markdown · fileUtils · errorHandler · toast)
|
||||||
│ ├── db/ # IndexedDB 持久化
|
│ ├── db/ # IndexedDB 持久化(schema · repositories)
|
||||||
│ ├── types/ # TypeScript 类型
|
│ ├── types/ # TypeScript 类型定义
|
||||||
│ └── styles/ # 全局样式
|
│ └── styles/ # 全局样式(variables · global · markdown-body)
|
||||||
└── shared/ # 主进程/渲染进程共享
|
└── 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)。
|
贡献的代码将遵循项目的 [MIT 许可证](LICENSE)。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# MarkLite v0.3.12 — 架构设计文档
|
# MarkLite v0.4.3 — 架构设计文档
|
||||||
|
|
||||||
## 1. 项目概述
|
## 1. 项目概述
|
||||||
|
|
||||||
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Milkdown v7 WYSIWYG 编辑器 + 源码编辑模式、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
|
MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 MetonaEditor v0.1.14 编辑器(三模式视图 + 插件系统)、Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线。
|
||||||
|
|
||||||
### 1.1 核心原则
|
### 1.1 核心原则
|
||||||
|
|
||||||
@@ -20,11 +20,12 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 |
|
||||||
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
| 前端框架 | React | v18 | 函数组件 + Hooks |
|
||||||
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
| 类型系统 | TypeScript | v5.6 | 全量类型安全 |
|
||||||
| 编辑器 | Milkdown | v7 | WYSIWYG 编辑器 + 源码编辑模式 |
|
| 编辑器 | MetonaEditor | v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
||||||
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
| 状态管理 | Zustand | v5 | 轻量级状态管理 |
|
||||||
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
|
| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 |
|
||||||
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 |
|
| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线(作为 MetonaEditor render 钩子) |
|
||||||
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
|
| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js |
|
||||||
|
| Toast | @metona-team/metona-toast | v2.0.1 | 通知提示组件 |
|
||||||
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
|
| 构建工具 | electron-vite | v3 | Electron + Vite,HMR 热更新 |
|
||||||
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
|
| 打包工具 | electron-builder | v25 | Windows NSIS 安装包 |
|
||||||
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
| 样式 | CSS Variables | — | 主题驱动,亮色/暗色 |
|
||||||
@@ -44,25 +45,25 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
│ Preload Script (src/preload/) 1 文件 │
|
│ Preload Script (src/preload/) 1 文件 │
|
||||||
│ index.ts contextBridge 类型安全暴露 │
|
│ index.ts contextBridge 类型安全暴露 │
|
||||||
│ electronAPI 18 个方法/事件的类型安全接口 │
|
│ electronAPI 22 个方法/事件的类型安全接口 │
|
||||||
└───────────────────────┬──────────────────────────────────────┘
|
└───────────────────────┬──────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────▼──────────────────────────────────────┐
|
┌───────────────────────▼──────────────────────────────────────┐
|
||||||
│ Renderer Process (src/renderer/) React 18 │
|
│ Renderer Process (src/renderer/) React 18 │
|
||||||
│ │
|
│ │
|
||||||
│ components/ (23) Toolbar · TabBar · Editor · EditorToolbar │
|
│ components/ Toolbar · TabBar · Editor · Sidebar │
|
||||||
│ SourceEditor · Preview · Sidebar · FileTree│
|
│ FileTree · OutlinePanel · WelcomeScreen │
|
||||||
│ OutlinePanel · StatusBar · WelcomeScreen │
|
│ ConfirmDialog · ModifiedBanner │
|
||||||
│ Toast · ConfirmDialog · ModifiedBanner │
|
│ DropOverlay · ErrorBoundary · AboutDialog│
|
||||||
│ SearchReplace · DropOverlay · ErrorBoundary│
|
│ LoadingSpinner · Icons │
|
||||||
│ AboutDialog · LoadingSpinner · Icons │
|
|
||||||
│ │
|
│ │
|
||||||
│ stores/ (3) tabStore · editorStore · sidebarStore │
|
│ stores/ (4) tabStore · editorStore · sidebarStore │
|
||||||
│ hooks/ (19) useTheme · useSettings · useSettingsInit │
|
│ autoSaveStore │
|
||||||
│ useKeyboard · useDragDrop · useFileWatch │
|
│ hooks/ (15) useTheme · useSettingsInit · useKeyboard │
|
||||||
│ useAutoSave · useIpcListeners ... │
|
│ useDragDrop · useFileWatch · useAutoSave │
|
||||||
│ lib/ (4) markdown · fileUtils · errorHandler │
|
│ useIpcListeners · useFileOperations ... │
|
||||||
│ constants │
|
│ lib/ (4) markdown · fileUtils · errorHandler │
|
||||||
|
│ toast · constants │
|
||||||
│ db/ (4) schema · tabRepository · settingsRepo │
|
│ db/ (4) schema · tabRepository · settingsRepo │
|
||||||
│ recentFilesRepository │
|
│ recentFilesRepository │
|
||||||
│ types/ (5) tab · file · settings · ipc · index │
|
│ types/ (5) tab · file · settings · ipc · index │
|
||||||
@@ -83,7 +84,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 |
|
||||||
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API |
|
||||||
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 22 个类型安全方法/事件 |
|
| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 18 个类型安全方法 + 4 个事件订阅 |
|
||||||
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源 |
|
||||||
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性 |
|
||||||
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 |
|
||||||
@@ -96,19 +97,18 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ App.tsx (根组件) │
|
│ App.tsx (根组件) │
|
||||||
├─────────┬──────────┬──────────┬──────────┬──────────────┤
|
├─────────┬──────────┬──────────┬──────────────────────────┤
|
||||||
│Toolbar │ TabBar │ Sidebar │ Editor │ Preview │
|
│Toolbar │ TabBar │ Sidebar │ Editor (MetonaEditor) │
|
||||||
│ │ │ │ (CM6) │ │
|
├─────────┴──────────┴──────────┴──────────────────────────┤
|
||||||
├─────────┴──────────┴──────────┴──────────┴──────────────┤
|
|
||||||
│ Zustand Stores │
|
│ Zustand Stores │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
|
||||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │
|
│ │ tabStore │ │editorStore│ │sidebarStore│ │autoSaveStore│ │
|
||||||
│ │ - tabs │ │- viewMode│ │- tree │ │
|
│ │ - tabs │ │- viewMode│ │- tree │ │- isSaving │ │
|
||||||
│ │- activeId│ │- darkMode│ │- expanded │ │
|
│ │- activeId│ │- darkMode│ │- expanded │ │- enabled │ │
|
||||||
│ │ - mru │ │ │ │- rootPath │ │
|
│ │ - mru │ │- extMod │ │- rootPath │ │ │ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
│ ┌────▼────────────▼────────────▼──────────────▼──────┐ │
|
||||||
│ │ IndexedDB (Dexie.js) │ │
|
│ │ IndexedDB (Dexie.js) │ │
|
||||||
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
│ │ tabSnapshots │ settings │ recentFiles │ activeTab │ │
|
||||||
│ └───────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
@@ -130,7 +130,7 @@ interface TabState {
|
|||||||
setModified(tabId, modified) // 设置修改状态
|
setModified(tabId, modified) // 设置修改状态
|
||||||
getActiveTab() // 获取当前标签
|
getActiveTab() // 获取当前标签
|
||||||
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
updateTabScroll(tabId, scroll) // 更新滚动/光标位置
|
||||||
saveToDB() // 保存到 IndexedDB
|
saveToDB() // 保存到 IndexedDB(防抖 500ms)
|
||||||
loadFromDB() // 从 IndexedDB 加载
|
loadFromDB() // 从 IndexedDB 加载
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -139,9 +139,15 @@ interface TabState {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewMode: 'editor' | 'preview' // 视图模式
|
viewMode: 'editor' | 'preview' | 'source' // 视图模式
|
||||||
darkMode: boolean // 暗色主题
|
darkMode: boolean // 暗色主题
|
||||||
|
externallyModified: { filePath: string } | null // 外部修改检测状态
|
||||||
|
loadingStates: Record<string, boolean> // 全局加载状态
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模块级 getter — 供 Sidebar/OutlinePanel 访问 MetonaEditor 实例
|
||||||
|
function getMetonaEditor(): MarkdownEditor | null
|
||||||
|
function setMetonaEditorGetter(fn: () => MarkdownEditor | null): void
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 sidebarStore — 侧边栏状态
|
### 3.4 sidebarStore — 侧边栏状态
|
||||||
@@ -151,7 +157,7 @@ interface SidebarState {
|
|||||||
isVisible: boolean // 是否显示
|
isVisible: boolean // 是否显示
|
||||||
rootPath: string | null // 当前打开的文件夹路径
|
rootPath: string | null // 当前打开的文件夹路径
|
||||||
tree: FileNode[] // 目录树数据
|
tree: FileNode[] // 目录树数据
|
||||||
expandedDirs: Set<string> // 已展开的目录集合
|
expandedDirs: string[] // 已展开的目录集合
|
||||||
sidebarWidth: number // 侧边栏宽度 (180~500)
|
sidebarWidth: number // 侧边栏宽度 (180~500)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -212,34 +218,78 @@ db.version(1).stores({
|
|||||||
| `window:confirmClose` | 无 | 请求确认关闭 |
|
| `window:confirmClose` | 无 | 请求确认关闭 |
|
||||||
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
| `sidebar:dirChanged` | 无 | 目录结构变化 |
|
||||||
|
|
||||||
## 6. 编辑器架构 — Milkdown v7 (WYSIWYG) + SourceEditor (textarea)
|
## 6. 编辑器架构 — MetonaEditor v0.1.14
|
||||||
|
|
||||||
### 6.1 双编辑模式
|
### 6.1 三模式视图
|
||||||
|
|
||||||
- **编辑模式 (Milkdown)**:基于 ProseMirror 的 WYSIWYG Markdown 编辑器,支持格式化工具栏(粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/分割线)、搜索替换面板(含正则支持)、自动配对括号/引号、undo/redo
|
- **编辑模式 (edit)**:纯文本编辑器,显示 Markdown 源码
|
||||||
- **源码模式 (SourceEditor)**:原生 textarea 控制 Markdown 原文,支持 Tab 缩进、Ctrl+B/I 快捷键
|
- **分屏模式 (split)**:左侧编辑、右侧实时预览,同步滚动
|
||||||
|
- **预览模式 (preview)**:仅显示渲染后的 HTML
|
||||||
|
|
||||||
两种模式共享同一 tabStore 数据源,可随时切换。
|
MetonaEditor 内置模式切换工具栏,与应用层的 viewMode store 双向同步,切换时自动持久化到 IndexedDB。
|
||||||
|
|
||||||
### 6.2 插件体系
|
### 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 预设插件:
|
||||||
|
|
||||||
| 插件 | 说明 |
|
| 插件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| commonmark | 基础 Markdown 语法 |
|
| searchReplace | Ctrl+F 查找、Ctrl+H 替换面板 |
|
||||||
| gfm | GitHub Flavored Markdown(表格/任务列表/删除线等) |
|
| imagePaste | Ctrl+V 粘贴剪贴板图片,自动转 base64 |
|
||||||
| history | undo/redo |
|
|
||||||
| listener | 内容变更监听 |
|
|
||||||
| indent | Tab 缩进 |
|
|
||||||
| trailing | 尾随换行 |
|
|
||||||
| clipboard | 剪贴板增强 |
|
|
||||||
| searchPlugin (自研) | 搜索高亮装饰 |
|
|
||||||
| autoPairPlugin (自研) | 自动配对括号/引号 |
|
|
||||||
|
|
||||||
### 6.3 滚动与选区持久化
|
> 注:autoSave 插件仅支持 localStorage,而 MarkLite 需要文件系统保存(Electron IPC),因此使用自定义 useAutoSave hook。
|
||||||
|
|
||||||
切换标签时自动保存/恢复:
|
### 6.4 渲染管线集成
|
||||||
- 滚动位置 (`scrollTop`)
|
|
||||||
- 光标选区 (`selectionStart`, `selectionEnd`)
|
通过 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 渲染管线
|
## 7. Markdown 渲染管线
|
||||||
|
|
||||||
@@ -271,7 +321,7 @@ rehype-highlight 代码语法高亮
|
|||||||
rehype-stringify 序列化为 HTML
|
rehype-stringify 序列化为 HTML
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
dangerouslySetInnerHTML 渲染到 DOM
|
Renderer (MetonaEditor preview / Preview component)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. UI 设计
|
## 8. UI 设计
|
||||||
@@ -304,18 +354,21 @@ dangerouslySetInnerHTML 渲染到 DOM
|
|||||||
┌──────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ MarkLite - filename.md ─ □ ✕ │
|
│ MarkLite - filename.md ─ □ ✕ │
|
||||||
├──────────────────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 🔗 ℹ️ │
|
│ 📁 打开 │ 💾 保存 │ 自动 │ 🌙 🌐 ℹ️ │
|
||||||
├──────────────────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
│ [file1.md] [file2.md] [未命名] [+] │
|
│ [file1.md] [file2.md] [未命名] [+] │
|
||||||
├──────────┬─────────────────────────────────────────────────────────────┤
|
├──────────┬───────────────────────────────────────────────────────────────┤
|
||||||
│ 资源管理器 │ │
|
│ 资源管理器 │ │
|
||||||
│ ▼ project │ 1 # Title │ Title │
|
│ ▼ project │ ┌─────────────────────────────────────────────┐ │
|
||||||
│ 📁 src │ 2 │ ─────── │
|
│ 📁 src │ │ B I S U </> │ H1 H2 H3 │ " 1. 2. ≡ ⇥ ⇤ │ │ │
|
||||||
│ 📄 file1│ 3 content... │ content... │
|
│ 📄 file1│ │ 🔗 🖼 ⊞ — │ ↶ ↷ │ 📝 ⇔ 👁 ⊞ │ │ │
|
||||||
│ 📄 file2│ │ │
|
│ 📄 file2│ ├─────────────────────────────────────────────┤ │
|
||||||
├──────────┴─────────────────────────────────────────────────────────────┤
|
│ │ │ # Title │ Title │ │
|
||||||
│ filename.md │ UTF-8 │ Markdown │
|
│ 文档大纲 │ │ │ ─────── │ │
|
||||||
└────────────────────────────────────────────────────────────────────────┘
|
│ · Title │ │ content... │ content... │ │
|
||||||
|
├──────────┴──┴─────────────────────────────────────────────┴──────────────┤
|
||||||
|
│ (MetonaEditor 底栏: 字数/行数/阅读时间) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. 构建与发布
|
## 9. 构建与发布
|
||||||
@@ -351,7 +404,8 @@ npm run build:portable # 便携版(免安装)
|
|||||||
| zustand | ^5.0 | 状态管理 |
|
| zustand | ^5.0 | 状态管理 |
|
||||||
| dexie | ^4.0 | IndexedDB 封装 |
|
| dexie | ^4.0 | IndexedDB 封装 |
|
||||||
| nanoid | ^5.0 | 唯一 ID 生成 |
|
| nanoid | ^5.0 | 唯一 ID 生成 |
|
||||||
| @codemirror/* | ^6.x | 代码编辑器 |
|
| @metona-team/metona-editor | 0.1.14 | Markdown 编辑器(零依赖) |
|
||||||
|
| @metona-team/metona-toast | ^2.0.1 | Toast 通知组件 |
|
||||||
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
| unified / remark / rehype | ^11.0 | Markdown 渲染管线 |
|
||||||
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
| rehype-highlight | ^7.0 | 代码语法高亮 |
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -1,4 +1,4 @@
|
|||||||
# Electron 项目开发环境完整配置指南
|
# MarkLite v0.4.3 — 开发环境配置指南
|
||||||
|
|
||||||
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
以下涵盖从 Node 版本管理、镜像源配置到 electron-builder 打包的全流程。
|
||||||
|
|
||||||
@@ -21,16 +21,23 @@ nvm alias default 18
|
|||||||
|
|
||||||
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
npm v9+ 已不支持 `npm config set` 设置自定义键名,必须直接编辑 `.npmrc` 文件。
|
||||||
|
|
||||||
### 推荐方式:项目级 .npmrc(仅对当前项目生效)
|
### 项目级 .npmrc(推荐)
|
||||||
|
|
||||||
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
在项目根目录下创建或编辑 `.npmrc` 文件,写入以下内容:
|
||||||
|
|
||||||
```
|
```
|
||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
|
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||||
|
//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<your_token>
|
||||||
|
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
|
||||||
|
|
||||||
|
# Electron 二进制下载镜像
|
||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 注:`@metona-team` scope 指向 MetonaTeam 私有 npm registry,用于安装 `@metona-team/metona-editor` 和 `@metona-team/metona-toast`。如需使用公开版本可参考 `.npmrc.example`。
|
||||||
|
|
||||||
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
如果想让所有项目都生效,编辑全局 `.npmrc`(可通过 `npm config get userconfig` 查看路径),内容同上。
|
||||||
|
|
||||||
## 三、配置文件说明
|
## 三、配置文件说明
|
||||||
@@ -38,6 +45,7 @@ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-
|
|||||||
| 配置项 | 作用 |
|
| 配置项 | 作用 |
|
||||||
|:-------|:-----|
|
|:-------|:-----|
|
||||||
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
| `registry` | npm 包下载源,避免从 npmjs.org 拉包缓慢 |
|
||||||
|
| `@metona-team:registry` | MetonaTeam 私有包 scope registry |
|
||||||
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
| `ELECTRON_MIRROR` | Electron 二进制文件(electron.exe 等)的下载源 |
|
||||||
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
| `ELECTRON_BUILDER_BINARIES_MIRROR` | electron-builder 打包工具链的下载源 |
|
||||||
|
|
||||||
@@ -66,6 +74,8 @@ nvm use 18
|
|||||||
|
|
||||||
# ② 在项目根目录创建 .npmrc,写入:
|
# ② 在项目根目录创建 .npmrc,写入:
|
||||||
# registry=https://registry.npmmirror.com
|
# registry=https://registry.npmmirror.com
|
||||||
|
# @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||||
|
# //git.metona.cn/api/packages/MetonaTeam/npm/:_auth=<token>
|
||||||
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
# ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||||
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|
||||||
@@ -78,5 +88,6 @@ npm run build
|
|||||||
## 六、常见问题排查
|
## 六、常见问题排查
|
||||||
|
|
||||||
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
- **`ELECTRON_MIRROR is not a valid npm option`**:npm 版本太新,禁止用 `npm config set` 设置自定义键,改用编辑 `.npmrc` 文件解决。
|
||||||
|
- **`@metona-team` 包安装失败**:检查 `.npmrc` 中 `@metona-team:registry` 和认证 token 配置是否正确。
|
||||||
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
- **下载 Electron 还是走 GitHub**:检查 `.npmrc` 文件名是否正确(不要写成 `.npmrc.txt`),以及是否重新打开了终端。
|
||||||
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
- **nvm 切换版本后 node 没变**:确保以管理员身份运行终端,且 nvm 安装路径没有中文或空格。
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
{
|
|
||||||
"review_date": "2026-07-06",
|
|
||||||
"reviewer": "CatPaw (苏浅玥) — Follow-up Audit & Fix",
|
|
||||||
"project": "MarkLite",
|
|
||||||
"overall_score": 96,
|
|
||||||
"verdict": "pass",
|
|
||||||
"previous_review": {
|
|
||||||
"review_date": "2026-06-03",
|
|
||||||
"overall_score": 82,
|
|
||||||
"verdict": "needs-fix"
|
|
||||||
},
|
|
||||||
"score": {
|
|
||||||
"code_quality": {
|
|
||||||
"score": 95,
|
|
||||||
"weight": 0.25,
|
|
||||||
"details": {
|
|
||||||
"typescript": {
|
|
||||||
"status": "pass",
|
|
||||||
"errors": 0,
|
|
||||||
"fixes_applied": [
|
|
||||||
"useMilkdown.ts:73 — Replaced state.selection.constructor.create with TextSelection.create from @milkdown/prose/state",
|
|
||||||
"useMilkdown.ts:42-56 — Fixed dead-code bug: Backspace handler was unreachable because PAIRS['Backspace'] check returned false first",
|
|
||||||
"package.json — Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom dependency plugins as direct deps, eliminating @milkdown/ctx 7.21.1/7.21.2 version duplication",
|
|
||||||
"useFolderOperations.ts — openFolderDialog() return type fixed (previously resolved by prior commit)",
|
|
||||||
"types/ipc.ts — Duplicate electronAPI declaration removed (previously resolved by prior commit)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"eslint": {
|
|
||||||
"status": "pass",
|
|
||||||
"errors": 0,
|
|
||||||
"warnings": 0,
|
|
||||||
"fixes_applied": [
|
|
||||||
"OutlinePanel.tsx — Extracted parseHeadings() and Heading interface to outlineUtils.ts to satisfy react-refresh/only-export-components",
|
|
||||||
"useStatusBarItems.tsx — Extracted 5 StatusBar component definitions to StatusBarItems.tsx to satisfy react-refresh/only-export-components",
|
|
||||||
"tabStore.test.ts:55 — Unused switchToTab variable removed (previously resolved by prior commit)",
|
|
||||||
"Editor.tsx / Preview.tsx — react-hooks/exhaustive-deps handled with eslint-disable comments (previously resolved by prior commit)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"npm_warnings": {
|
|
||||||
"status": "pass",
|
|
||||||
"fixes_applied": [
|
|
||||||
"package.json — Added allowScripts config for electron and esbuild postinstall scripts via npm approve-scripts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tests": {
|
|
||||||
"status": "pass",
|
|
||||||
"total_files": 6,
|
|
||||||
"total_tests": 96,
|
|
||||||
"all_passed": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"performance_optimization": {
|
|
||||||
"score": 95,
|
|
||||||
"weight": 0.2,
|
|
||||||
"details": {
|
|
||||||
"markdown_cache": {
|
|
||||||
"status": "pass",
|
|
||||||
"implementation": "LRU cache with MAX_CACHE_SIZE=10",
|
|
||||||
"features": ["filePath-based cache key", "LRU eviction", "null key handling"]
|
|
||||||
},
|
|
||||||
"zustand_selectors": {
|
|
||||||
"status": "pass",
|
|
||||||
"implementation": "Fine-grained selectors throughout",
|
|
||||||
"example": "useTabStore(s => s.tabs) instead of full store"
|
|
||||||
},
|
|
||||||
"codemirror_compartment": {
|
|
||||||
"status": "pass",
|
|
||||||
"implementation": "Compartment.reconfigure() for theme switching",
|
|
||||||
"benefit": "No EditorView reconstruction on theme change"
|
|
||||||
},
|
|
||||||
"debounce": {
|
|
||||||
"status": "pass",
|
|
||||||
"implementations": [
|
|
||||||
"Preview: 150ms debounce on markdown rendering",
|
|
||||||
"TabStore: 500ms debounce on DB persistence"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"architecture_refactoring": {
|
|
||||||
"score": 92,
|
|
||||||
"weight": 0.2,
|
|
||||||
"details": {
|
|
||||||
"component_split": {
|
|
||||||
"status": "pass",
|
|
||||||
"extractions": [
|
|
||||||
"FileTree extracted from Sidebar",
|
|
||||||
"useFolderOperations hook from Sidebar",
|
|
||||||
"EditorToolbar from Editor",
|
|
||||||
"Toast with SingleToast sub-component",
|
|
||||||
"parseHeadings + Heading extracted to outlineUtils.ts",
|
|
||||||
"StatusBar item components extracted to StatusBarItems.tsx"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"type_safety": {
|
|
||||||
"status": "pass",
|
|
||||||
"positives": [
|
|
||||||
"Shared types properly defined",
|
|
||||||
"IpcInvokeMap type mapping implemented",
|
|
||||||
"Generic tryAsync pattern with proper typing",
|
|
||||||
"ElectronAPI centralized in types/ipc.ts, preload.d.ts imports from it",
|
|
||||||
"All @milkdown/* packages pinned to exact versions to prevent type duplication"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"error_handling": {
|
|
||||||
"status": "pass",
|
|
||||||
"implementation": [
|
|
||||||
"AppError class with code and cause",
|
|
||||||
"logError() for logging",
|
|
||||||
"getErrorMessage() for user-facing messages",
|
|
||||||
"tryAsync() wrapper returning [result, error] tuple",
|
|
||||||
"ErrorBoundary for React component errors"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"user_experience": {
|
|
||||||
"score": 90,
|
|
||||||
"weight": 0.2,
|
|
||||||
"details": {
|
|
||||||
"confirm_dialog": {
|
|
||||||
"status": "pass",
|
|
||||||
"features": [
|
|
||||||
"Promise-based API via useConfirm hook",
|
|
||||||
"Focus management (save/restore)",
|
|
||||||
"ESC key handling",
|
|
||||||
"Background scroll lock",
|
|
||||||
"ARIA: role=alertdialog, aria-modal, aria-labelledby, aria-describedby",
|
|
||||||
"Variant support (danger/warning/info)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"loading_states": {
|
|
||||||
"status": "pass",
|
|
||||||
"features": [
|
|
||||||
"LoadingSpinner with size variants (small/medium/large)",
|
|
||||||
"Overlay mode support",
|
|
||||||
"Global loading state via editorStore.loadingStates",
|
|
||||||
"StatusBar integration with contextual labels",
|
|
||||||
"ARIA: role=status, aria-label, aria-busy"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"toast": {
|
|
||||||
"status": "pass",
|
|
||||||
"features": [
|
|
||||||
"Multi-toast stacking (max 5)",
|
|
||||||
"Type differentiation with icons",
|
|
||||||
"Auto-dismiss with configurable duration",
|
|
||||||
"Enter/exit animations",
|
|
||||||
"Close button",
|
|
||||||
"ARIA: role=alert, aria-live=assertive"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accessibility": {
|
|
||||||
"status": "pass",
|
|
||||||
"features": [
|
|
||||||
"File tree: role=tree, role=treeitem, aria-expanded, aria-selected",
|
|
||||||
"Keyboard navigation: Enter/Space on tree items",
|
|
||||||
"Sidebar: aria-label on sections",
|
|
||||||
"StatusBar: role=status, aria-live=polite",
|
|
||||||
"Preview: aria-live=polite, aria-busy during render",
|
|
||||||
"Resize handle: role=separator, aria-orientation, tabIndex"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"developer_experience": {
|
|
||||||
"score": 92,
|
|
||||||
"weight": 0.15,
|
|
||||||
"details": {
|
|
||||||
"test_framework": {
|
|
||||||
"status": "pass",
|
|
||||||
"setup": {
|
|
||||||
"framework": "Vitest 4.1.8",
|
|
||||||
"environment": "jsdom",
|
|
||||||
"setup_file": "@testing-library/jest-dom/vitest",
|
|
||||||
"coverage_provider": "v8",
|
|
||||||
"coverage_targets": ["stores", "lib", "hooks"]
|
|
||||||
},
|
|
||||||
"test_files": [
|
|
||||||
"tabStore.test.ts (22 tests)",
|
|
||||||
"editorStore.test.ts (12 tests)",
|
|
||||||
"fileUtils.test.ts (22 tests)",
|
|
||||||
"markdown.test.ts (16 tests)",
|
|
||||||
"errorHandler.test.ts (12 tests)",
|
|
||||||
"useDocStats.test.ts (12 tests)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"git_hooks": {
|
|
||||||
"status": "pass",
|
|
||||||
"setup": {
|
|
||||||
"tool": "Husky 9.1.7",
|
|
||||||
"pre_commit": "lint-staged",
|
|
||||||
"lint_staged": {
|
|
||||||
"ts_tsx": ["eslint --fix", "tsc --noEmit --pretty"],
|
|
||||||
"json_css_md": ["prettier --write"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"documentation": {
|
|
||||||
"status": "pass",
|
|
||||||
"files": ["CONTRIBUTING.md", "README.md", "DESIGN.md", "DEVSETUP.md"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"issues_resolved": [
|
|
||||||
{
|
|
||||||
"severity": "critical",
|
|
||||||
"category": "typescript",
|
|
||||||
"file": "src/renderer/components/Editor/useMilkdown.ts",
|
|
||||||
"lines": [73],
|
|
||||||
"description": "state.selection.constructor.create — Property 'create' does not exist on type 'Function'",
|
|
||||||
"fix": "Imported TextSelection from @milkdown/prose/state, replaced with TextSelection.create(tr.doc, from + 1)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "critical",
|
|
||||||
"category": "typescript",
|
|
||||||
"file": "src/renderer/components/Editor/useMilkdown.ts",
|
|
||||||
"lines": [173, 190, 193, 194],
|
|
||||||
"description": "Milkdown plugin type incompatibility: @milkdown/ctx 7.21.2 (top-level) vs 7.21.1 (nested in plugin packages) caused Ctx #private field mismatch",
|
|
||||||
"fix": "Pinned all @milkdown/* packages to exact 7.21.1, added 5 phantom-dependency plugins as direct deps, reinstalled to eliminate nested @milkdown/ctx copy",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "critical",
|
|
||||||
"category": "typescript",
|
|
||||||
"file": "src/renderer/hooks/useFolderOperations.ts",
|
|
||||||
"lines": [20, 21, 24, 27],
|
|
||||||
"description": "openFolderDialog() return type mismatch",
|
|
||||||
"fix": "IPC handler changed to return string | null, type definitions aligned (resolved in prior commit)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "critical",
|
|
||||||
"category": "typescript",
|
|
||||||
"file": "src/renderer/types/ipc.ts",
|
|
||||||
"lines": [54, 58],
|
|
||||||
"description": "Duplicate Window.electronAPI declaration",
|
|
||||||
"fix": "Removed duplicate from ipc.ts, preload.d.ts is source of truth (resolved in prior commit)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "error",
|
|
||||||
"category": "eslint",
|
|
||||||
"file": "src/renderer/stores/__tests__/tabStore.test.ts",
|
|
||||||
"lines": [55],
|
|
||||||
"description": "'switchToTab' is destructured but never used in test",
|
|
||||||
"fix": "Test updated (resolved in prior commit)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "warning",
|
|
||||||
"category": "eslint/react-refresh",
|
|
||||||
"file": "src/renderer/components/OutlinePanel/OutlinePanel.tsx",
|
|
||||||
"lines": [19],
|
|
||||||
"description": "Fast refresh only works when a file only exports components",
|
|
||||||
"fix": "Extracted parseHeadings() and Heading interface to outlineUtils.ts",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "warning",
|
|
||||||
"category": "eslint/react-refresh",
|
|
||||||
"file": "src/renderer/hooks/useStatusBarItems.tsx",
|
|
||||||
"lines": [10, 20, 42, 59, 64],
|
|
||||||
"description": "5 react-refresh/only-export-components warnings — component definitions mixed with hook export",
|
|
||||||
"fix": "Extracted 5 StatusBar item components to StatusBarItems.tsx",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "warning",
|
|
||||||
"category": "eslint/react-hooks",
|
|
||||||
"file": "src/renderer/components/Editor/Editor.tsx",
|
|
||||||
"lines": [43, 55],
|
|
||||||
"description": "useEffect hooks have missing dependencies",
|
|
||||||
"fix": "Added eslint-disable comments with explanation (resolved in prior commit)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "warning",
|
|
||||||
"category": "eslint/react-hooks",
|
|
||||||
"file": "src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"lines": [53],
|
|
||||||
"description": "useEffect missing 'activeTab' dependency",
|
|
||||||
"fix": "Correctly using optional chaining in deps array (resolved in prior commit)",
|
|
||||||
"status": "fixed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"severity": "warning",
|
|
||||||
"category": "npm",
|
|
||||||
"file": "package.json",
|
|
||||||
"lines": [],
|
|
||||||
"description": "npm allow-scripts warning: electron and esbuild postinstall scripts not covered",
|
|
||||||
"fix": "Added allowScripts config via npm approve-scripts electron esbuild",
|
|
||||||
"status": "fixed"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"strengths": [
|
|
||||||
"Excellent LRU cache implementation for Markdown processor with proper eviction strategy",
|
|
||||||
"CodeMirror Compartment pattern for efficient theme switching without EditorView reconstruction",
|
|
||||||
"Comprehensive Zustand selector usage preventing unnecessary re-renders",
|
|
||||||
"Well-designed ConfirmDialog with full accessibility support (focus trap, ESC, ARIA)",
|
|
||||||
"LoadingSpinner component with multiple sizes, overlay mode, and proper ARIA attributes",
|
|
||||||
"Toast system supports stacking, types, animations, and accessibility",
|
|
||||||
"Unified error handling with AppError, logError, getErrorMessage, tryAsync patterns",
|
|
||||||
"Good component extraction (FileTree, EditorToolbar, useFolderOperations, outlineUtils, StatusBarItems)",
|
|
||||||
"96 unit tests covering core modules with proper mocking",
|
|
||||||
"Debounce implementation for both preview rendering and DB persistence",
|
|
||||||
"Git hooks with lint-staged for automated code quality checks",
|
|
||||||
"Comprehensive documentation (CONTRIBUTING.md, DESIGN.md, README.md)",
|
|
||||||
"All @milkdown/* packages pinned to exact versions preventing type duplication",
|
|
||||||
"Type-safe IPC architecture with centralized ElectronAPI definition",
|
|
||||||
"npm allow-scripts configured for secure postinstall script management"
|
|
||||||
],
|
|
||||||
"summary": "MarkLite has been fully audited and all issues resolved. TypeScript compilation passes with 0 errors, ESLint passes with 0 errors and 0 warnings, and all 96 tests pass. Key fixes include: (1) @milkdown/ctx version deduplication by pinning all Milkdown packages to exact 7.21.1, (2) TextSelection.create type fix in useMilkdown.ts, (3) Backspace auto-pair dead-code bug fix, (4) react-refresh warnings resolved by extracting utilities and components to separate files, (5) npm allow-scripts configuration for electron and esbuild. The project is production-ready."
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/Version-v0.3.12-orange?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/Version-v0.4.3-orange?style=flat-square" alt="Version">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -30,18 +30,21 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 |
|
||||||
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 |
|
||||||
| ✏️ **双编辑模式** | Milkdown WYSIWYG 编辑器(格式化工具栏/搜索替换/正则/自动配对)+ 源码模式(textarea)|
|
| ✏️ **编辑器** | 基于 MetonaEditor 的代码+预览编辑器,内置格式化工具栏、三模式切换(编辑/分屏/预览)、搜索替换、撤销重做、粘贴图片转 base64 |
|
||||||
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
|
| 👁 **实时预览** | 分屏模式下左侧编辑、右侧实时预览,基于 unified/rehype 管线渲染 |
|
||||||
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
||||||
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
|
| 🎨 **三种视图** | 编辑模式 / 分屏模式 / 预览模式,自由切换 |
|
||||||
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
|
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB),编辑器主题同步切换 |
|
||||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||||
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
||||||
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 |
|
| 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持正则表达式、大小写敏感 |
|
||||||
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 |
|
||||||
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
|
| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 |
|
||||||
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
| ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 |
|
||||||
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 |
|
||||||
|
| 🖼️ **粘贴图片** | Ctrl+V 粘贴剪贴板图片,自动转为 base64 内嵌 |
|
||||||
|
| 📝 **文档大纲** | 侧边栏显示当前文档标题结构,点击导航 |
|
||||||
|
| 🧹 **自动保存** | 文件修改后 2 秒防抖自动保存,可在工具栏开关 |
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
# 克隆仓库
|
||||||
git clone https://gitee.com/thzxx/MarkLite.git
|
git clone https://git.metona.cn/MetonaTeam/MarkLite.git
|
||||||
cd MarkLite
|
cd MarkLite
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
@@ -116,8 +119,6 @@ npm run test:coverage
|
|||||||
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
|
- `lib/` — 工具库(fileUtils, markdown, errorHandler)
|
||||||
- `hooks/` — 自定义 Hooks
|
- `hooks/` — 自定义 Hooks
|
||||||
|
|
||||||
测试文件位于对应模块的 `__tests__/` 目录下,命名格式为 `*.test.ts`。
|
|
||||||
|
|
||||||
## ⌨️ 快捷键
|
## ⌨️ 快捷键
|
||||||
|
|
||||||
| 快捷键 | 功能 |
|
| 快捷键 | 功能 |
|
||||||
@@ -129,15 +130,11 @@ npm run test:coverage
|
|||||||
| `Ctrl + O` | 打开文件 |
|
| `Ctrl + O` | 打开文件 |
|
||||||
| `Ctrl + S` | 保存文件 |
|
| `Ctrl + S` | 保存文件 |
|
||||||
| `Ctrl + Shift + S` | 另存为 |
|
| `Ctrl + Shift + S` | 另存为 |
|
||||||
| `Ctrl + 1` | 编辑模式 |
|
|
||||||
| `Ctrl + 2` | 预览模式 |
|
|
||||||
| `Ctrl + 3` | 源码模式 |
|
|
||||||
| `Ctrl + F` | 搜索 |
|
| `Ctrl + F` | 搜索 |
|
||||||
| `Ctrl + H` | 搜索并替换 |
|
| `Ctrl + H` | 搜索并替换 |
|
||||||
| `Ctrl + B` | 粗体 |
|
| `Ctrl + Z` / `Ctrl + Y` | 撤销 / 重做 |
|
||||||
| `Ctrl + I` | 斜体 |
|
| `Ctrl + B` / `Ctrl + I` | 粗体 / 斜体(编辑器内置) |
|
||||||
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
|
| `Ctrl + 1/2/3` | 切换模式(编辑器内置) |
|
||||||
| `Esc` | 关闭搜索栏 |
|
|
||||||
|
|
||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
@@ -146,11 +143,12 @@ npm run test:coverage
|
|||||||
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
| 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 |
|
||||||
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks |
|
||||||
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 |
|
||||||
| 编辑器 | [Milkdown](https://milkdown.dev/) v7 | WYSIWYG 编辑器 + 源码模式 |
|
| 编辑器 | [MetonaEditor](https://git.metona.cn/MetonaTeam/MetonaEditor) v0.1.14 | 零依赖 Markdown 编辑器,三模式视图 + 插件系统 |
|
||||||
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 |
|
||||||
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
|
| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 |
|
||||||
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
|
| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 |
|
||||||
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
|
| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 |
|
||||||
|
| Toast | [@metona-team/metona-toast](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-toast) v2.0.1 | 通知提示组件 |
|
||||||
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
|
| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 |
|
||||||
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
|
| 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 |
|
||||||
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
| 样式 | CSS Variables | 主题驱动,亮色/暗色切换 |
|
||||||
@@ -164,7 +162,7 @@ MarkLite/
|
|||||||
├── tsconfig.node.json # Node 端 TypeScript 配置
|
├── tsconfig.node.json # Node 端 TypeScript 配置
|
||||||
├── electron.vite.config.ts # electron-vite 构建配置
|
├── electron.vite.config.ts # electron-vite 构建配置
|
||||||
├── vitest.config.ts # Vitest 测试框架配置
|
├── vitest.config.ts # Vitest 测试框架配置
|
||||||
├── .eslintrc.cjs # ESLint + TypeScript 规则
|
├── eslint.config.mjs # ESLint + TypeScript 规则
|
||||||
├── CONTRIBUTING.md # 贡献指南
|
├── CONTRIBUTING.md # 贡献指南
|
||||||
│
|
│
|
||||||
├── src/
|
├── src/
|
||||||
@@ -183,19 +181,14 @@ MarkLite/
|
|||||||
│ │ ├── main.tsx # React 入口
|
│ │ ├── main.tsx # React 入口
|
||||||
│ │ ├── App.tsx # 根组件:布局编排、全局事件
|
│ │ ├── App.tsx # 根组件:布局编排、全局事件
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── components/ # UI 组件 (23个)
|
│ │ ├── components/ # UI 组件
|
||||||
│ │ │ ├── Toolbar/ # 工具栏
|
│ │ │ ├── Toolbar/ # 工具栏(文件操作、自动保存、主题、关于)
|
||||||
│ │ │ ├── TabBar/ # 标签页栏 (含拖拽排序)
|
│ │ │ ├── TabBar/ # 标签页栏(含拖拽排序、右键菜单)
|
||||||
│ │ │ ├── Editor/ # Milkdown 编辑器 + 工具栏
|
│ │ │ ├── Editor/ # MetonaEditor 编辑器集成
|
||||||
│ │ │ ├── Preview/ # Markdown 预览面板
|
│ │ │ ├── Sidebar/ # 侧边栏文件树 + 文档大纲
|
||||||
│ │ │ ├── SourceEditor/ # 源码编辑模式 (textarea)
|
|
||||||
│ │ │ ├── Sidebar/ # 侧边栏文件树
|
|
||||||
│ │ │ ├── FileTree/ # 递归文件树
|
│ │ │ ├── FileTree/ # 递归文件树
|
||||||
│ │ │ ├── OutlinePanel/ # 文档大纲 (活跃标题高亮)
|
│ │ │ ├── OutlinePanel/ # 文档大纲(活跃标题高亮)
|
||||||
│ │ │ ├── StatusBar/ # 状态栏 (自动保存开关)
|
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕(应用图标、最近文件)
|
||||||
│ │ │ ├── SearchReplace/ # 搜索替换面板 (支持正则)
|
|
||||||
│ │ │ ├── WelcomeScreen/ # 欢迎屏幕
|
|
||||||
│ │ │ ├── Toast/ # Toast 通知
|
|
||||||
│ │ │ ├── ConfirmDialog/ # 确认对话框
|
│ │ │ ├── ConfirmDialog/ # 确认对话框
|
||||||
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
|
│ │ │ ├── ModifiedBanner/ # 文件外部修改提示
|
||||||
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
|
│ │ │ ├── DropOverlay/ # 拖拽文件覆盖层
|
||||||
@@ -207,25 +200,27 @@ MarkLite/
|
|||||||
│ │ ├── stores/ # Zustand 状态管理
|
│ │ ├── stores/ # Zustand 状态管理
|
||||||
│ │ │ ├── tabStore.ts # 标签页状态
|
│ │ │ ├── tabStore.ts # 标签页状态
|
||||||
│ │ │ ├── editorStore.ts # 编辑器状态
|
│ │ │ ├── editorStore.ts # 编辑器状态
|
||||||
│ │ │ └── sidebarStore.ts # 侧边栏状态
|
│ │ │ ├── sidebarStore.ts # 侧边栏状态
|
||||||
|
│ │ │ └── autoSaveStore.ts # 自动保存状态
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── hooks/ # 自定义 Hooks (19个)
|
│ │ ├── hooks/ # 自定义 Hooks
|
||||||
│ │ │ ├── useTheme.ts # 暗色/亮色主题
|
│ │ │ ├── useTheme.ts # 暗色/亮色主题
|
||||||
│ │ │ ├── useSettings.ts # 视图模式
|
|
||||||
│ │ │ ├── useSettingsInit.ts # 设置初始加载
|
│ │ │ ├── useSettingsInit.ts # 设置初始加载
|
||||||
│ │ │ ├── useKeyboard.ts # 全局快捷键
|
│ │ │ ├── useKeyboard.ts # 全局快捷键
|
||||||
│ │ │ ├── useDragDrop.ts # 拖拽打开
|
│ │ │ ├── useDragDrop.ts # 拖拽打开
|
||||||
│ │ │ ├── useFileWatch.ts # 外部修改监听
|
│ │ │ ├── useFileWatch.ts # 外部修改监听
|
||||||
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
|
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
|
||||||
│ │ │ ├── useAutoSave.ts # 自动保存
|
│ │ │ ├── useAutoSave.ts # 自动保存(Electron IPC)
|
||||||
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
|
│ │ │ ├── useAutoExpandDir.ts # 自动展开目录
|
||||||
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
|
│ │ │ ├── useActiveHeading.ts # 活跃标题追踪
|
||||||
|
│ │ │ ├── useDocStats.ts # 文档统计
|
||||||
│ │ │ └── ... # 文件操作、IPC监听等
|
│ │ │ └── ... # 文件操作、IPC监听等
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── lib/ # 工具库
|
│ │ ├── lib/ # 工具库
|
||||||
│ │ │ ├── markdown.ts # Markdown 渲染管线
|
│ │ │ ├── markdown.ts # Markdown 渲染管线(unified/rehype)
|
||||||
│ │ │ ├── fileUtils.ts # 文件工具函数
|
│ │ │ ├── fileUtils.ts # 文件工具函数
|
||||||
│ │ │ ├── errorHandler.ts # 错误处理
|
│ │ │ ├── errorHandler.ts # 错误处理
|
||||||
|
│ │ │ ├── toast.ts # Toast 通知(MetonaToast)
|
||||||
│ │ │ └── constants.ts # 常量定义
|
│ │ │ └── constants.ts # 常量定义
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── db/ # IndexedDB 持久化层
|
│ │ ├── db/ # IndexedDB 持久化层
|
||||||
@@ -239,7 +234,8 @@ MarkLite/
|
|||||||
│ │
|
│ │
|
||||||
│ └── shared/ # 主进程/渲染进程共享
|
│ └── shared/ # 主进程/渲染进程共享
|
||||||
│ ├── ipc-channels.ts # IPC 通道名常量
|
│ ├── ipc-channels.ts # IPC 通道名常量
|
||||||
│ └── types.ts # 共享类型定义
|
│ ├── types.ts # 共享类型定义
|
||||||
|
│ └── constants.ts # 共享常量(版本号等)
|
||||||
│
|
│
|
||||||
├── assets/
|
├── assets/
|
||||||
│ └── icon.ico # 应用图标
|
│ └── icon.ico # 应用图标
|
||||||
@@ -253,14 +249,16 @@ MarkLite/
|
|||||||
|
|
||||||
- ✅ 标题(h1 ~ h6)
|
- ✅ 标题(h1 ~ h6)
|
||||||
- ✅ **粗体** / *斜体* / ~~删除线~~
|
- ✅ **粗体** / *斜体* / ~~删除线~~
|
||||||
|
- ✅ 高亮标记 `==text==` / 上标 `x^2^` / 下标 `H~2~O`
|
||||||
- ✅ 有序列表 / 无序列表
|
- ✅ 有序列表 / 无序列表
|
||||||
- ✅ 任务列表 `- [x]`
|
- ✅ 任务列表 `- [x]`
|
||||||
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
|
- ✅ 代码块(围栏式 + 语法高亮,180+ 语言)
|
||||||
- ✅ 行内代码
|
- ✅ 行内代码
|
||||||
- ✅ 链接 / 图片(支持相对路径)
|
- ✅ 链接 / 图片(支持相对路径、粘贴图片)
|
||||||
- ✅ 表格
|
- ✅ 表格(含列对齐)
|
||||||
- ✅ 引用块
|
- ✅ 引用块
|
||||||
- ✅ 水平线
|
- ✅ 水平线
|
||||||
|
- ✅ Emoji 短码 `:smile:` `:rocket:`
|
||||||
- ✅ HTML 内联元素
|
- ✅ HTML 内联元素
|
||||||
- ✅ GFM(GitHub Flavored Markdown)
|
- ✅ GFM(GitHub Flavored Markdown)
|
||||||
|
|
||||||
@@ -277,7 +275,7 @@ npm run prepare
|
|||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|
||||||
[MIT License](LICENSE) © 2026 [thzxx](https://gitee.com/thzxx)
|
[MIT License](LICENSE) © 2026 [thzxx](https://git.metona.cn/thzxx)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 760 KiB |
@@ -0,0 +1,522 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-md-theme="auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>"/>
|
||||||
|
<title>MetonaEditor v0.1.10 — 在线演示</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root {
|
||||||
|
--app-bg: #f5f6f8;
|
||||||
|
--app-text: #1a1a2e;
|
||||||
|
--app-card: #ffffff;
|
||||||
|
--app-border: #e5e7eb;
|
||||||
|
--app-accent: #3b82f6;
|
||||||
|
--app-accent2: #8b5cf6;
|
||||||
|
|
||||||
|
/* dark overrides */
|
||||||
|
--header-bg: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
--header-text: #f1f5f9;
|
||||||
|
--badge-bg: rgba(255,255,255,0.12);
|
||||||
|
--badge-text: #e2e8f0;
|
||||||
|
}
|
||||||
|
[data-md-theme="dark"] {
|
||||||
|
--app-bg: #0f1117;
|
||||||
|
--app-text: #e2e8f0;
|
||||||
|
--app-card: #1a1d27;
|
||||||
|
--app-border: #2d3140;
|
||||||
|
--header-bg: linear-gradient(135deg, #0a0d15 0%, #161923 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--app-bg);
|
||||||
|
color: var(--app-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Header ====== */
|
||||||
|
.header {
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: var(--header-text);
|
||||||
|
padding: 32px 24px 24px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -60px; right: -60px;
|
||||||
|
width: 300px; height: 300px;
|
||||||
|
background: radial-gradient(circle, rgba(139,92,246,0.3) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.header h1 span.grad {
|
||||||
|
background: linear-gradient(135deg, var(--app-accent), var(--app-accent2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.header .badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.header .badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
color: var(--badge-text);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Toolbar Strip ====== */
|
||||||
|
.toolbar-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--app-card);
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.toolbar-strip label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar-strip select {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--app-bg);
|
||||||
|
color: var(--app-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar-strip button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--app-card);
|
||||||
|
color: var(--app-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar-strip button:hover { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
|
||||||
|
.toolbar-strip button.accent { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
|
||||||
|
.toolbar-strip .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.toggle-group { display: flex; gap: 2px; }
|
||||||
|
.toggle-group button {
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.toggle-group button:first-child { border-radius: 6px 0 0 6px; }
|
||||||
|
.toggle-group button:last-child { border-radius: 0 6px 6px 0; }
|
||||||
|
.toggle-group button.on { background: var(--app-accent); color: #fff; border-color: var(--app-accent); }
|
||||||
|
|
||||||
|
/* ====== Editor Container ====== */
|
||||||
|
.editor-section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.editor-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||||
|
background: var(--app-card);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
transition: box-shadow 0.3s, background 0.3s;
|
||||||
|
}
|
||||||
|
.editor-card:hover { box-shadow: 0 8px 36px rgba(0,0,0,0.1); }
|
||||||
|
|
||||||
|
/* ====== Info Cards ====== */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
background: var(--app-card);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
.info-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||||
|
.info-card .icon { font-size: 24px; margin-bottom: 8px; }
|
||||||
|
.info-card h4 { font-size: 14px; font-weight: 650; margin-bottom: 4px; }
|
||||||
|
.info-card p { font-size: 12px; color: var(--app-text); opacity: 0.65; margin: 0; }
|
||||||
|
|
||||||
|
/* ====== Footer ====== */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.footer a { color: var(--app-accent); text-decoration: none; }
|
||||||
|
|
||||||
|
/* ====== Responsive ====== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header h1 { font-size: 1.5rem; }
|
||||||
|
.toolbar-strip { padding: 8px 12px; gap: 6px; }
|
||||||
|
.toolbar-strip button { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.info-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<h1><span class="grad">Metona</span>Editor</h1>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge">v0.1.10</span>
|
||||||
|
<span class="badge">零依赖</span>
|
||||||
|
<span class="badge">532 tests</span>
|
||||||
|
<span class="badge">MIT</span>
|
||||||
|
<span class="badge">中文优先</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="toolbar-strip" id="controlBar">
|
||||||
|
<a href="index.html" style="text-decoration:none;font-size:18px;margin-right:8px;" title="返回首页">🏠</a>
|
||||||
|
|
||||||
|
<label>主题</label>
|
||||||
|
<select id="themeSelect">
|
||||||
|
<option value="auto">🌓 跟随系统</option>
|
||||||
|
<option value="light">☀️ 亮色</option>
|
||||||
|
<option value="dark">🌙 暗色</option>
|
||||||
|
<option value="warm">🔥 暖色</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>语言</label>
|
||||||
|
<select id="localeSelect">
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label>模式</label>
|
||||||
|
<div class="toggle-group" id="modeBtns">
|
||||||
|
<button data-mode="edit">编辑</button>
|
||||||
|
<button data-mode="split" class="on">分屏</button>
|
||||||
|
<button data-mode="preview">预览</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<label>行号</label>
|
||||||
|
<div class="toggle-group" id="lineNumBtns">
|
||||||
|
<button class="on">开</button>
|
||||||
|
<button>关</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>大纲</label>
|
||||||
|
<div class="toggle-group" id="outlineBtns">
|
||||||
|
<button>开</button>
|
||||||
|
<button class="on">关</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="demoToast()" title="演示 Toast 通知">💬 Toast</button>
|
||||||
|
<button onclick="demoOutline()" title="生成大纲">📋 大纲</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-card">
|
||||||
|
<div id="editor"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">📝</div>
|
||||||
|
<h4>丰富语法</h4>
|
||||||
|
<p>数学公式、脚注、定义列表、嵌套列表、表格对齐、任务列表、Emoji</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">⌨️</div>
|
||||||
|
<h4>快捷键</h4>
|
||||||
|
<p>Ctrl+B 粗体 · Ctrl+I 斜体 · Ctrl+S 保存 · Ctrl+F 搜索 · Ctrl+H 替换</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">🎨</div>
|
||||||
|
<h4>4 套主题</h4>
|
||||||
|
<p>亮色 · 暗色 · 暖色 · 跟随系统,实例级隔离,外部主题跟随</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">🔌</div>
|
||||||
|
<h4>4 个插件</h4>
|
||||||
|
<p>自动保存草稿 · 导出 MD/HTML · 搜索替换 · 图片粘贴</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">🧩</div>
|
||||||
|
<h4>编辑增强</h4>
|
||||||
|
<p>行号装订线 · 智能 Enter · 括号自动闭合 · 拖放文件 · 大纲面板</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="icon">🛡️</div>
|
||||||
|
<h4>安全</h4>
|
||||||
|
<p>XSS 防护 · URL 协议过滤 · HTML 实体保护 · sanitize 钩子</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
MetonaEditor v0.1.10 ·
|
||||||
|
<a href="index.html">首页</a> ·
|
||||||
|
<a href="docs.html">API 文档</a> ·
|
||||||
|
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> ·
|
||||||
|
MIT License
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="../dist/metona-editor.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const demoMd = [
|
||||||
|
'# 🚀 MetonaEditor 演示',
|
||||||
|
'',
|
||||||
|
'> 轻量、零依赖、精致美观的 **Markdown Editor** 库。',
|
||||||
|
'> 单文件,开箱即用,中文优先。',
|
||||||
|
'',
|
||||||
|
'## ✨ 核心特性',
|
||||||
|
'',
|
||||||
|
'- 零依赖 · 单文件打包 · UMD / ESM / CJS',
|
||||||
|
'- 三模式视图 · 分屏拖拽 · 滚动同步',
|
||||||
|
'- 行号装订线 · 当前行高亮',
|
||||||
|
'- 智能 Enter · 括号自动闭合',
|
||||||
|
'- 4 套主题 · 实例级隔离 · 外部跟随',
|
||||||
|
'- 插件系统 v2 · 依赖排序 · 异步加载',
|
||||||
|
'',
|
||||||
|
'## 📐 数学公式',
|
||||||
|
'',
|
||||||
|
'行内公式:爱因斯坦质能方程 $E = mc^2$',
|
||||||
|
'',
|
||||||
|
'块级公式:',
|
||||||
|
'$$',
|
||||||
|
'\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}',
|
||||||
|
'$$',
|
||||||
|
'',
|
||||||
|
'## 📋 表格与对齐',
|
||||||
|
'',
|
||||||
|
'| 版本 | 日期 | 测试数 |',
|
||||||
|
'| :--- | :---: | ---: |',
|
||||||
|
'| v0.1.10 | 2026-07 | 532+ |',
|
||||||
|
'| v0.1.6 | 2026-07 | 521 |',
|
||||||
|
'| v0.1.5 | 2026-07 | 499 |',
|
||||||
|
'',
|
||||||
|
'## ✅ 任务列表',
|
||||||
|
'',
|
||||||
|
'- [x] 解析器增强(11 项新语法)',
|
||||||
|
'- [x] 主题系统(实例隔离 + 外部跟随)',
|
||||||
|
'- [x] 插件 v2 + i18n 增强',
|
||||||
|
'- [x] 编辑体验(行号 / 自动格式化 / 括号闭合)',
|
||||||
|
'- [ ] 更多语言翻译包',
|
||||||
|
'- [ ] VS Code 插件',
|
||||||
|
'',
|
||||||
|
'## 🎯 嵌套列表',
|
||||||
|
'',
|
||||||
|
'1. 一级有序',
|
||||||
|
' 1. 二级有序',
|
||||||
|
' 2. 二级第二项',
|
||||||
|
'2. 回到一级',
|
||||||
|
' - 混杂无序',
|
||||||
|
' - 第二项',
|
||||||
|
' - 三级嵌套',
|
||||||
|
'',
|
||||||
|
'## 📖 定义列表',
|
||||||
|
'',
|
||||||
|
'MetonaEditor',
|
||||||
|
': 一款轻量、零依赖的中文优先 Markdown 编辑器库。',
|
||||||
|
': 支持 CommonMark + GFM 扩展语法。',
|
||||||
|
'',
|
||||||
|
'Markdown',
|
||||||
|
': 由 John Gruber 创建的轻量级标记语言。',
|
||||||
|
'',
|
||||||
|
'## 🔗 链接与图片',
|
||||||
|
'',
|
||||||
|
'访问 [MetonaEditor 仓库](https://git.metona.cn/MetonaTeam/MetonaEditor \'点击访问源码\')',
|
||||||
|
'',
|
||||||
|
'图片示例:',
|
||||||
|
'',
|
||||||
|
'## 💻 代码块',
|
||||||
|
'',
|
||||||
|
'```js',
|
||||||
|
'import MeEditor from \'@metona-team/metona-editor\';',
|
||||||
|
'',
|
||||||
|
'const editor = MeEditor.create(\'#editor\', {',
|
||||||
|
' value: \'# Hello World\',',
|
||||||
|
' mode: \'split\',',
|
||||||
|
' lineNumbers: true,',
|
||||||
|
' autoBrackets: true,',
|
||||||
|
' plugins: [\'autoSave\', \'searchReplace\'],',
|
||||||
|
' onChange: (value) => console.log(value),',
|
||||||
|
'});',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'## 🎨 文本样式',
|
||||||
|
'',
|
||||||
|
'- **粗体文本** 与 __另一种粗体__',
|
||||||
|
'- *斜体文本* 与 _另一种斜体_',
|
||||||
|
'- ~~删除线~~ 与 ==高亮标记==',
|
||||||
|
'- 化学式:H~2~O 与 数学:x^2^ + y^2^ = z^2^',
|
||||||
|
'- 行内代码:`const x = 42;`',
|
||||||
|
'- Emoji::smile: :rocket: :fire: :tada: :rainbow: :coffee:',
|
||||||
|
'',
|
||||||
|
'## 📌 脚注',
|
||||||
|
'',
|
||||||
|
'这是一个带脚注的句子[^1],这是另一个脚注[^note]。',
|
||||||
|
'',
|
||||||
|
'[^1]: 第一个脚注的详细说明内容。',
|
||||||
|
'[^note]: 脚注可以包含 **格式化** 和 `代码`。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'> **提示:** 试试拖放图片到编辑器、按 `Ctrl+F` 搜索、`Ctrl+H` 替换、',
|
||||||
|
'> 在列表行尾按 `Enter` 体验智能延续、选中文字后输入 `(` 体验自动包裹。',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const editor = window.MeEditor.create('#editor', {
|
||||||
|
value: demoMd,
|
||||||
|
mode: 'split',
|
||||||
|
height: 620,
|
||||||
|
lineNumbers: true,
|
||||||
|
autoBrackets: true,
|
||||||
|
wordCount: true,
|
||||||
|
outline: false,
|
||||||
|
theme: 'auto',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
plugins: ['autoSave', 'exportTool', 'searchReplace', 'imagePaste'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ====== Control Bar Handlers ======
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
document.getElementById('themeSelect').addEventListener('change', function () {
|
||||||
|
editor.setTheme(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Locale
|
||||||
|
document.getElementById('localeSelect').addEventListener('change', function () {
|
||||||
|
editor.setLocale(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mode buttons
|
||||||
|
document.querySelectorAll('#modeBtns button').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
editor.setMode(this.dataset.mode);
|
||||||
|
document.querySelectorAll('#modeBtns button').forEach(function (b) { b.classList.remove('on'); });
|
||||||
|
this.classList.add('on');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line numbers toggle
|
||||||
|
var lineNumBtns = document.querySelectorAll('#lineNumBtns button');
|
||||||
|
lineNumBtns[0].addEventListener('click', function () {
|
||||||
|
editor.config.lineNumbers = true;
|
||||||
|
if (editor.gutter) editor.gutter.style.display = '';
|
||||||
|
editor._renderGutter();
|
||||||
|
lineNumBtns[0].classList.add('on');
|
||||||
|
lineNumBtns[1].classList.remove('on');
|
||||||
|
});
|
||||||
|
lineNumBtns[1].addEventListener('click', function () {
|
||||||
|
editor.config.lineNumbers = false;
|
||||||
|
if (editor.gutter) editor.gutter.style.display = 'none';
|
||||||
|
lineNumBtns[1].classList.add('on');
|
||||||
|
lineNumBtns[0].classList.remove('on');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outline toggle
|
||||||
|
var outlineBtns = document.querySelectorAll('#outlineBtns button');
|
||||||
|
outlineBtns[0].addEventListener('click', function () {
|
||||||
|
editor.config.outline = true;
|
||||||
|
editor._buildOutline();
|
||||||
|
outlineBtns[0].classList.add('on');
|
||||||
|
outlineBtns[1].classList.remove('on');
|
||||||
|
});
|
||||||
|
outlineBtns[1].addEventListener('click', function () {
|
||||||
|
editor.config.outline = false;
|
||||||
|
var panel = editor.el.querySelector('.me-outline');
|
||||||
|
if (panel) panel.remove();
|
||||||
|
outlineBtns[1].classList.add('on');
|
||||||
|
outlineBtns[0].classList.remove('on');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast demo
|
||||||
|
window.demoToast = function () {
|
||||||
|
var types = ['success', 'error', 'warning', 'info'];
|
||||||
|
var type = types[Math.floor(Math.random() * types.length)];
|
||||||
|
var messages = {
|
||||||
|
success: '✅ 内容已自动保存',
|
||||||
|
error: '❌ 操作失败,请重试',
|
||||||
|
warning: '⚠️ 有未保存的更改',
|
||||||
|
info: 'ℹ️ 这是来自插件系统的通知',
|
||||||
|
};
|
||||||
|
editor.toast(messages[type], { type: type, duration: 2500 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Outline demo
|
||||||
|
window.demoOutline = function () {
|
||||||
|
if (editor.config.outline) {
|
||||||
|
editor.config.outline = false;
|
||||||
|
var panel = editor.el.querySelector('.me-outline');
|
||||||
|
if (panel) panel.remove();
|
||||||
|
outlineBtns[0].classList.remove('on');
|
||||||
|
outlineBtns[1].classList.add('on');
|
||||||
|
} else {
|
||||||
|
editor.config.outline = true;
|
||||||
|
editor._buildOutline();
|
||||||
|
outlineBtns[0].classList.add('on');
|
||||||
|
outlineBtns[1].classList.remove('on');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register custom shortcut
|
||||||
|
editor.registerShortcut('Ctrl+Shift+T', function () {
|
||||||
|
editor.toast('快捷键 Ctrl+Shift+T 已触发!', { type: 'info' });
|
||||||
|
}, 'Toast test');
|
||||||
|
|
||||||
|
// Listen for theme change to update select
|
||||||
|
editor.on('themeChange', function (data) {
|
||||||
|
document.getElementById('themeSelect').value = data.theme;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync editor mode button state
|
||||||
|
editor.on('modeChange', function (mode) {
|
||||||
|
document.querySelectorAll('#modeBtns button').forEach(function (b) {
|
||||||
|
b.classList.toggle('on', b.dataset.mode === mode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>"/>
|
||||||
|
<title>MetonaEditor · 文档</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg-soft: #161922;
|
||||||
|
--card: #1c2029;
|
||||||
|
--card-hover: #232834;
|
||||||
|
--text: #e6e8eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-2: #8b5cf6;
|
||||||
|
--accent-3: #ec4899;
|
||||||
|
--accent-soft: rgba(59, 130, 246, 0.12);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--gradient: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
|
||||||
|
--shadow: 0 20px 50px -20px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
nav {
|
||||||
|
position: sticky; top: 0; z-index: 100;
|
||||||
|
background: rgba(15, 17, 23, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1000px; margin: 0 auto; padding: 12px 24px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
text-decoration: none; color: var(--text);
|
||||||
|
font-weight: 700; font-size: 15px;
|
||||||
|
}
|
||||||
|
.nav-brand .logo-mark {
|
||||||
|
width: 26px; height: 26px; border-radius: 6px;
|
||||||
|
background: var(--gradient);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.logo-mark svg { display: block; }
|
||||||
|
.nav-links { display: flex; gap: 22px; align-items: center; }
|
||||||
|
.nav-links a { color: var(--muted); text-decoration: none; font-size: 13.5px; transition: color .15s; }
|
||||||
|
.nav-links a:hover, .nav-links a.active { color: var(--text); }
|
||||||
|
.nav-links a.active { color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header.docs-header {
|
||||||
|
text-align: center; padding: 64px 24px 40px;
|
||||||
|
background: radial-gradient(ellipse 60% 50% at 50% 0%, var(--accent-soft), transparent 70%), var(--bg);
|
||||||
|
}
|
||||||
|
header h1 { font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; margin-bottom: 10px; }
|
||||||
|
header h1 .grad { background: var(--gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
header .sub { color: var(--muted); font-size: 15px; }
|
||||||
|
|
||||||
|
/* Sidebar + Content Layout */
|
||||||
|
.docs-layout { display: flex; gap: 32px; padding: 40px 0 80px; }
|
||||||
|
.sidebar {
|
||||||
|
position: sticky; top: 72px;
|
||||||
|
width: 200px; flex-shrink: 0;
|
||||||
|
max-height: calc(100vh - 100px); overflow-y: auto;
|
||||||
|
padding-right: 16px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sidebar a {
|
||||||
|
display: block; padding: 6px 12px; border-radius: 6px;
|
||||||
|
color: var(--muted); text-decoration: none; font-size: 13.5px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.sidebar a:hover, .sidebar a.active { background: var(--card); color: var(--text); }
|
||||||
|
.sidebar .toc-group { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; padding: 16px 12px 6px; font-weight: 600; }
|
||||||
|
.content { flex: 1; min-width: 0; }
|
||||||
|
.content section { margin-bottom: 48px; }
|
||||||
|
.content h2 {
|
||||||
|
font-size: 24px; font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 16px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.content h3 { font-size: 18px; font-weight: 600; margin: 28px 0 14px; }
|
||||||
|
.content h4 { font-size: 15px; font-weight: 600; margin: 20px 0 10px; color: var(--accent); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 10px 14px; text-align: left;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th { background: var(--card); font-weight: 600; color: var(--text); }
|
||||||
|
td { color: var(--muted); }
|
||||||
|
tr:nth-child(even) td { background: var(--bg-soft); }
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 13px; padding: 2px 6px;
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; color: var(--accent);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 18px 20px; overflow-x: auto;
|
||||||
|
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 13px; line-height: 1.8;
|
||||||
|
color: #e6e8eb; margin: 14px 0;
|
||||||
|
}
|
||||||
|
pre .c-key { color: #c792ea; }
|
||||||
|
pre .c-str { color: #c3e88d; }
|
||||||
|
pre .c-fn { color: #82aaff; }
|
||||||
|
pre .c-com { color: #5c6370; font-style: italic; }
|
||||||
|
pre .c-kw { color: #89ddff; }
|
||||||
|
pre .c-num { color: #f78c6c; }
|
||||||
|
pre .c-punc { color: #89ddff; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 3px 10px;
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||||
|
margin-right: 6px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border); padding: 36px 24px;
|
||||||
|
text-align: center; color: var(--muted); font-size: 13px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
footer a { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.docs-layout { flex-direction: column; }
|
||||||
|
.sidebar {
|
||||||
|
position: static; width: 100%; max-height: none;
|
||||||
|
border-right: 0; border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
}
|
||||||
|
.sidebar .toc-group { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a class="nav-brand" href="index.html">
|
||||||
|
<span class="logo-mark" title="MetonaEditor"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V6l7 8 7-8v13"/></svg></span>
|
||||||
|
MetonaEditor
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="index.html">首页</a>
|
||||||
|
<a href="demo.html">演示</a>
|
||||||
|
<a href="docs.html" class="active">文档</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="docs-header">
|
||||||
|
<h1><span class="grad">文档</span></h1>
|
||||||
|
<p class="sub">MetonaEditor v0.1.10 完整 API、配置与使用指南</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="docs-layout">
|
||||||
|
<div class="sidebar" id="sidebar">
|
||||||
|
<div class="toc-group">快速入门</div>
|
||||||
|
<a href="#quickstart">安装与引入</a>
|
||||||
|
<div class="toc-group">配置</div>
|
||||||
|
<a href="#config">全部配置项</a>
|
||||||
|
<a href="#toolbar">工具栏</a>
|
||||||
|
<div class="toc-group">实例 API</div>
|
||||||
|
<a href="#api-content">内容操作</a>
|
||||||
|
<a href="#api-exec">命令执行</a>
|
||||||
|
<a href="#api-history">历史栈</a>
|
||||||
|
<a href="#api-mode">模式与全屏</a>
|
||||||
|
<a href="#api-stats">统计与状态</a>
|
||||||
|
<a href="#api-readonly">启用/禁用/只读</a>
|
||||||
|
<a href="#api-events">事件系统</a>
|
||||||
|
<a href="#api-plugin">插件管理</a>
|
||||||
|
<a href="#api-lifecycle">生命周期</a>
|
||||||
|
<div class="toc-group">静态 API</div>
|
||||||
|
<a href="#static-api">顶层函数</a>
|
||||||
|
<div class="toc-group">语法参考</div>
|
||||||
|
<a href="#syntax">解析器语法</a>
|
||||||
|
<div class="toc-group">插件</div>
|
||||||
|
<a href="#plugins">插件约定</a>
|
||||||
|
<a href="#preset-plugins">预设插件</a>
|
||||||
|
<div class="toc-group">主题</div>
|
||||||
|
<a href="#themes">主题系统</a>
|
||||||
|
<div class="toc-group">国际化</div>
|
||||||
|
<a href="#i18n">i18n API</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<!-- ======== 快速入门 ======== -->
|
||||||
|
<section id="quickstart">
|
||||||
|
<h2>安装与引入</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-com"># npm 安装</span>
|
||||||
|
<span class="c-kw">npm</span> install @metona-team/metona-editor
|
||||||
|
|
||||||
|
<span class="c-com"># ES Module</span>
|
||||||
|
<span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
<span class="c-fn">value</span><span class="c-punc">:</span> <span class="c-str">'# Hello World'</span><span class="c-punc">,</span>
|
||||||
|
<span class="c-fn">mode</span><span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span>
|
||||||
|
<span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com"># 浏览器 UMD</span>
|
||||||
|
<span class="c-com"><script src="dist/metona-editor.js"></script></span>
|
||||||
|
<span class="c-com"><script></span>
|
||||||
|
<span class="c-kw">const</span> editor <span class="c-punc">=</span> MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span><span class="c-str">'#editor'</span><span class="c-punc">);</span>
|
||||||
|
<span class="c-com"></script></span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 配置项 ======== -->
|
||||||
|
<section id="config">
|
||||||
|
<h2>全部配置项</h2>
|
||||||
|
<p style="color:var(--muted);margin-bottom:14px">所有配置均为可选,以下是默认值:</p>
|
||||||
|
|
||||||
|
<pre>MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
<span class="c-com">// 内容</span>
|
||||||
|
value<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 初始 Markdown 文本</span>
|
||||||
|
placeholder<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 占位符</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 视图</span>
|
||||||
|
mode<span class="c-punc">:</span> <span class="c-str">'split'</span><span class="c-punc">,</span> <span class="c-com">// 'edit' | 'split' | 'preview'</span>
|
||||||
|
height<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 数字为 px,字符串原样使用</span>
|
||||||
|
toolbar<span class="c-punc">:</span> DEFAULT_TOOLBAR<span class="c-punc">,</span> <span class="c-com">// 工具栏配置,false 隐藏</span>
|
||||||
|
wordCount<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 字数统计状态栏</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 行为</span>
|
||||||
|
autofocus<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 自动聚焦</span>
|
||||||
|
spellcheck<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 拼写检查</span>
|
||||||
|
readOnly<span class="c-punc">:</span> <span class="c-kw">false</span><span class="c-punc">,</span> <span class="c-com">// 只读模式</span>
|
||||||
|
historyLimit<span class="c-punc">:</span> <span class="c-num">100</span><span class="c-punc">,</span> <span class="c-com">// 历史栈上限</span>
|
||||||
|
historyDebounce<span class="c-punc">:</span> <span class="c-num">400</span><span class="c-punc">,</span> <span class="c-com">// 历史防抖延迟(ms)</span>
|
||||||
|
syncScroll<span class="c-punc">:</span> <span class="c-kw">true</span><span class="c-punc">,</span> <span class="c-com">// 分屏模式同步滚动</span>
|
||||||
|
tabSize<span class="c-punc">:</span> <span class="c-num">2</span><span class="c-punc">,</span> <span class="c-com">// 制表符空格数(0 表示 \t)</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 主题与国际化</span>
|
||||||
|
theme<span class="c-punc">:</span> <span class="c-str">'auto'</span><span class="c-punc">,</span> <span class="c-com">// 'light' | 'dark' | 'auto' | 'warm' | 自定义</span>
|
||||||
|
locale<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span> <span class="c-com">// 'zh-CN' | 'en-US' | 自定义</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 自定义渲染</span>
|
||||||
|
render<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (md, env) => html</span>
|
||||||
|
highlight<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (code, lang) => html</span>
|
||||||
|
sanitize<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (html) => safeHtml</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 外观</span>
|
||||||
|
className<span class="c-punc">:</span> <span class="c-str">''</span><span class="c-punc">,</span> <span class="c-com">// 容器额外 class</span>
|
||||||
|
style<span class="c-punc">:</span> <span class="c-punc">{},</span> <span class="c-com">// 内联样式</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 插件与回调</span>
|
||||||
|
plugins<span class="c-punc">:</span> <span class="c-punc">[],</span> <span class="c-com">// 实例级插件数组</span>
|
||||||
|
onChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) => void</span>
|
||||||
|
onInput<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) => void</span>
|
||||||
|
onFocus<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) => void</span>
|
||||||
|
onBlur<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) => void</span>
|
||||||
|
onSave<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (value, editor) => void</span>
|
||||||
|
onModeChange<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (mode, editor) => void</span>
|
||||||
|
onFullscreen<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (fullscreen, editor) => void</span>
|
||||||
|
onCreate<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) => void</span>
|
||||||
|
onDestroy<span class="c-punc">:</span> <span class="c-kw">null</span><span class="c-punc">,</span> <span class="c-com">// (editor) => void</span>
|
||||||
|
<span class="c-punc">});</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 工具栏 ======== -->
|
||||||
|
<section id="toolbar">
|
||||||
|
<h2>工具栏配置</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-com">// 默认工具栏(完整按钮)</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">true</span> <span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 隐藏工具栏</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span> toolbar<span class="c-punc">:</span> <span class="c-kw">false</span> <span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 自定义工具栏 — '|' 为分隔符</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
toolbar<span class="c-punc">:</span> <span class="c-punc">[</span><span class="c-str">'bold'</span><span class="c-punc">,</span> <span class="c-str">'italic'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'h1'</span><span class="c-punc">,</span> <span class="c-str">'h2'</span><span class="c-punc">,</span> <span class="c-str">'|'</span><span class="c-punc">,</span> <span class="c-str">'undo'</span><span class="c-punc">,</span> <span class="c-str">'redo'</span><span class="c-punc">],</span>
|
||||||
|
<span class="c-punc">});</span></pre>
|
||||||
|
|
||||||
|
<p>可用动作:</p>
|
||||||
|
<p>
|
||||||
|
<span class="badge">bold</span><span class="badge">italic</span><span class="badge">strikethrough</span><span class="badge">underline</span><span class="badge">code</span>
|
||||||
|
<span class="badge">h1</span><span class="badge">h2</span><span class="badge">h3</span><span class="badge">quote</span><span class="badge">ul</span><span class="badge">ol</span>
|
||||||
|
<span class="badge">indent</span><span class="badge">outdent</span><span class="badge">hr</span>
|
||||||
|
<span class="badge">link</span><span class="badge">image</span><span class="badge">table</span>
|
||||||
|
<span class="badge">undo</span><span class="badge">redo</span>
|
||||||
|
<span class="badge">edit</span><span class="badge">split</span><span class="badge">preview</span><span class="badge">fullscreen</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 实例 API ======== -->
|
||||||
|
<section id="api-content">
|
||||||
|
<h2>实例 API</h2>
|
||||||
|
|
||||||
|
<h3>内容操作</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">getValue</span><span class="c-punc">();</span> <span class="c-com">// 获取 Markdown 文本</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">setValue</span><span class="c-punc">(</span>md<span class="c-punc">,</span> <span class="c-punc">{</span> silent <span class="c-punc">});</span> <span class="c-com">// 设置内容,silent 不触发 change</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getHTML</span><span class="c-punc">();</span> <span class="c-com">// 获取渲染后的 HTML</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">refresh</span><span class="c-punc">();</span> <span class="c-com">// 强制刷新预览(内容未变时)</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">insert</span><span class="c-punc">(</span>text<span class="c-punc">,</span> <span class="c-punc">{</span> replace <span class="c-punc">});</span> <span class="c-com">// 光标处插入 / 替换选区</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">wrap</span><span class="c-punc">(</span>before<span class="c-punc">,</span> after<span class="c-punc">);</span> <span class="c-com">// 选区包裹</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">focus</span><span class="c-punc">();</span> <span class="c-com">// 聚焦</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">blur</span><span class="c-punc">();</span> <span class="c-com">// 失焦</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-exec">
|
||||||
|
<h3>命令执行</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span>action<span class="c-punc">,</span> <span class="c-punc">...</span>args<span class="c-punc">);</span> <span class="c-com">// 执行命令,返回 this 链式调用</span>
|
||||||
|
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">exec</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">).</span><span class="c-fn">exec</span><span class="c-punc">(</span><span class="c-str">'h1'</span><span class="c-punc">);</span> <span class="c-com">// 链式调用</span></pre>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>action</th><th>效果</th><th>快捷键</th></tr>
|
||||||
|
<tr><td><code>bold</code></td><td>加粗 **text**</td><td>Ctrl+B</td></tr>
|
||||||
|
<tr><td><code>italic</code></td><td>斜体 *text*</td><td>Ctrl+I</td></tr>
|
||||||
|
<tr><td><code>strikethrough</code></td><td>删除线 ~~text~~</td><td>—</td></tr>
|
||||||
|
<tr><td><code>underline</code></td><td>下划线 <u>text</u></td><td>Ctrl+U</td></tr>
|
||||||
|
<tr><td><code>code</code></td><td>行内代码 `text`</td><td>Ctrl+E</td></tr>
|
||||||
|
<tr><td><code>h1</code>/<code>h2</code>/<code>h3</code></td><td>标题 # / ## / ###</td><td>Ctrl+1/2/3</td></tr>
|
||||||
|
<tr><td><code>quote</code></td><td>引用 ></td><td>Ctrl+Q</td></tr>
|
||||||
|
<tr><td><code>ul</code>/<code>ol</code></td><td>无序/有序列表</td><td>—</td></tr>
|
||||||
|
<tr><td><code>indent</code>/<code>outdent</code></td><td>缩进/反缩进</td><td>Tab/Shift+Tab</td></tr>
|
||||||
|
<tr><td><code>hr</code></td><td>水平线 ---</td><td>—</td></tr>
|
||||||
|
<tr><td><code>link</code></td><td>插入链接 [text](url)</td><td>Ctrl+K</td></tr>
|
||||||
|
<tr><td><code>image</code></td><td>插入图片 </td><td>—</td></tr>
|
||||||
|
<tr><td><code>table</code></td><td>插入表格</td><td>—</td></tr>
|
||||||
|
<tr><td><code>undo</code>/<code>redo</code></td><td>撤销/重做</td><td>Ctrl+Z / Ctrl+Y</td></tr>
|
||||||
|
<tr><td><code>edit</code>/<code>split</code>/<code>preview</code></td><td>切换模式</td><td>—</td></tr>
|
||||||
|
<tr><td><code>fullscreen</code></td><td>切换全屏</td><td>—</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-history">
|
||||||
|
<h3>历史栈</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">undo</span><span class="c-punc">();</span> <span class="c-com">// 撤销</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">redo</span><span class="c-punc">();</span> <span class="c-com">// 重做</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">canUndo</span><span class="c-punc">();</span> <span class="c-com">// 是否可撤销</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">canRedo</span><span class="c-punc">();</span> <span class="c-com">// 是否可重做</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-mode">
|
||||||
|
<h3>模式与全屏</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">setMode</span><span class="c-punc">(</span><span class="c-str">'split'</span><span class="c-punc">);</span> <span class="c-com">// 设置模式:edit | split | preview</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getMode</span><span class="c-punc">();</span> <span class="c-com">// 获取当前模式</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">toggleFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 切换全屏</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">exitFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 退出全屏</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">isFullscreen</span><span class="c-punc">();</span> <span class="c-com">// 是否全屏</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-stats">
|
||||||
|
<h3>统计与状态</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">getStats</span><span class="c-punc">();</span>
|
||||||
|
<span class="c-com">// => { characters, words, chineseChars, englishWords, lines, readingTime }</span>
|
||||||
|
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span>
|
||||||
|
<span class="c-com">// => { id, mode, theme, locale, fullscreen, readOnly, disabled, destroyed, plugins }</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-readonly">
|
||||||
|
<h3>启用/禁用/只读</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">enable</span><span class="c-punc">();</span> <span class="c-com">// 启用</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">disable</span><span class="c-punc">();</span> <span class="c-com">// 禁用</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">isDisabled</span><span class="c-punc">();</span> <span class="c-com">// 是否禁用</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">setReadOnly</span><span class="c-punc">(</span><span class="c-kw">true</span><span class="c-punc">);</span> <span class="c-com">// 只读模式</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">isReadOnly</span><span class="c-punc">();</span> <span class="c-com">// 是否只读</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-events">
|
||||||
|
<h3>事件系统</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>事件名</th><th>触发时机</th><th>回调参数</th></tr>
|
||||||
|
<tr><td><code>input</code></td><td>textarea 原生 input</td><td><code>(value, editor)</code></td></tr>
|
||||||
|
<tr><td><code>change</code></td><td>内容变化</td><td><code>(value, editor)</code></td></tr>
|
||||||
|
<tr><td><code>focus</code></td><td>聚焦</td><td><code>(editor)</code></td></tr>
|
||||||
|
<tr><td><code>blur</code></td><td>失焦</td><td><code>(editor)</code></td></tr>
|
||||||
|
<tr><td><code>save</code></td><td>Ctrl+S</td><td><code>(value, editor)</code></td></tr>
|
||||||
|
<tr><td><code>modeChange</code></td><td>模式切换</td><td><code>(mode, editor)</code></td></tr>
|
||||||
|
<tr><td><code>fullscreen</code></td><td>全屏切换</td><td><code>(fullscreen, editor)</code></td></tr>
|
||||||
|
<tr><td><code>destroy</code></td><td>销毁</td><td><code>()</code></td></tr>
|
||||||
|
<tr><td><code>autosave</code></td><td>autoSave 保存</td><td><code>({ key, value })</code></td></tr>
|
||||||
|
<tr><td><code>beforeRender</code></td><td>渲染前</td><td><code>(editor)</code></td></tr>
|
||||||
|
<tr><td><code>afterRender</code></td><td>渲染后</td><td><code>(editor)</code></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>全局钩子(所有实例共享)</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>钩子</th><th>触发时机</th></tr>
|
||||||
|
<tr><td><code>beforeCreate</code></td><td>构造函数初始化前</td></tr>
|
||||||
|
<tr><td><code>afterCreate</code></td><td>构造函数初始化完成</td></tr>
|
||||||
|
<tr><td><code>beforeRender</code></td><td>每次渲染前(全局)</td></tr>
|
||||||
|
<tr><td><code>afterRender</code></td><td>每次渲染后(全局)</td></tr>
|
||||||
|
<tr><td><code>beforeDestroy</code></td><td>destroy 前</td></tr>
|
||||||
|
<tr><td><code>afterDestroy</code></td><td>destroy 后</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-plugin">
|
||||||
|
<h3>插件管理</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>plugin<span class="c-punc">,</span> options<span class="c-punc">);</span> <span class="c-com">// 安装插件</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getPlugins</span><span class="c-punc">();</span> <span class="c-com">// 获取已安装插件列表</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">addToolbarButton</span><span class="c-punc">(</span>config<span class="c-punc">);</span> <span class="c-com">// 追加工具栏按钮</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="api-lifecycle">
|
||||||
|
<h3>生命周期</h3>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span> <span class="c-com">// 销毁实例</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">isDestroyed</span><span class="c-punc">();</span> <span class="c-com">// 是否已销毁</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 静态 API ======== -->
|
||||||
|
<section id="static-api">
|
||||||
|
<h2>静态 API</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-kw">import</span> MeEditor <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 工厂函数</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">create</span><span class="c-punc">(</span>container<span class="c-punc">,</span> options<span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 全局默认插件</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span> delay<span class="c-punc">:</span> <span class="c-num">2000</span> <span class="c-punc">});</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span><span class="c-str">'searchReplace'</span><span class="c-punc">);</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>customPlugin<span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 全局事件钩子</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> <span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'beforeCreate'</span><span class="c-punc">,</span> handler<span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 主题与语言</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 状态查询</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">getStatus</span><span class="c-punc">();</span> <span class="c-com">// => { version, theme, locale, globalPlugins, presetPlugins }</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 销毁全局资源</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">destroy</span><span class="c-punc">();</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 内置解析器</span>
|
||||||
|
<span class="c-kw">import</span> <span class="c-punc">{</span> parseMarkdown<span class="c-punc">,</span> safeUrl<span class="c-punc">,</span> slugify <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
<span class="c-fn">parseMarkdown</span><span class="c-punc">(</span><span class="c-str">'# Hello'</span><span class="c-punc">);</span> <span class="c-com">// => '<h1 id="hello">Hello</h1>'</span>
|
||||||
|
<span class="c-fn">safeUrl</span><span class="c-punc">(</span><span class="c-str">'javascript:alert(1)'</span><span class="c-punc">);</span> <span class="c-com">// => ''</span>
|
||||||
|
<span class="c-fn">slugify</span><span class="c-punc">(</span><span class="c-str">'Hello World'</span><span class="c-punc">);</span> <span class="c-com">// => 'hello-world'</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 语法参考 ======== -->
|
||||||
|
<section id="syntax">
|
||||||
|
<h2>解析器语法参考</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>语法</th><th>示例</th><th>输出</th></tr>
|
||||||
|
<tr><td>标题</td><td><code># H1</code> ~ <code>###### H6</code></td><td><code><h1></code> ~ <code><h6></code></td></tr>
|
||||||
|
<tr><td>段落</td><td>纯文本</td><td><code><p></code></td></tr>
|
||||||
|
<tr><td>粗体</td><td><code>**bold**</code> <code>__bold__</code></td><td><code><strong></code></td></tr>
|
||||||
|
<tr><td>斜体</td><td><code>*italic*</code> <code>_italic_</code></td><td><code><em></code></td></tr>
|
||||||
|
<tr><td>删除线</td><td><code>~~text~~</code></td><td><code><del></code></td></tr>
|
||||||
|
<tr><td>高亮标记</td><td><code>==text==</code></td><td><code><mark></code></td></tr>
|
||||||
|
<tr><td>上标</td><td><code>x^2^</code></td><td><code><sup></code></td></tr>
|
||||||
|
<tr><td>下标</td><td><code>H~2~O</code></td><td><code><sub></code></td></tr>
|
||||||
|
<tr><td>行内代码</td><td><code>`code`</code></td><td><code><code></code></td></tr>
|
||||||
|
<tr><td>代码块</td><td><code>```lang</code></td><td><code><pre><code></code></td></tr>
|
||||||
|
<tr><td>引用</td><td><code>> quote</code></td><td><code><blockquote></code></td></tr>
|
||||||
|
<tr><td>无序列表</td><td><code>- item</code></td><td><code><ul><li></code></td></tr>
|
||||||
|
<tr><td>有序列表</td><td><code>1. item</code></td><td><code><ol><li></code></td></tr>
|
||||||
|
<tr><td>任务列表</td><td><code>- [x] done</code></td><td><code><li class="me-task-item"></code></td></tr>
|
||||||
|
<tr><td>水平线</td><td><code>---</code> <code>***</code> <code>___</code></td><td><code><hr></code></td></tr>
|
||||||
|
<tr><td>表格</td><td><code>| a | b |</code></td><td><code><table></code>(支持 <code>:---:</code> 列对齐)</td></tr>
|
||||||
|
<tr><td>链接</td><td><code>[text](url)</code></td><td><code><a></code></td></tr>
|
||||||
|
<tr><td>图片</td><td><code></code></td><td><code><img></code></td></tr>
|
||||||
|
<tr><td>自动链接</td><td><code><https://...></code></td><td><code><a></code></td></tr>
|
||||||
|
<tr><td>Emoji</td><td><code>:smile:</code> <code>:rocket:</code></td><td>😊 🚀(80+ 常用)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>XSS 防护</h3>
|
||||||
|
<p>所有文本经 HTML 转义。URL 自动过滤 <code>javascript:</code> <code>vbscript:</code> <code>file:</code> 及非图片 <code>data:</code> 协议。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 插件 ======== -->
|
||||||
|
<section id="plugins">
|
||||||
|
<h2>插件约定</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-kw">const</span> myPlugin <span class="c-punc">=</span> <span class="c-punc">{</span>
|
||||||
|
name<span class="c-punc">:</span> <span class="c-str">'myPlugin'</span><span class="c-punc">,</span>
|
||||||
|
description<span class="c-punc">:</span> <span class="c-str">'我的自定义插件'</span><span class="c-punc">,</span>
|
||||||
|
|
||||||
|
<span class="c-fn">install</span><span class="c-punc">(</span>editor<span class="c-punc">,</span> options<span class="c-punc">)</span> <span class="c-punc">{</span>
|
||||||
|
<span class="c-com">// this 指向插件对象本身</span>
|
||||||
|
<span class="c-kw">this</span><span class="c-punc">.</span>_timer <span class="c-punc">=</span> <span class="c-kw">null</span><span class="c-punc">;</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
|
||||||
|
<span class="c-punc">},</span>
|
||||||
|
|
||||||
|
<span class="c-fn">destroy</span><span class="c-punc">(</span>editor<span class="c-punc">)</span> <span class="c-punc">{</span>
|
||||||
|
<span class="c-kw">if</span> <span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">)</span> <span class="c-fn">clearTimeout</span><span class="c-punc">(</span><span class="c-kw">this</span><span class="c-punc">.</span>_timer<span class="c-punc">);</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">off</span><span class="c-punc">(</span><span class="c-str">'change'</span><span class="c-punc">,</span> <span class="c-kw">this</span><span class="c-punc">.</span>_onChange<span class="c-punc">);</span>
|
||||||
|
<span class="c-punc">},</span>
|
||||||
|
<span class="c-punc">};</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="preset-plugins">
|
||||||
|
<h3>预设插件</h3>
|
||||||
|
|
||||||
|
<h4>autoSave — 自动保存草稿</h4>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>autoSave<span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
key<span class="c-punc">:</span> <span class="c-str">'me-draft-'</span> <span class="c-punc">+</span> editor<span class="c-punc">.</span>id<span class="c-punc">,</span> <span class="c-com">// localStorage key</span>
|
||||||
|
delay<span class="c-punc">:</span> <span class="c-num">1000</span><span class="c-punc">,</span> <span class="c-com">// 防抖延迟(ms)</span>
|
||||||
|
<span class="c-punc">});</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">restoreDraft</span><span class="c-punc">();</span> <span class="c-com">// 恢复草稿</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">clearDraft</span><span class="c-punc">();</span> <span class="c-com">// 清除草稿</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getDraftKey</span><span class="c-punc">();</span> <span class="c-com">// 获取存储 key</span></pre>
|
||||||
|
|
||||||
|
<h4>exportTool — 导出文件</h4>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>exportTool<span class="c-punc">);</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">exportMarkdown</span><span class="c-punc">(</span><span class="c-str">'my-doc.md'</span><span class="c-punc">);</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">exportHTML</span><span class="c-punc">(</span><span class="c-str">'my-doc.html'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
title<span class="c-punc">:</span> <span class="c-str">'文档标题'</span><span class="c-punc">,</span>
|
||||||
|
css<span class="c-punc">:</span> <span class="c-str">'body { font-family: sans-serif; }'</span><span class="c-punc">,</span>
|
||||||
|
lang<span class="c-punc">:</span> <span class="c-str">'zh-CN'</span><span class="c-punc">,</span>
|
||||||
|
<span class="c-punc">});</span></pre>
|
||||||
|
|
||||||
|
<h4>searchReplace — 查找替换</h4>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>searchReplace<span class="c-punc">);</span>
|
||||||
|
<span class="c-com">// Ctrl+F → 查找 Ctrl+H → 替换</span>
|
||||||
|
<span class="c-com">// Enter → 下一个 Shift+Enter → 上一个 Escape → 关闭</span></pre>
|
||||||
|
|
||||||
|
<h4>imagePaste — 粘贴图片转 base64</h4>
|
||||||
|
<pre>editor<span class="c-punc">.</span><span class="c-fn">use</span><span class="c-punc">(</span>presetPlugins<span class="c-punc">.</span>imagePaste<span class="c-punc">);</span>
|
||||||
|
<span class="c-com">// 在编辑区 Ctrl+V 粘贴剪贴板图片</span>
|
||||||
|
<span class="c-com">// 自动插入 </span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 主题 ======== -->
|
||||||
|
<section id="themes">
|
||||||
|
<h2>主题系统</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-com">// 全局切换主题</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'light'</span><span class="c-punc">);</span> <span class="c-com">// 亮色</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span> <span class="c-com">// 暗色</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'warm'</span><span class="c-punc">);</span> <span class="c-com">// 暖色</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'auto'</span><span class="c-punc">);</span> <span class="c-com">// 自动(默认)</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 实例级主题(v0.1.5,每个编辑器独立)</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span><span class="c-str">'dark'</span><span class="c-punc">);</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">getTheme</span><span class="c-punc">();</span> <span class="c-com">// 'dark'</span>
|
||||||
|
editor<span class="c-punc">.</span><span class="c-fn">on</span><span class="c-punc">(</span><span class="c-str">'themeChange'</span><span class="c-punc">,</span> <span class="c-punc">({</span> theme <span class="c-punc">})</span> <span class="c-punc">=></span> <span class="c-punc">{});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 注册自定义主题(v0.1.5: 支持继承)</span>
|
||||||
|
<span class="c-kw">import</span> <span class="c-punc">{</span> themeUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">registerTheme</span><span class="c-punc">(</span><span class="c-str">'ocean'</span><span class="c-punc">,</span> <span class="c-punc">{</span>
|
||||||
|
extends<span class="c-punc">:</span> <span class="c-str">'dark'</span><span class="c-punc">,</span> <span class="c-com">// 继承 dark 主题</span>
|
||||||
|
accent<span class="c-punc">:</span> <span class="c-str">'#00ddff'</span><span class="c-punc">,</span>
|
||||||
|
<span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 外部主题跟随(v0.1.5)</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">followExternalTheme</span><span class="c-punc">(</span>
|
||||||
|
<span class="c-punc">{</span> element<span class="c-punc">:</span> document<span class="c-punc">.</span>body<span class="c-punc">,</span> attr<span class="c-punc">:</span> <span class="c-str">'data-theme'</span> <span class="c-punc">},</span>
|
||||||
|
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
|
||||||
|
<span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 从父容器继承主题</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">adoptFromParent</span><span class="c-punc">(</span>editor<span class="c-punc">.</span>container<span class="c-punc">,</span>
|
||||||
|
<span class="c-punc">(</span>theme<span class="c-punc">)</span> <span class="c-punc">=></span> editor<span class="c-punc">.</span><span class="c-fn">setTheme</span><span class="c-punc">(</span>theme<span class="c-punc">)</span>
|
||||||
|
<span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// CSS 变量导出</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">exportCSSVars</span><span class="c-punc">();</span> <span class="c-com">// { '--md-bg': '#fff', ... }</span>
|
||||||
|
themeUtils<span class="c-punc">.</span><span class="c-fn">getCSSVariable</span><span class="c-punc">(</span><span class="c-str">'accent'</span><span class="c-punc">);</span> <span class="c-com">// '#3b82f6'</span></pre>
|
||||||
|
|
||||||
|
<p>CSS 变量限定在 <code>.me-wrapper</code> 元素上,不污染全局。支持 <code>@media print</code> 打印样式。v0.1.5 新增实例级主题隔离、外部跟随、主题继承。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======== 国际化 ======== -->
|
||||||
|
<section id="i18n">
|
||||||
|
<h2>国际化</h2>
|
||||||
|
|
||||||
|
<pre><span class="c-com">// 切换语言</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'en-US'</span><span class="c-punc">);</span>
|
||||||
|
MeEditor<span class="c-punc">.</span><span class="c-fn">setLocale</span><span class="c-punc">(</span><span class="c-str">'zh-CN'</span><span class="c-punc">);</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 翻译函数</span>
|
||||||
|
<span class="c-kw">import</span> <span class="c-punc">{</span> i18nUtils <span class="c-punc">}</span> <span class="c-kw">from</span> <span class="c-str">'@metona-team/metona-editor'</span><span class="c-punc">;</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => '粗体'(zh-CN)</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">t</span><span class="c-punc">(</span><span class="c-str">'bold'</span><span class="c-punc">);</span> <span class="c-com">// => 'Bold'(en-US)</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 添加语言</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">addTranslations</span><span class="c-punc">(</span><span class="c-str">'ja'</span><span class="c-punc">,</span> <span class="c-punc">{</span> bold<span class="c-punc">:</span> <span class="c-str">'太字'</span><span class="c-punc">,</span> italic<span class="c-punc">:</span> <span class="c-str">'斜体'</span> <span class="c-punc">});</span>
|
||||||
|
|
||||||
|
<span class="c-com">// 格式化</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatNumber</span><span class="c-punc">(</span><span class="c-num">1234567</span><span class="c-punc">);</span> <span class="c-com">// => '1,234,567'</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatCurrency</span><span class="c-punc">(</span><span class="c-num">99.99</span><span class="c-punc">,</span> <span class="c-str">'USD'</span><span class="c-punc">);</span> <span class="c-com">// => '$99.99'</span>
|
||||||
|
i18nUtils<span class="c-punc">.</span><span class="c-fn">formatDate</span><span class="c-punc">(</span><span class="c-str">'2024-01-15'</span><span class="c-punc">);</span> <span class="c-com">// => '1/15/2024'</span></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
MetonaEditor v0.1.10 · <a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a> · MIT License
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 高亮当前 section 的侧边栏链接
|
||||||
|
const sections = document.querySelectorAll('section[id]');
|
||||||
|
const links = document.querySelectorAll('.sidebar a');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
let current = '';
|
||||||
|
sections.forEach(s => {
|
||||||
|
if (window.scrollY >= s.offsetTop - 100) current = s.id;
|
||||||
|
});
|
||||||
|
links.forEach(a => {
|
||||||
|
a.classList.toggle('active', a.getAttribute('href') === '#' + current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<title>在线演示 — MetonaToast</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1121; --surface: #151d33; --border: #1e2d4d;
|
||||||
|
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); color:var(--text); line-height:1.6; min-height:100vh; }
|
||||||
|
a { color:var(--accent2); text-decoration:none; }
|
||||||
|
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.85em; }
|
||||||
|
.nav { position:sticky; top:0; z-index:100; background:rgba(11,17,33,0.88); backdrop-filter:blur(14px); border-bottom:1px solid var(--border); }
|
||||||
|
.nav-inner { max-width:1300px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; padding:0 20px; height:56px; }
|
||||||
|
.nav-logo { font-size:18px; font-weight:700; background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
|
||||||
|
.nav-links { display:flex; gap:20px; }
|
||||||
|
.nav-links a { color:var(--muted); font-size:13px; font-weight:500; transition:.2s; }
|
||||||
|
.nav-links a:hover { color:var(--accent2); }
|
||||||
|
.hero { text-align:center; padding:40px 20px 30px; border-bottom:1px solid var(--border); background:radial-gradient(ellipse at 50% 0%,rgba(99,102,241,0.12) 0%,transparent 50%); }
|
||||||
|
.hero h1 { font-size:36px; font-weight:800; }
|
||||||
|
.hero h1 span { background:linear-gradient(135deg,#818cf8,#c084fc,#f472b6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
|
||||||
|
.hero p { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||||
|
|
||||||
|
.container { max-width:1300px; margin:0 auto; padding:24px 20px; display:grid; grid-template-columns:repeat(auto-fill,minmax(400px,1fr)); gap:18px; }
|
||||||
|
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; }
|
||||||
|
.card h2 { font-size:15px; margin-bottom:2px; }
|
||||||
|
.card > p { color:var(--muted); font-size:11px; margin-bottom:12px; }
|
||||||
|
.btn-row { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.btn { padding:8px 14px; border:none; border-radius:7px; font-size:12px; font-weight:500; cursor:pointer; transition:.15s; color:#fff; }
|
||||||
|
.btn:hover { transform:translateY(-1px); filter:brightness(1.15); }
|
||||||
|
.btn-sm { padding:5px 10px; font-size:11px; border-radius:5px; }
|
||||||
|
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
|
||||||
|
.btn-outline:hover { border-color:var(--accent); background:rgba(99,102,241,0.06); }
|
||||||
|
.btn-tiny { padding:4px 9px; font-size:10px; border-radius:4px; }
|
||||||
|
.c-s { background:#10b981; } .c-e { background:#ef4444; } .c-w { background:#f59e0b; color:#1e293b; }
|
||||||
|
.c-i { background:#3b82f6; } .c-p { background:#6366f1; } .c-pp { background:#8b5cf6; }
|
||||||
|
.c-pk { background:#ec4899; } .c-t { background:#14b8a6; } .c-o { background:#f97316; }
|
||||||
|
.c-sl { background:#475569; } .c-r { background:#f43f5e; } .c-g { background:#22c55e; }
|
||||||
|
.label { font-size:10px; text-transform:uppercase; letter-spacing:0.8px; color:var(--muted); margin-bottom:10px; font-weight:600; }
|
||||||
|
.demo-section { border-top:1px solid var(--border); padding-top:8px; margin-top:8px; }
|
||||||
|
footer { text-align:center; padding:30px 20px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }
|
||||||
|
@media (max-width:768px) { .container { grid-template-columns:1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">MetonaToast</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="index.html">首页</a>
|
||||||
|
<a href="docs.html">文档</a>
|
||||||
|
<a href="demo.html">演示</a>
|
||||||
|
<a href="https://git.metona.cn/MetonaTeam/MetonaToast" target="_blank">Gitea</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<h1><span>在线演示</span></h1>
|
||||||
|
<p>覆盖项目全部功能的交互演示 — 点击即可体验</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
|
||||||
|
<!-- 1. 基础通知 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">📢 基础通知</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-s" onclick="MeToast.success('操作成功!')">✓ 成功</button>
|
||||||
|
<button class="btn c-e" onclick="MeToast.error('操作失败!')">✕ 错误</button>
|
||||||
|
<button class="btn c-w" onclick="MeToast.warning('请注意检查')">⚠ 警告</button>
|
||||||
|
<button class="btn c-i" onclick="MeToast.info('系统维护中')">ℹ 信息</button>
|
||||||
|
<button class="btn c-sl" onclick="MeToast.show('默认消息')">— 默认</button>
|
||||||
|
</div>
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success({title:'保存成功',message:'文件已同步到云端'})">带标题</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.info('持续10秒',{duration:10000})">长时10s</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.warning('手动关闭',{duration:0,closeButton:true})">手动关闭</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.info('',{icon:false})">无图标</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.info('无按钮',{closeButton:false})">无关闭按钮</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="demoCustomIcon()">自定义图标</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Loading 链式 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🔄 Loading 链式转换</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-s" onclick="demoLoading()">提交成功</button>
|
||||||
|
<button class="btn c-e" onclick="demoLoadingFail()">提交失败</button>
|
||||||
|
<button class="btn c-w" onclick="demoLoadingWarn()">转为警告</button>
|
||||||
|
<button class="btn c-i" onclick="demoLoadingUpdate()">原地更新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Promise + confirm + prompt -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">💬 对话框</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-pp" onclick="demoPromise()">Promise 风格</button>
|
||||||
|
<button class="btn c-pk" onclick="demoConfirm()">确认对话框</button>
|
||||||
|
<button class="btn c-t" onclick="demoPrompt()">输入对话框</button>
|
||||||
|
<button class="btn c-o" onclick="demoPromptPassword()">密码输入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 进度 & 倒计时 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">📊 进度 & 倒计时</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-o" onclick="demoProgress()">进度条(水平)</button>
|
||||||
|
<button class="btn c-g" onclick="demoProgressV()">进度条(垂直)</button>
|
||||||
|
<button class="btn c-pk" onclick="demoCountdown()">倒计时 5s</button>
|
||||||
|
<button class="btn c-r" onclick="demoCountdownPause()">倒计时(可暂停)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. 队列 & 堆叠 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">📋 队列 & 堆叠</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-p" onclick="demoQueue()">队列展示</button>
|
||||||
|
<button class="btn c-e" onclick="demoQueueCancel()">队列中途取消</button>
|
||||||
|
<button class="btn c-pp" onclick="demoStack()">堆叠展示</button>
|
||||||
|
<button class="btn c-t" onclick="demoStackMixed()">混合类型堆叠</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Action Toast -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">⚡ Action Toast</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-e" onclick="demoActionDelete()">删除+撤销</button>
|
||||||
|
<button class="btn c-i" onclick="demoActionMulti()">多按钮</button>
|
||||||
|
<button class="btn c-s" onclick="demoActionNoClose()">不自动关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. 分组管理 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">📁 分组管理</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-p" onclick="demoGroup()">创建分组消息</button>
|
||||||
|
<button class="btn c-e" onclick="dismissGroup()">关闭分组</button>
|
||||||
|
<button class="btn c-i" onclick="MeToast.info('分组数量: '+group.count(),{type:'info'})">查看数量</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="demoGroupMixed()">混合类型分组</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. 动画效果 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🎭 11种动画效果</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('slide')">slide</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('fade')">fade</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('scale')">scale</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('bounce')">bounce</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('flip')">flip</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('rotate')">rotate</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('zoom')">zoom</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('slideUp')">slideUp</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('slideDown')">slideDown</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('slideLeft')">slideLeft</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="anim('slideRight')">slideRight</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 9. 6种位置 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">📍 全部位置</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左上角',{position:'top-left'})">↖ top-left</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('顶部居中',{position:'top-center'})">↑ top-center</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右上角',{position:'top-right'})">↗ top-right</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('左下角',{position:'bottom-left'})">↙ bottom-left</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('底部居中',{position:'bottom-center'})">↓ bottom-center</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.success('右下角',{position:'bottom-right'})">↘ bottom-right</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10. 主题切换 + 自定义 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🎨 主题切换 & 自定义</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="switchTheme('light')">☀️ Light</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="switchTheme('dark')">🌙 Dark</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="switchTheme('auto')">🔄 Auto</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="switchTheme('warm')">🔥 Warm</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="demoRegisterTheme()">注册自定义主题</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 11. 插件系统 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🔌 插件系统</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.use('keyboard');MeToast.info('已安装 — 按 ESC 关闭所有 Toast')">keyboard ESC关闭</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.use('persistence');MeToast.info('已安装 — 配置将持久化到 localStorage')">persistence 持久化</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="MeToast.use('accessibility');MeToast.info('已安装 — 屏幕阅读器可用')">accessibility 无障碍</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="demoCustomPlugin()">自定义插件</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 12. 高级特性 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🔥 高级特性 <span style="color:#34d399;font-size:10px;">resetTimer · onUpdate · render · notify</span></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-i" onclick="demoResetTimer()">resetTimerOnUpdate</button>
|
||||||
|
<button class="btn c-p" onclick="demoOnUpdate()">onUpdate 回调</button>
|
||||||
|
<button class="btn c-pp" onclick="demoCustomRender()">自定义渲染</button>
|
||||||
|
<button class="btn c-w" onclick="demoNotify()">系统通知</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 13. Toast 管理 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🔧 Toast 管理</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-s" onclick="MeToast.success('消息 '+(MeToast.count()+1))">发一条</button>
|
||||||
|
<button class="btn c-i" onclick="MeToast.info('消息 '+(MeToast.count()+1))">再一条</button>
|
||||||
|
<button class="btn c-w" onclick="MeToast.info('当前 '+MeToast.count()+' 条')">查看数量</button>
|
||||||
|
<button class="btn c-sl" onclick="MeToast.pauseAll();MeToast.info('已暂停全部')">暂停全部</button>
|
||||||
|
<button class="btn c-sl" onclick="MeToast.resumeAll();MeToast.info('已恢复全部')">恢复全部</button>
|
||||||
|
<button class="btn c-e" onclick="MeToast.dismiss()">关闭全部</button>
|
||||||
|
<button class="btn c-r" onclick="MeToast.destroy();MeToast.info('已销毁')">销毁</button>
|
||||||
|
</div>
|
||||||
|
<div class="demo-section">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="demoFindById()">findById</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="demoGetAll()">getAll</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="demoClearByPos()">clear(位置)</button>
|
||||||
|
<button class="btn btn-outline btn-tiny" onclick="demoGetConfig()">查看配置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 14. 错误回调 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🛡️ onError 错误捕获 <span style="color:#34d399;font-size:10px;">v0.1.2</span></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
|
||||||
|
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 15. 交互特性 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">✋ 交互特性</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-i" onclick="demoDraggable()">拖拽关闭</button>
|
||||||
|
<button class="btn c-p" onclick="demoPauseHover()">悬停暂停</button>
|
||||||
|
<button class="btn c-w" onclick="demoCloseOnClick()">点击关闭</button>
|
||||||
|
<button class="btn c-pp" onclick="demoWidth()">自定义宽度</button>
|
||||||
|
<button class="btn c-t" onclick="demoClassName()">自定义className</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 16. 国际化切换 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">🌍 国际化 & 格式化</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn c-s" onclick="switchLocale('zh-CN')">切换到中文</button>
|
||||||
|
<button class="btn c-i" onclick="switchLocale('en-US')">Switch to English</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="demoFormat()">格式化演示</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 17. 107种预设图标 -->
|
||||||
|
<div class="card" style="grid-column:1/-1;">
|
||||||
|
<div class="label">🎨 107种预设图标 — 点击任意图标发送Toast</div>
|
||||||
|
<div class="btn-row" id="icon-grid" style="max-height:300px;overflow-y:auto;padding:4px 0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
MetonaToast v0.1.2 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="../dist/metona-toast.js"></script>
|
||||||
|
<script>
|
||||||
|
MeToast.configure({ position:'top-right', duration:4000, theme:'dark', animation:'slide', pauseOnHover:true, showProgress:true });
|
||||||
|
|
||||||
|
// === Loading ===
|
||||||
|
function demoLoading() { 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); }
|
||||||
|
|
||||||
|
// === Promise / confirm / prompt ===
|
||||||
|
async function demoPromise() {
|
||||||
|
const p=new Promise((r,e)=>setTimeout(()=>Math.random()>0.3?r('ok'):e('fail'),2000));
|
||||||
|
try{await MeToast.promise(p,{loading:'处理中...',success:'成功!',error:'失败'});}catch(_){}
|
||||||
|
}
|
||||||
|
async function demoConfirm() { const ok=await MeToast.confirm('确定删除?',{confirmText:'删除',confirmColor:'#ef4444'}); if(ok)MeToast.success('已删除'); }
|
||||||
|
async function demoPrompt() { const v=await MeToast.prompt('请输入姓名',{placeholder:'请输入...'}); if(v) MeToast.info('你好,'+v+'!'); }
|
||||||
|
async function demoPromptPassword() { const v=await MeToast.prompt('请输入密码',{placeholder:'密码',inputType:'password',submitText:'登录'}); if(v) MeToast.info('密码已输入'); }
|
||||||
|
|
||||||
|
// === Progress & Countdown ===
|
||||||
|
function demoProgress() { 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 demoCountdown() { MeToast.countdown('{seconds} 秒后执行',5,{onComplete:()=>MeToast.success('已执行')}); }
|
||||||
|
function demoCountdownPause() {
|
||||||
|
const c=MeToast.countdown('{seconds}s 可暂停',10,{type:'info',onComplete:()=>MeToast.success('倒计时结束')});
|
||||||
|
setTimeout(()=>{c.pause();MeToast.warning('已暂停3秒后恢复',{duration:3000})},4000);
|
||||||
|
setTimeout(()=>{c.resume();MeToast.info('已恢复倒计时')},7000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Queue & Stack ===
|
||||||
|
async function demoQueue() { await MeToast.queue(['第一步','第二步','第三步'],{delay:800,duration:2000,type:'info'}); }
|
||||||
|
async function demoQueueCancel() {
|
||||||
|
const q=MeToast.queue(['步骤1','步骤2','步骤3','步骤4','步骤5'],{delay:600,duration:1500,type:'info'});
|
||||||
|
setTimeout(()=>{q.cancel();MeToast.warning('队列已取消',{duration:2000});},3500);
|
||||||
|
}
|
||||||
|
function demoStack() { MeToast.stack(['消息1','消息2','消息3','消息4','消息5'],{stagger:120,type:'info'}); }
|
||||||
|
function demoStackMixed() {
|
||||||
|
MeToast.stack(['普通消息',{message:'警告消息',type:'warning',duration:5000},{message:'错误消息',type:'error'}],{stagger:150});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Action ===
|
||||||
|
function demoActionDelete() { MeToast.action('文件 data.json 已删除',[{text:'撤销',onClick:()=>MeToast.success('已撤销'),color:'#3b82f6'}]); }
|
||||||
|
function demoActionMulti() { MeToast.action('邮件已发送',[{text:'查看',onClick:()=>MeToast.info('打开邮件详情'),color:'#3b82f6'},{text:'撤回',onClick:()=>MeToast.warning('已撤回'),color:'#f59e0b'}]); }
|
||||||
|
function demoActionNoClose() { MeToast.action('完成后点击关闭',[{text:'我知道了',onClick:()=>{},color:'#10b981'}]); }
|
||||||
|
|
||||||
|
// === Group ===
|
||||||
|
const group = MeToast.group('demo-group');
|
||||||
|
function demoGroup() { group.success('分组成功 #'+group.count()); group.info('分组消息 #'+group.count()); }
|
||||||
|
function dismissGroup() { MeToast.dismissGroup('demo-group'); MeToast.info('分组已关闭'); }
|
||||||
|
function demoGroupMixed() {
|
||||||
|
const g = MeToast.group('mixed-test');
|
||||||
|
g.success('OK'); g.warning('Warning'); g.info('Info');
|
||||||
|
setTimeout(()=>MeToast.dismissGroup('mixed-test'),4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Animation ===
|
||||||
|
function anim(name) { MeToast.success('动画: '+name,{animation:name,duration:3000,showProgress:false}); }
|
||||||
|
|
||||||
|
// === Theme ===
|
||||||
|
function switchTheme(t) { MeToast.themes.switchTheme(t); MeToast.configure({theme:t}); MeToast.info('主题: '+t,{theme:t}); }
|
||||||
|
function demoRegisterTheme() {
|
||||||
|
try {
|
||||||
|
MeToast.themes.registerTheme('sunset',{bg:'rgba(255,248,240,0.96)',text:'#92400e',border:'rgba(249,115,22,0.2)',shadow:'0 10px 36px -10px rgba(249,115,22,0.2)',hoverShadow:'0 14px 48px -10px rgba(249,115,22,0.3)',progressBg:'rgba(249,115,22,0.1)',closeHoverBg:'rgba(249,115,22,0.1)'});
|
||||||
|
MeToast.themes.switchTheme('sunset');
|
||||||
|
MeToast.configure({theme:'sunset'});
|
||||||
|
MeToast.success('已切换到 sunset 主题',{theme:'sunset'});
|
||||||
|
} catch(_) { MeToast.warning('主题已注册,已切换'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Plugins ===
|
||||||
|
function demoCustomPlugin() {
|
||||||
|
MeToast.use({name:'click-counter',version:'1.0',install(){let c=0;Toast.on('afterShow',t=>{c++;setTimeout(()=>t.update({message:t.message.replace(/\\(\\d+\\)/,'')+' (总通知#'+c+')'}),1);});}});
|
||||||
|
MeToast.info('自定义插件已安装(消息后追加计数)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 高级特性 ===
|
||||||
|
function 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);
|
||||||
|
}
|
||||||
|
function demoOnUpdate() { const t=MeToast.success('onUpdate 演示',{onUpdate:t=>{console.log('更新了:',t.message);}}); setTimeout(()=>t.update({message:'已更新!'}),1000); setTimeout(()=>t.update({message:'再次更新!'}),2000); }
|
||||||
|
function demoCustomRender() {
|
||||||
|
MeToast.show({render:toast=>'<div style="background:#111;color:#0f0;padding:16px 20px;border-radius:10px;border:1px solid #0f0;font-family:monospace">🟢 <b>'+toast.message+'</b><br><span style="font-size:11px;opacity:0.6"># custom render by thzxx</span></div>',message:'自定义渲染内容',duration:5000});
|
||||||
|
}
|
||||||
|
function demoNotify() {
|
||||||
|
if(typeof Notification==='undefined'){MeToast.error('浏览器不支持 Notification');return;}
|
||||||
|
if(Notification.permission==='denied'){MeToast.error('通知权限已拒绝');return;}
|
||||||
|
MeToast.info('页面隐藏时看系统通知',{notifyWhenHidden:true,duration:6000});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Management ===
|
||||||
|
function demoFindById() {
|
||||||
|
const t=MeToast.info('这是测试 toast',{duration:5000});
|
||||||
|
setTimeout(()=>{const found=MeToast.find(t.id); if(found)MeToast.success('找到了 #'+found.id);},500);
|
||||||
|
}
|
||||||
|
function demoGetAll() { const all=MeToast.getAll(); MeToast.info('当前共 '+all.size+' 条 toast'); }
|
||||||
|
function demoClearByPos() { MeToast.error('右下角临时消息',{position:'bottom-right',duration:3000}); setTimeout(()=>{MeToast.clear('bottom-right');MeToast.info('右下角已清除');},500); }
|
||||||
|
function demoGetConfig() { 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: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === onError ===
|
||||||
|
function demoOnError() {
|
||||||
|
MeToast.configure({
|
||||||
|
onError: function(info) {
|
||||||
|
MeToast.error('⚠️ 捕获错误: ' + (info.hook || info.source), { duration: 3000, position: 'bottom-center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
MeToast.info('已启用 onError 监控(钩子/定时器异常将被捕获)', { duration: 3000 });
|
||||||
|
// 触发一个钩子错误来演示
|
||||||
|
var badFn = function() { throw new Error('demo-error'); };
|
||||||
|
Toast.on('afterShow', badFn);
|
||||||
|
MeToast.success('这条消息会触发错误', { duration: 2000 });
|
||||||
|
setTimeout(function() { Toast.off('afterShow', badFn); }, 3000);
|
||||||
|
}
|
||||||
|
function demoOnErrorReset() {
|
||||||
|
MeToast.configure({ onError: null });
|
||||||
|
MeToast.info('onError 已重置为 null');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 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('已切换语言');}
|
||||||
|
}
|
||||||
|
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');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 图标网格 ===
|
||||||
|
(function renderIcons() {
|
||||||
|
var grid = document.getElementById('icon-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var icons = ['success','error','warning','info','loading','close','check','x','alert','question','star','heart','bell','mail','settings','user','home','search','plus','minus','edit','trash','download','upload','share','link','external','clock','calendar','map','compass','globe','wifi','cloud','sun','moon','zap','activity','cpu','database','server','terminal','code','git','package','layers','grid','list','filter','sort','refresh','sync','power','battery','bluetooth','volume','mic','camera','image','video','music','file','folder','clipboard','save','print','eye','eyeOff','lock','unlock','shield','key','flag','bookmark','tag','gift','award','target','crosshair','move','maximize','minimize','copy','cut','paste','rotateCw','rotateCcw','zoomIn','zoomOut','crop','sliders','toggleLeft','toggleRight','checkCircle','xCircle','alertCircle','infoCircle','helpCircle','alertTriangle','checkSquare','square','circle','triangle','hexagon','octagon','pentagon','diamond'];
|
||||||
|
icons.forEach(function(ic) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'btn btn-outline btn-tiny';
|
||||||
|
btn.textContent = ic;
|
||||||
|
btn.title = 'type=' + ic;
|
||||||
|
btn.onclick = function() { MeToast.success(ic, { type: ic, duration: 2000, showProgress: false }); };
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<title>API 文档 — MetonaToast</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1121; --surface: #1a2332; --border: #1e2d4d;
|
||||||
|
--text: #e2e8f0; --muted: #64748b; --accent: #6366f1; --accent2: #818cf8;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); color:var(--text); line-height:1.7; display:flex; min-height:100vh; }
|
||||||
|
code { background:rgba(99,102,241,0.12); color:#a5b4fc; padding:1px 6px; border-radius:4px; font-size:0.9em; white-space:nowrap; }
|
||||||
|
pre { background:#0a0f1a; border:1px solid var(--border); border-radius:8px; padding:14px 18px; overflow-x:auto; font:13px/1.7 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace; color:#cbd5e1; margin:10px 0; }
|
||||||
|
pre .kw { color:#c084fc; } pre .fn { color:#60a5fa; } pre .s { color:#34d399; } pre .cm { color:#475569; }
|
||||||
|
a { color:var(--accent2); text-decoration:none; }
|
||||||
|
|
||||||
|
.sidebar { width:260px; min-width:260px; background:var(--surface); border-right:1px solid var(--border); padding:24px 0; position:sticky; top:0; height:100vh; overflow-y:auto; }
|
||||||
|
.sidebar h2 { font-size:15px; padding:0 24px; margin-bottom:16px; color:var(--accent2); }
|
||||||
|
.sidebar a { display:block; padding:7px 24px; font-size:13px; color:var(--muted); transition:.15s; border-left:2px solid transparent; }
|
||||||
|
.sidebar a:hover, .sidebar a.active { color:var(--accent2); border-left-color:var(--accent); background:rgba(99,102,241,0.06); }
|
||||||
|
.sidebar .section { font-size:11px; text-transform:uppercase; letter-spacing:1px; padding:16px 24px 6px; color:var(--muted); font-weight:600; }
|
||||||
|
|
||||||
|
.main { flex:1; padding:40px 48px; max-width:950px; }
|
||||||
|
.main h1 { font-size:30px; font-weight:700; margin-bottom:8px; }
|
||||||
|
.main h2 { font-size:22px; font-weight:700; margin:44px 0 14px; padding-top:22px; border-top:1px solid var(--border); }
|
||||||
|
.main h3 { font-size:17px; font-weight:600; margin:28px 0 10px; color:var(--accent2); }
|
||||||
|
.main p { margin:8px 0; color:var(--muted); font-size:14px; }
|
||||||
|
.main table { width:100%; border-collapse:collapse; margin:14px 0 20px; font-size:13px; }
|
||||||
|
.main th, .main td { text-align:left; padding:9px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||||
|
.main th { color:var(--muted); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
|
||||||
|
.main td:first-child { color:var(--accent2); font-family:monospace; font-size:12px; white-space:nowrap; }
|
||||||
|
.main td:nth-child(3) { font-family:monospace; font-size:11px; color:var(--muted); }
|
||||||
|
.badge { display:inline-block; padding:1px 7px; border-radius:3px; font-size:10px; font-weight:700; margin-left:6px; vertical-align:middle; }
|
||||||
|
.badge-new { background:rgba(16,185,129,0.2); color:#34d399; }
|
||||||
|
.hint { font-size:12px; color:var(--muted); margin-top:2px; }
|
||||||
|
.return-box { background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:8px; padding:14px 18px; margin:12px 0; }
|
||||||
|
.return-box h4 { font-size:12px; color:#34d399; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.5px; }
|
||||||
|
|
||||||
|
@media (max-width:768px) {
|
||||||
|
body { flex-direction:column; }
|
||||||
|
.sidebar { width:100%; min-width:0; height:auto; position:static; border-right:none; border-bottom:1px solid var(--border); padding:12px 16px; }
|
||||||
|
.sidebar a { display:inline-block; padding:4px 10px; border-left:none; }
|
||||||
|
.main { padding:24px 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="sidebar">
|
||||||
|
<h2>📘 MetonaToast 文档</h2>
|
||||||
|
<a href="index.html">← 返回首页</a>
|
||||||
|
<a href="demo.html">🎮 在线演示</a>
|
||||||
|
<div class="section">基础通知</div>
|
||||||
|
<a href="#show">show()</a>
|
||||||
|
<a href="#success">success()</a>
|
||||||
|
<a href="#error">error()</a>
|
||||||
|
<a href="#warning">warning()</a>
|
||||||
|
<a href="#info">info()</a>
|
||||||
|
<a href="#loading">loading()</a>
|
||||||
|
<div class="section">高级功能</div>
|
||||||
|
<a href="#promise">promise()</a>
|
||||||
|
<a href="#confirm">confirm()</a>
|
||||||
|
<a href="#prompt">prompt()</a>
|
||||||
|
<a href="#progress">progress()</a>
|
||||||
|
<a href="#countdown">countdown()</a>
|
||||||
|
<a href="#queue">queue()</a>
|
||||||
|
<a href="#stack">stack()</a>
|
||||||
|
<a href="#action">action()</a>
|
||||||
|
<div class="section">管理方法</div>
|
||||||
|
<a href="#group">group()</a>
|
||||||
|
<a href="#dismiss">dismiss()</a>
|
||||||
|
<a href="#clear">clear()</a>
|
||||||
|
<a href="#configure">configure()</a>
|
||||||
|
<a href="#use">use()</a>
|
||||||
|
<a href="#destroy">destroy()</a>
|
||||||
|
<div class="section">配置参考</div>
|
||||||
|
<a href="#config">全部配置项</a>
|
||||||
|
<a href="#callbacks">回调函数</a>
|
||||||
|
<a href="#animations">动画列表</a>
|
||||||
|
<a href="#themes">主题参考</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<h1>API 文档</h1>
|
||||||
|
<p>MetonaToast v0.1.2 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
|
||||||
|
|
||||||
|
<!-- ===== 基础通知 ===== -->
|
||||||
|
<h2 id="show">show(message, opts?)</h2>
|
||||||
|
<p>显示默认类型的 Toast 通知,无特定颜色和图标。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串,或包含 title/message 等属性的配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置对象,覆盖 <a href="#config">全部配置项</a>。仅当 message 为字符串时有效</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="cm">// 字符串形式</span>
|
||||||
|
MeToast.<span class="fn">show</span>(<span class="s">'默认消息'</span>);
|
||||||
|
MeToast.<span class="fn">show</span>(<span class="s">'自定义'</span>, { <span class="fn">duration</span>: 2000, <span class="fn">position</span>: <span class="s">'bottom-center'</span> });
|
||||||
|
<span class="cm">// 对象形式(所有 opts 作为一级属性)</span>
|
||||||
|
MeToast.<span class="fn">show</span>({ <span class="fn">title</span>: <span class="s">'标题'</span>, <span class="fn">message</span>: <span class="s">'内容'</span>, <span class="fn">duration</span>: 3000 });</pre>
|
||||||
|
|
||||||
|
<h2 id="success">success(message, opts?)</h2>
|
||||||
|
<p>显示成功通知。绿色对勾图标 <code>#10b981</code>,type = success。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 success</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">success</span>(<span class="s">'保存成功!'</span>);
|
||||||
|
MeToast.<span class="fn">success</span>({ <span class="fn">title</span>: <span class="s">'已保存'</span>, <span class="fn">message</span>: <span class="s">'数据已同步'</span> });
|
||||||
|
Met.<span class="fn">success</span>(<span class="s">'MeToast和Met等价'</span>);</pre>
|
||||||
|
|
||||||
|
<h2 id="error">error(message, opts?)</h2>
|
||||||
|
<p>显示错误通知。红色叉号图标 <code>#ef4444</code>,type = error。aria-live 设为 assertive。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 error</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">error</span>(<span class="s">'网络错误,请重试'</span>);
|
||||||
|
MeToast.<span class="fn">error</span>({ <span class="fn">title</span>: <span class="s">'提交失败'</span>, <span class="fn">message</span>: <span class="s">'服务器不可达'</span> });</pre>
|
||||||
|
|
||||||
|
<h2 id="warning">warning(message, opts?)</h2>
|
||||||
|
<p>显示警告通知。黄色三角图标 <code>#f59e0b</code>,type = warning。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 warning</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">warning</span>(<span class="s">'请注意检查输入内容'</span>);</pre>
|
||||||
|
|
||||||
|
<h2 id="info">info(message, opts?)</h2>
|
||||||
|
<p>显示信息通知。蓝色圆形图标 <code>#3b82f6</code>,type = info。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖。type 固定为 info</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">info</span>(<span class="s">'系统将于 22:00 维护'</span>);</pre>
|
||||||
|
|
||||||
|
<h2 id="loading">loading(message, opts?)</h2>
|
||||||
|
<p>显示加载状态。type = loading,duration 强制为 0(不自动关闭),closeButton 和 showProgress 强制为 false。返回 LoadingControl 对象。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>加载提示文本或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置覆盖</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> loading = MeToast.<span class="fn">loading</span>(<span class="s">'正在提交...'</span>);
|
||||||
|
<span class="fn">setTimeout</span>(() => loading.<span class="fn">success</span>(<span class="s">'提交成功!'</span>), 2000);</pre>
|
||||||
|
<div class="return-box">
|
||||||
|
<h4>返回 LoadingControl</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
|
||||||
|
<tr><td>success</td><td>(msg, opts?) => ToastInstance</td><td>关闭加载 toast,原地替换为 success 类型</td></tr>
|
||||||
|
<tr><td>error</td><td>(msg, opts?) => ToastInstance</td><td>替换为 error 类型</td></tr>
|
||||||
|
<tr><td>info</td><td>(msg, opts?) => ToastInstance</td><td>替换为 info 类型</td></tr>
|
||||||
|
<tr><td>warning</td><td>(msg, opts?) => ToastInstance</td><td>替换为 warning 类型</td></tr>
|
||||||
|
<tr><td>update</td><td>(partial) => MeToast</td><td>不关闭加载 toast,原地更新其内容(支持 resetTimerOnUpdate)</td></tr>
|
||||||
|
<tr><td>dismiss</td><td>()</td><td>直接关闭加载 toast 不替换</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 高级功能 ===== -->
|
||||||
|
<h2 id="promise">promise(promise, opts)</h2>
|
||||||
|
<p>监听 Promise 生命周期。自动显示 loading → 根据 resolve/reject 自动切换 success/error。返回原 Promise,支持 await 获取结果。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>promise</td><td>Promise</td><td>—</td><td>要监听的 Promise 对象。非 Promise 会打印错误并返回 rejected Promise</td></tr>
|
||||||
|
<tr><td>opts.loading</td><td>string</td><td>"加载中..."</td><td>加载中显示的文本</td></tr>
|
||||||
|
<tr><td>opts.success</td><td>string</td><td>"操作成功"</td><td>resolve 后显示的文本</td></tr>
|
||||||
|
<tr><td>opts.error</td><td>string</td><td>"操作失败"</td><td>reject 后显示的文本</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">try</span> {
|
||||||
|
<span class="kw">await</span> MeToast.<span class="fn">promise</span>(<span class="fn">fetch</span>(<span class="s">'/api/data'</span>), {
|
||||||
|
<span class="fn">loading</span>: <span class="s">'加载中...'</span>,
|
||||||
|
<span class="fn">success</span>: <span class="s">'加载完成!'</span>,
|
||||||
|
<span class="fn">error</span>: <span class="s">'加载失败'</span>,
|
||||||
|
});
|
||||||
|
} <span class="kw">catch</span> (<span class="fn">e</span>) { <span class="cm">/* promise reject 会继续抛出 */</span> }</pre>
|
||||||
|
|
||||||
|
<h2 id="confirm">confirm(message, opts?)</h2>
|
||||||
|
<p>确认对话框。返回 <code>Promise<boolean></code>。内置 10 秒安全超时,超时自动 resolve(false)。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string</td><td>—</td><td>对话框消息。非字符串会打印错误并 resolve(false)</td></tr>
|
||||||
|
<tr><td>opts.confirmText</td><td>string</td><td>"确认"</td><td>确认按钮文字</td></tr>
|
||||||
|
<tr><td>opts.confirmColor</td><td>string</td><td>"#10b981"</td><td>确认按钮背景色</td></tr>
|
||||||
|
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
|
||||||
|
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
|
||||||
|
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型(影响图标和颜色)</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> ok = <span class="kw">await</span> MeToast.<span class="fn">confirm</span>(<span class="s">'确定删除?'</span>, {
|
||||||
|
<span class="fn">confirmText</span>: <span class="s">'删除'</span>,
|
||||||
|
<span class="fn">confirmColor</span>: <span class="s">'#ef4444'</span>,
|
||||||
|
<span class="fn">cancelText</span>: <span class="s">'保留'</span>,
|
||||||
|
});
|
||||||
|
<span class="kw">if</span> (ok) MeToast.<span class="fn">success</span>(<span class="s">'已删除'</span>);</pre>
|
||||||
|
|
||||||
|
<h2 id="prompt">prompt(message, opts?)</h2>
|
||||||
|
<p>输入对话框。返回 <code>Promise<string|null></code>。按 Enter 或点击提交按钮返回输入值,取消返回 null。内置 10 秒安全超时。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string</td><td>—</td><td>对话框消息。非字符串会打印错误并 resolve(null)</td></tr>
|
||||||
|
<tr><td>opts.placeholder</td><td>string</td><td>""</td><td>输入框占位文字</td></tr>
|
||||||
|
<tr><td>opts.defaultValue</td><td>string</td><td>""</td><td>输入框默认值</td></tr>
|
||||||
|
<tr><td>opts.inputType</td><td>string</td><td>"text"</td><td>input 标签 type 属性(如 password/email/number)</td></tr>
|
||||||
|
<tr><td>opts.submitText</td><td>string</td><td>"确认"</td><td>提交按钮文字</td></tr>
|
||||||
|
<tr><td>opts.submitColor</td><td>string</td><td>"#3b82f6"</td><td>提交按钮背景色</td></tr>
|
||||||
|
<tr><td>opts.cancelText</td><td>string</td><td>"取消"</td><td>取消按钮文字</td></tr>
|
||||||
|
<tr><td>opts.cancelColor</td><td>string</td><td>"#6b7280"</td><td>取消按钮背景色</td></tr>
|
||||||
|
<tr><td>opts.type</td><td>string</td><td>"info"</td><td>Toast 类型</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> name = <span class="kw">await</span> MeToast.<span class="fn">prompt</span>(<span class="s">'请输入姓名'</span>, {
|
||||||
|
<span class="fn">placeholder</span>: <span class="s">'请输入...'</span>,
|
||||||
|
<span class="fn">defaultValue</span>: <span class="s">'张三'</span>,
|
||||||
|
<span class="fn">submitText</span>: <span class="s">'确定'</span>,
|
||||||
|
});
|
||||||
|
<span class="kw">if</span> (name) MeToast.<span class="fn">info</span>(<span class="s">'你好,'</span> + name);</pre>
|
||||||
|
|
||||||
|
<h2 id="progress">progress(message, opts?)</h2>
|
||||||
|
<p>进度条通知。不自动关闭。返回 ProgressControl 对象以手动更新进度。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>进度提示文本或配置对象</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。type 默认 info</td></tr>
|
||||||
|
<tr><td>opts.progressColor</td><td>string</td><td>"#3b82f6"</td><td>进度条填充颜色</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> p = MeToast.<span class="fn">progress</span>(<span class="s">'上传中...'</span>, { <span class="fn">progressColor</span>: <span class="s">'#10b981'</span> });
|
||||||
|
p.<span class="fn">setProgress</span>(45); <span class="cm">// → 45%</span>
|
||||||
|
p.<span class="fn">setProgress</span>(90); <span class="cm">// → 90%</span>
|
||||||
|
p.<span class="fn">complete</span>(<span class="s">'上传完成!'</span>); <span class="cm">// → 100% → success</span>
|
||||||
|
<span class="cm">// 或</span>
|
||||||
|
p.<span class="fn">error</span>(<span class="s">'上传失败'</span>);
|
||||||
|
p.<span class="fn">dismiss</span>();</pre>
|
||||||
|
<div class="return-box">
|
||||||
|
<h4>返回 ProgressControl</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
|
||||||
|
<tr><td>setProgress</td><td>(percent: number)</td><td>设置进度 0~100,自动 clamp。更新进度条宽度和百分比文字</td></tr>
|
||||||
|
<tr><td>complete</td><td>(message?: string)</td><td>跳到 100%,300ms 后替换为 success toast,1s 后自动关闭</td></tr>
|
||||||
|
<tr><td>error</td><td>(message?: string)</td><td>替换为 error toast,2s 后自动关闭</td></tr>
|
||||||
|
<tr><td>dismiss</td><td>()</td><td>直接关闭</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="countdown">countdown(message, seconds, opts?)</h2>
|
||||||
|
<p>倒计时 Toast。<code>{seconds}</code> 占位符每秒自动替换为剩余秒数。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string</td><td>—</td><td>消息文本。支持 <code>{seconds}</code> 占位符。非字符串打印错误并返回空控制对象</td></tr>
|
||||||
|
<tr><td>seconds</td><td>number</td><td>10</td><td>倒计时秒数,最小 1</td></tr>
|
||||||
|
<tr><td>opts.onComplete</td><td>function</td><td>—</td><td>倒计时归零时的回调</td></tr>
|
||||||
|
<tr><td>opts.type</td><td>string</td><td>"warning"</td><td>Toast 类型</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">countdown</span>(<span class="s">'{seconds} 秒后执行'</span>, 5, {
|
||||||
|
<span class="fn">onComplete</span>: () => MeToast.<span class="fn">success</span>(<span class="s">'已执行'</span>),
|
||||||
|
});</pre>
|
||||||
|
<div class="return-box">
|
||||||
|
<h4>返回 CountdownControl</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>方法</th><th>说明</th></tr>
|
||||||
|
<tr><td>cancel()</td><td>清除计时器并关闭 toast</td></tr>
|
||||||
|
<tr><td>pause()</td><td>暂停倒计时</td></tr>
|
||||||
|
<tr><td>resume()</td><td>恢复倒计时</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="queue">queue(messages, opts?)</h2>
|
||||||
|
<p>顺序逐个显示消息队列。前一条关闭后延时显示下一条。返回 QueueControl(thenable + cancel)。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>messages</td><td>Array<string|object></td><td>—</td><td>消息数组。可为字符串或带 message/type/duration/onClose 的对象。非数组打印错误并返回空控制对象</td></tr>
|
||||||
|
<tr><td>opts.delay</td><td>number</td><td>1000</td><td>每条消息关闭后到显示下一条的间隔(ms)</td></tr>
|
||||||
|
<tr><td>opts.duration</td><td>number</td><td>3000</td><td>每条消息显示时长(ms),可被消息级 duration 覆盖</td></tr>
|
||||||
|
<tr><td>opts.onClose</td><td>function</td><td>—</td><td>全部队列完成后的回调。消息级 onClose 和队列级 onClose 都会依次调用</td></tr>
|
||||||
|
<tr><td>opts.type</td><td>string</td><td>—</td><td>默认类型</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> q = MeToast.<span class="fn">queue</span>([
|
||||||
|
<span class="s">'步骤一'</span>,
|
||||||
|
{ <span class="fn">message</span>: <span class="s">'步骤二'</span>, <span class="fn">duration</span>: 5000, <span class="fn">type</span>: <span class="s">'warning'</span> },
|
||||||
|
<span class="s">'步骤三'</span>,
|
||||||
|
], { <span class="fn">delay</span>: 800, <span class="fn">duration</span>: 2000, <span class="fn">type</span>: <span class="s">'info'</span> });
|
||||||
|
q.<span class="fn">cancel</span>(); <span class="cm">// 中途取消</span>
|
||||||
|
<span class="kw">await</span> q; <span class="cm">// 等待完成(thenable 支持 await)</span></pre>
|
||||||
|
<div class="return-box">
|
||||||
|
<h4>返回 QueueControl (thenable)</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>方法</th><th>说明</th></tr>
|
||||||
|
<tr><td>.then(fn, rj)</td><td>Promise.then 代理,支持 await</td></tr>
|
||||||
|
<tr><td>.catch(rj)</td><td>Promise.catch 代理</td></tr>
|
||||||
|
<tr><td>.cancel()</td><td>设置取消标志,不再显示下一条消息</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="stack">stack(messages, opts?)</h2>
|
||||||
|
<p>同时错峰显示多条消息。每条间隔 stagger 毫秒依次出现,全部叠加在屏幕上。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>messages</td><td>Array<string|object></td><td>—</td><td>消息数组。可为字符串或带 message/type/duration 的对象。非数组打印错误</td></tr>
|
||||||
|
<tr><td>opts.stagger</td><td>number</td><td>100</td><td>每条消息之间的显示间隔(ms)</td></tr>
|
||||||
|
<tr><td>opts.type</td><td>string</td><td>—</td><td>默认类型</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">stack</span>([<span class="s">'消息1'</span>, <span class="s">'消息2'</span>, { <span class="fn">message</span>: <span class="s">'警告'</span>, <span class="fn">type</span>: <span class="s">'warning'</span> }], {
|
||||||
|
<span class="fn">stagger</span>: 150,
|
||||||
|
<span class="fn">type</span>: <span class="s">'info'</span>,
|
||||||
|
});</pre>
|
||||||
|
|
||||||
|
<h2 id="action">action(message, actions, opts?)</h2>
|
||||||
|
<p>Action Toast:内嵌操作按钮。默认 duration=0 不自动关闭,closeButton=true。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>message</td><td>string | object</td><td>—</td><td>消息字符串或配置对象</td></tr>
|
||||||
|
<tr><td>actions</td><td>ActionButton[]</td><td>[]</td><td>按钮数组。每个按钮可配 text/onClick/color/style/close</td></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>{}</td><td>可选配置。duration 默认 0,closeButton 默认 true</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><th>ActionButton 字段</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>text</td><td>string</td><td>—(必填)</td><td>按钮显示文字</td></tr>
|
||||||
|
<tr><td>onClick</td><td>(toast) => void</td><td>—(必填)</td><td>点击回调函数,参数为当前 toast 实例</td></tr>
|
||||||
|
<tr><td>color</td><td>string</td><td>"#6366f1"</td><td>按钮背景色</td></tr>
|
||||||
|
<tr><td>style</td><td>object</td><td>{}</td><td>按钮内联样式,可覆盖 color(style.background 优先)</td></tr>
|
||||||
|
<tr><td>close</td><td>boolean</td><td>true</td><td>点击后是否自动关闭 toast。设为 false 可多次点击</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">action</span>(<span class="s">'文件已删除'</span>, [
|
||||||
|
{ <span class="fn">text</span>: <span class="s">'撤销'</span>, <span class="fn">onClick</span>: () => <span class="fn">restore</span>(), <span class="fn">color</span>: <span class="s">'#3b82f6'</span> },
|
||||||
|
{ <span class="fn">text</span>: <span class="s">'查看详情'</span>, <span class="fn">onClick</span>: (t) => <span class="fn">openFile</span>(), <span class="fn">color</span>: <span class="s">'#10b981'</span>, <span class="fn">close</span>: false },
|
||||||
|
]);</pre>
|
||||||
|
|
||||||
|
<!-- ===== 管理方法 ===== -->
|
||||||
|
<h2 id="group">group(name)</h2>
|
||||||
|
<p>创建 Toast 分组,返回 GroupAPI 对象。该对象所有方法自动传入 <code>group: name</code>,按组管理。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>name</td><td>string</td><td>分组名称。GroupAPI 和 dismissGroup 通过此名称关联</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre><span class="kw">const</span> orders = MeToast.<span class="fn">group</span>(<span class="s">'orders'</span>);
|
||||||
|
orders.<span class="fn">success</span>(<span class="s">'订单已创建'</span>);
|
||||||
|
orders.<span class="fn">error</span>(<span class="s">'支付失败'</span>, { <span class="fn">duration</span>: 5000 });
|
||||||
|
orders.<span class="fn">count</span>(); <span class="cm">// 该组当前 toast 数量</span>
|
||||||
|
orders.<span class="fn">dismiss</span>(); <span class="cm">// 关闭该组全部 toast</span>
|
||||||
|
<span class="cm">// 也支持主对象关闭</span>
|
||||||
|
MeToast.<span class="fn">dismissGroup</span>(<span class="s">'orders'</span>);</pre>
|
||||||
|
<div class="return-box">
|
||||||
|
<h4>返回 GroupAPI</h4>
|
||||||
|
<table>
|
||||||
|
<tr><th>方法</th><th>说明</th></tr>
|
||||||
|
<tr><td>show(msg, opts?)</td><td>等价 MeToast.show,自动注入 group</td></tr>
|
||||||
|
<tr><td>success(msg, opts?)</td><td>等价 MeToast.success</td></tr>
|
||||||
|
<tr><td>error(msg, opts?)</td><td>等价 MeToast.error</td></tr>
|
||||||
|
<tr><td>warning(msg, opts?)</td><td>等价 MeToast.warning</td></tr>
|
||||||
|
<tr><td>info(msg, opts?)</td><td>等价 MeToast.info</td></tr>
|
||||||
|
<tr><td>loading(msg, opts?)</td><td>等价 MeToast.loading</td></tr>
|
||||||
|
<tr><td>action(msg, actions, opts?)</td><td>等价 MeToast.action</td></tr>
|
||||||
|
<tr><td>dismiss()</td><td>关闭该组所有 toast</td></tr>
|
||||||
|
<tr><td>count()</td><td>返回该组当前 toast 数量</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="dismiss">dismiss(id?)</h2>
|
||||||
|
<p>关闭 Toast。无参数则关闭全部。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>id</td><td>string</td><td>可选。Toast 的 id,不传则关闭所有</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">dismiss</span>(); <span class="cm">// 关闭所有</span>
|
||||||
|
MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 关闭指定</span></pre>
|
||||||
|
|
||||||
|
<h2 id="clear">clear(position?)</h2>
|
||||||
|
<p>按位置清除 Toast。不传参数清除所有位置。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>position</td><td>string</td><td>可选。位置如 'top-right',不传则清除全部</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
|
||||||
|
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre>
|
||||||
|
|
||||||
|
<h2 id="configure">configure(opts)</h2>
|
||||||
|
<p>全局配置,影响后续所有 Toast。theme 和 locale 变化会触发相应副作用(应用主题 CSS / 切换语言)。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>opts</td><td>object</td><td>包含任意 <a href="#config">配置项</a> 的对象</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">configure</span>({
|
||||||
|
<span class="fn">position</span>: <span class="s">'top-right'</span>,
|
||||||
|
<span class="fn">duration</span>: 4000,
|
||||||
|
<span class="fn">theme</span>: <span class="s">'dark'</span>,
|
||||||
|
<span class="fn">animation</span>: <span class="s">'slide'</span>,
|
||||||
|
<span class="fn">locale</span>: <span class="s">'zh-CN'</span>,
|
||||||
|
});</pre>
|
||||||
|
|
||||||
|
<h2 id="use">use(plugin)</h2>
|
||||||
|
<p>安装插件。支持字符串(内置预设名)或插件对象。已被拒绝注册的无效插件会打印错误。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>plugin</td><td>string | object</td><td>预设名 'keyboard'/'persistence'/'accessibility' 或自定义插件对象 { name, version?, install, uninstall? }</td></tr>
|
||||||
|
</table>
|
||||||
|
<pre>MeToast.<span class="fn">use</span>(<span class="s">'keyboard'</span>); <span class="cm">// ESC 关闭所有 Toast</span>
|
||||||
|
MeToast.<span class="fn">use</span>(<span class="s">'persistence'</span>); <span class="cm">// 配置自动保存到 localStorage</span>
|
||||||
|
MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <span class="cm">// 屏幕阅读器实时朗读 toast 内容</span></pre>
|
||||||
|
<p class="hint">内置插件详情:keyboard 监听 keydown Escape 键;persistence 将配置写入 localStorage 并在页面加载时恢复;accessibility 在 afterShow/afterUpdate 钩子中通过 aria-live 区域朗读 toast 内容。</p>
|
||||||
|
|
||||||
|
<h2 id="destroy">destroy()</h2>
|
||||||
|
<p>完全销毁。关闭所有 Toast、移除所有 DOM 容器和注入的样式标签、清空内存缓存。</p>
|
||||||
|
<pre>MeToast.<span class="fn">destroy</span>();</pre>
|
||||||
|
|
||||||
|
<!-- ===== 配置参考 ===== -->
|
||||||
|
<h2 id="config">全部配置项</h2>
|
||||||
|
<p>以下配置可用于 <code>configure()</code>、<code>init()</code> 或单个 Toast 方法的 opts 参数。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>配置项</th><th>类型</th><th>默认值</th><th>说明</th></tr>
|
||||||
|
<tr><td>position</td><td>string</td><td>'top-right'</td><td>位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right</td></tr>
|
||||||
|
<tr><td>duration</td><td>number</td><td>4000</td><td>显示时长(ms),0=不自动关闭</td></tr>
|
||||||
|
<tr><td>max</td><td>number</td><td>6</td><td>同一位置最多同时显示条数,超出则关闭最早的</td></tr>
|
||||||
|
<tr><td>gap</td><td>number</td><td>12</td><td>Toast 之间的间距(px)</td></tr>
|
||||||
|
<tr><td>offset</td><td>number</td><td>24</td><td>容器到屏幕边缘的距离(px)</td></tr>
|
||||||
|
<tr><td>pauseOnHover</td><td>boolean</td><td>true</td><td>鼠标悬停时暂停 duration 倒计时和进度条</td></tr>
|
||||||
|
<tr><td>closeOnClick</td><td>boolean</td><td>true</td><td>点击 Toast 任意位置关闭。关闭按钮始终触发关闭</td></tr>
|
||||||
|
<tr><td>draggable</td><td>boolean</td><td>true</td><td>允许拖拽关闭。拖拽超过 120px 触发关闭</td></tr>
|
||||||
|
<tr><td>showProgress</td><td>boolean</td><td>true</td><td>显示 duration 倒计时进度条</td></tr>
|
||||||
|
<tr><td>progressDirection</td><td>string</td><td>'horizontal'</td><td>进度条方向:horizontal(底部水平) / vertical(右侧垂直)</td></tr>
|
||||||
|
<tr><td>icon</td><td>boolean</td><td>true</td><td>显示类型对应图标(success→对勾等)</td></tr>
|
||||||
|
<tr><td>closeButton</td><td>boolean</td><td>true</td><td>显示右上角关闭 × 按钮</td></tr>
|
||||||
|
<tr><td>theme</td><td>string</td><td>'auto'</td><td>主题:light / dark / auto(跟随系统)/ warm / 自定义注册名</td></tr>
|
||||||
|
<tr><td>animation</td><td>string</td><td>'slide'</td><td>入场动画名称,支持 CSS 动画列表见下</td></tr>
|
||||||
|
<tr><td>zIndex</td><td>number</td><td>9999</td><td>容器 CSS z-index</td></tr>
|
||||||
|
<tr><td>width</td><td>number|string</td><td>360</td><td>Toast 宽度。数字表示 px</td></tr>
|
||||||
|
<tr><td>className</td><td>string</td><td>''</td><td>附加到 Toast 元素上的 CSS 类名</td></tr>
|
||||||
|
<tr><td>style</td><td>object</td><td>{}</td><td>附加到 Toast 元素上的内联样式对象</td></tr>
|
||||||
|
<tr><td>locale</td><td>string</td><td>'zh-CN'</td><td>语言代码(zh-CN / en-US 等)</td></tr>
|
||||||
|
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
|
||||||
|
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
|
||||||
|
<tr><td>render</td><td>function</td><td>—</td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
|
||||||
|
<tr><td>onError</td><td>function</td><td>—</td><td>全局错误回调 <code>({ hook, source, error, toast }) => void</code>,钩子异常或定时器错误时触发</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="callbacks">回调函数</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
|
||||||
|
<tr><td>onShow</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 创建并播放入场动画后</td></tr>
|
||||||
|
<tr><td>onClose</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 被移除后(离场动画完成时)</td></tr>
|
||||||
|
<tr><td>onClick</td><td>(toast: ToastInstance) => void</td><td>Toast 被点击时(closeOnClick 为 true 时还会自动关闭)</td></tr>
|
||||||
|
<tr><td>onUpdate</td><td>(toast: ToastInstance) => void</td><td>Toast 内容通过 update() 更新后</td></tr>
|
||||||
|
<tr><td>onError</td><td>({ hook, source, error, toast }) => void</td><td>钩子回调或定时器发生异常时(全局配置,适用于接入错误监控)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="animations">动画列表</h2>
|
||||||
|
<p>配置 <code>animation</code> 的值,支持以下 CSS 动画。未在此列表中的值将 fallback 到 slide。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>名称</th><th>效果</th><th>时长</th></tr>
|
||||||
|
<tr><td>slide</td><td>从右侧滑入 + 回弹</td><td>400ms</td></tr>
|
||||||
|
<tr><td>fade</td><td>纯淡入 + blur→清晰</td><td>500ms</td></tr>
|
||||||
|
<tr><td>scale</td><td>弹性放大 (0.55→1.07→1)</td><td>450ms</td></tr>
|
||||||
|
<tr><td>bounce</td><td>从天而降四段弹跳</td><td>650ms</td></tr>
|
||||||
|
<tr><td>flip</td><td>3D 翻转入场 + 回摆</td><td>500ms</td></tr>
|
||||||
|
<tr><td>rotate</td><td>旋转摇摆进入</td><td>500ms</td></tr>
|
||||||
|
<tr><td>zoom</td><td>从中心爆发式弹出</td><td>500ms</td></tr>
|
||||||
|
<tr><td>slideUp</td><td>从下方弹入</td><td>400ms</td></tr>
|
||||||
|
<tr><td>slideDown</td><td>从上方弹入</td><td>400ms</td></tr>
|
||||||
|
<tr><td>slideLeft</td><td>从左侧滑入</td><td>400ms</td></tr>
|
||||||
|
<tr><td>slideRight</td><td>从右侧滑入</td><td>400ms</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="themes">主题参考</h2>
|
||||||
|
<pre><span class="cm">// 内置主题</span>
|
||||||
|
light <span class="cm">— 白色玻璃质感</span>
|
||||||
|
dark <span class="cm">— 深色玻璃质感</span>
|
||||||
|
auto <span class="cm">— 跟随系统主题设置</span>
|
||||||
|
warm <span class="cm">— 暖色调</span>
|
||||||
|
|
||||||
|
<span class="cm">// 注册自定义主题</span>
|
||||||
|
MeToast.themes.<span class="fn">registerTheme</span>(<span class="s">'ocean'</span>, {
|
||||||
|
<span class="fn">bg</span>: <span class="s">'rgba(240,249,255,0.96)'</span>,
|
||||||
|
<span class="fn">text</span>: <span class="s">'#0c4a6e'</span>,
|
||||||
|
<span class="fn">border</span>: <span class="s">'rgba(14,165,233,0.2)'</span>,
|
||||||
|
<span class="fn">shadow</span>: <span class="s">'0 10px 36px -10px rgba(14,165,233,0.18)'</span>,
|
||||||
|
<span class="fn">hoverShadow</span>: <span class="s">'0 14px 48px -10px rgba(14,165,233,0.22)'</span>,
|
||||||
|
<span class="fn">progressBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
|
||||||
|
<span class="fn">closeHoverBg</span>: <span class="s">'rgba(14,165,233,0.1)'</span>,
|
||||||
|
});
|
||||||
|
MeToast.themes.<span class="fn">switchTheme</span>(<span class="s">'ocean'</span>);</pre>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="../dist/metona-toast.js"></script>
|
||||||
|
<script>
|
||||||
|
MeToast.configure({ position: 'top-right', duration: 4000, theme: 'dark', animation: 'slide' });
|
||||||
|
document.querySelectorAll('.sidebar a[href^="#"]').forEach(a => {
|
||||||
|
a.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
||||||
|
a.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,973 +0,0 @@
|
|||||||
{
|
|
||||||
"planVersion": "1.0.0",
|
|
||||||
"generatedDate": "2026-06-03",
|
|
||||||
"project": "MarkLite",
|
|
||||||
"basedOnRequirements": "docs/optimization-requirements.json",
|
|
||||||
|
|
||||||
"optimization_phases": [
|
|
||||||
{
|
|
||||||
"phase": 1,
|
|
||||||
"name": "基础设施与代码规范",
|
|
||||||
"priority": "P0-CRITICAL",
|
|
||||||
"estimatedEffort": "1-2 天",
|
|
||||||
"description": "建立代码质量基线:ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复。这些是后续所有优化的前提。",
|
|
||||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
|
||||||
"rationale": "CQ-01 (ESLint) 是所有代码质量改进的基础;AR-05 (ErrorBoundary) 保护后续重构不会导致白屏;DX-07 (路径别名) 修复后简化后续 import。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"npm run lint 通过且返回 0",
|
|
||||||
"npm run format 能格式化全部源文件",
|
|
||||||
"ErrorBoundary 包裹 App 组件",
|
|
||||||
"TypeScript 路径别名在 vite config 中完整映射"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 2,
|
|
||||||
"name": "关键性能修复",
|
|
||||||
"priority": "P0-CRITICAL",
|
|
||||||
"estimatedEffort": "1-2 天",
|
|
||||||
"description": "修复影响日常使用的核心性能瓶颈:Markdown 处理器缓存、Zustand 选择器重渲染、CodeMirror Compartment 主题切换、Preview 防抖、IndexedDB 去抖。",
|
|
||||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
|
||||||
"rationale": "PF-01 和 PF-02 是 P0-CRITICAL 级性能问题,直接影响用户感知;PF-04 的 Compartment 重构是 PF-03 (React.memo) 的前置;PF-07/08 是低风险高收益的防抖优化。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"Markdown processor 实例复用率 > 90%",
|
|
||||||
"编辑时仅 activeTab 相关组件重渲染(React DevTools Profiler 验证)",
|
|
||||||
"darkMode 切换不销毁 EditorView",
|
|
||||||
"Preview 渲染频率 < 10 次/秒(快速输入场景)",
|
|
||||||
"IndexedDB 写入频率 < 3 次/秒(连续编辑场景)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 3,
|
|
||||||
"name": "架构重构与代码质量",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"estimatedEffort": "2-3 天",
|
|
||||||
"description": "组件拆分、TypeScript 类型完善、错误处理统一、消除架构反模式(DOM CustomEvent、重复设置加载)。",
|
|
||||||
"items": ["AR-01", "AR-02", "AR-03", "AR-04", "CQ-04", "CQ-05", "PF-03"],
|
|
||||||
"rationale": "组件拆分 (AR-01/02) 和 React.memo (PF-03) 依赖 Phase 1 的 ESLint 和 Phase 2 的 Zustand 优化;类型完善 (CQ-04) 和错误处理 (CQ-05) 可与架构重构并行。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"App.tsx < 150 行",
|
|
||||||
"Sidebar.tsx < 120 行",
|
|
||||||
"所有函数参数有显式类型标注",
|
|
||||||
"无空 catch 块,所有错误有具体描述",
|
|
||||||
"设置加载只触发一次 IndexedDB 读取"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 4,
|
|
||||||
"name": "用户体验改进",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"estimatedEffort": "2-3 天",
|
|
||||||
"description": "替换原生 confirm()、添加加载状态指示、改进 Toast 通知系统、增强可访问性。",
|
|
||||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
|
||||||
"rationale": "UX 改进依赖 Phase 3 的组件拆分(新的 ConfirmDialog、Toast 组件需要独立目录)和 ErrorBoundary(统一错误处理模式)。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"零处原生 confirm() 调用",
|
|
||||||
"文件打开、目录加载、Markdown 渲染有 loading 状态",
|
|
||||||
"Toast 支持多条堆叠 + 类型区分 (success/error/warning)",
|
|
||||||
"所有交互元素有 ARIA 属性"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 5,
|
|
||||||
"name": "开发体验与测试",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"estimatedEffort": "2-3 天",
|
|
||||||
"description": "建立单元测试框架、强化类型安全、添加项目文档和 Git hooks。",
|
|
||||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
|
||||||
"rationale": "测试框架应在代码稳定后添加(Phase 1-4 的重构会导致测试频繁失效);DX-02 (preload 类型安全) 依赖 CQ-04 (类型完善)。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"Vitest + React Testing Library 可运行",
|
|
||||||
"核心模块测试覆盖 > 60%",
|
|
||||||
"README.md 包含完整开发指南",
|
|
||||||
"pre-commit 钩子执行 ESLint + typecheck"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 6,
|
|
||||||
"name": "高级功能与安全加固",
|
|
||||||
"priority": "P2-P3",
|
|
||||||
"estimatedEffort": "3-5 天",
|
|
||||||
"description": "代码分割、分屏模式、拖拽排序、文件搜索、安全加固 (CSP/file:// 协议)、CI/CD 配置。",
|
|
||||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
|
||||||
"rationale": "这些是「锦上添花」的优化,不阻塞核心功能。代码分割 (PF-06) 和分屏模式 (UX-03) 是大规模功能,需要在架构稳定后实施。",
|
|
||||||
"exitCriteria": [
|
|
||||||
"renderer bundle 拆分为 3+ 个 chunk",
|
|
||||||
"分屏模式可正常编辑+预览",
|
|
||||||
"CSP header 配置且预览正常渲染",
|
|
||||||
"GitHub Actions CI 流水线 lint → typecheck → test → build"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"file_manifest": {
|
|
||||||
"new_files": [
|
|
||||||
{
|
|
||||||
"path": "eslint.config.js",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "CQ-01",
|
|
||||||
"description": "ESLint v9 flat config 格式配置文件",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".prettierrc",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "CQ-02",
|
|
||||||
"description": "Prettier 代码格式化配置",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".prettierignore",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "CQ-02",
|
|
||||||
"description": "Prettier 忽略文件列表",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".editorconfig",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "CQ-03",
|
|
||||||
"description": "编辑器统一行为配置",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "AR-05",
|
|
||||||
"description": "React ErrorBoundary 组件,显示错误信息和重启按钮",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/ErrorBoundary/index.ts",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "AR-05",
|
|
||||||
"description": "ErrorBoundary barrel export",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-01",
|
|
||||||
"description": "从 App.tsx 提取的 About 对话框组件",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/AboutDialog/index.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-01",
|
|
||||||
"description": "AboutDialog barrel export",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useFileOperations.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-01",
|
|
||||||
"description": "从 App.tsx 提取的文件操作 hook(open/save/saveAs/openRecent)",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Sidebar/FileTree.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-02",
|
|
||||||
"description": "从 Sidebar.tsx 提取的递归文件树组件",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useSidebarResize.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-02",
|
|
||||||
"description": "从 Sidebar.tsx 提取的 resize 逻辑 hook",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/ConfirmDialog/ConfirmDialog.tsx",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-01",
|
|
||||||
"description": "自定义确认对话框组件,替代原生 confirm()",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/ConfirmDialog/index.ts",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-01",
|
|
||||||
"description": "ConfirmDialog barrel export",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useConfirm.ts",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-01",
|
|
||||||
"description": "确认对话框状态管理 hook",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/LoadingSpinner/LoadingSpinner.tsx",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-02",
|
|
||||||
"description": "通用加载指示器组件",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/icons/toolbar-icons.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-06",
|
|
||||||
"description": "工具栏相关 SVG 图标组件",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/icons/sidebar-icons.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-06",
|
|
||||||
"description": "侧边栏相关 SVG 图标组件",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/icons/app-icons.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-06",
|
|
||||||
"description": "应用级 SVG 图标组件",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/icons/index.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-06",
|
|
||||||
"description": "Icons barrel export(保持向后兼容)",
|
|
||||||
"size_estimate": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/lib/markdown-processor.ts",
|
|
||||||
"phase": 2,
|
|
||||||
"item": "PF-01",
|
|
||||||
"description": "Markdown processor 缓存管理器",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useSettingsInit.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-03",
|
|
||||||
"description": "统一设置加载 hook,一次 IndexedDB 读取分发到 store",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "vitest.config.ts",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-01",
|
|
||||||
"description": "Vitest 测试框架配置",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/stores/__tests__/tabStore.test.ts",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-01",
|
|
||||||
"description": "tabStore 单元测试",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/lib/__tests__/fileUtils.test.ts",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-01",
|
|
||||||
"description": "fileUtils 单元测试",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/lib/__tests__/markdown.test.ts",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-01",
|
|
||||||
"description": "Markdown 渲染单元测试",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "README.md",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-04",
|
|
||||||
"description": "项目介绍、安装、开发、构建说明",
|
|
||||||
"size_estimate": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "CONTRIBUTING.md",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-04",
|
|
||||||
"description": "贡献指南、代码规范、PR 流程",
|
|
||||||
"size_estimate": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": ".github/workflows/ci.yml",
|
|
||||||
"phase": 6,
|
|
||||||
"item": "DX-06",
|
|
||||||
"description": "GitHub Actions CI 配置",
|
|
||||||
"size_estimate": "small"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"modified_files": [
|
|
||||||
{
|
|
||||||
"path": "package.json",
|
|
||||||
"phase": [1, 2, 5],
|
|
||||||
"item": ["CQ-02", "DX-05", "DX-01"],
|
|
||||||
"changes": [
|
|
||||||
"添加 prettier、eslint-config-prettier 依赖",
|
|
||||||
"添加 vitest、@testing-library/react、jsdom 依赖",
|
|
||||||
"添加 husky、lint-staged 依赖",
|
|
||||||
"添加 test、test:watch、test:coverage、format 脚本"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "electron.vite.config.ts",
|
|
||||||
"phase": [1, 6],
|
|
||||||
"item": ["DX-07", "PF-06"],
|
|
||||||
"changes": [
|
|
||||||
"renderer.resolve.alias 添加 @main 映射",
|
|
||||||
"renderer.build.rollupOptions.output 添加 manualChunks 分割策略"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/main.tsx",
|
|
||||||
"phase": 1,
|
|
||||||
"item": "AR-05",
|
|
||||||
"changes": [
|
|
||||||
"用 ErrorBoundary 包裹 App 组件"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/App.tsx",
|
|
||||||
"phase": [1, 3],
|
|
||||||
"item": ["AR-01", "AR-05", "CQ-05"],
|
|
||||||
"changes": [
|
|
||||||
"提取 About 对话框到 AboutDialog 组件",
|
|
||||||
"提取文件操作逻辑到 useFileOperations hook",
|
|
||||||
"统一错误处理模式"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/lib/markdown.ts",
|
|
||||||
"phase": 2,
|
|
||||||
"item": "PF-01",
|
|
||||||
"changes": [
|
|
||||||
"引入 markdown-processor.ts 缓存管理器",
|
|
||||||
"renderMarkdown 改为使用缓存的 processor 实例",
|
|
||||||
"添加 processor 缓存失效策略(仅 filePath 变化时重建)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/stores/tabStore.ts",
|
|
||||||
"phase": [2, 3],
|
|
||||||
"item": ["PF-02", "PF-08", "AR-07"],
|
|
||||||
"changes": [
|
|
||||||
"添加 getActiveTab() 选择器方法(返回 activeTab 对象引用)",
|
|
||||||
"添加 getActiveTabId() 选择器方法",
|
|
||||||
"将 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)",
|
|
||||||
"统一异步保存模式为 Zustand subscribeWithSelector middleware"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Editor/Editor.tsx",
|
|
||||||
"phase": 2,
|
|
||||||
"item": "PF-02",
|
|
||||||
"changes": [
|
|
||||||
"替换 const tabs = useTabStore(s => s.tabs) 为 const activeTab = useTabStore(s => s.getActiveTab())",
|
|
||||||
"消除 tabs.find() 调用"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"phase": [2],
|
|
||||||
"item": ["PF-02", "PF-07"],
|
|
||||||
"changes": [
|
|
||||||
"替换 tabs 订阅为 activeTab 选择器",
|
|
||||||
"添加 150ms 防抖(useDebounce hook)",
|
|
||||||
"使用新的 markdown processor 缓存"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/StatusBar/StatusBar.tsx",
|
|
||||||
"phase": 2,
|
|
||||||
"item": "PF-02",
|
|
||||||
"changes": [
|
|
||||||
"替换 tabs 订阅为 activeTab 选择器"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Sidebar/Sidebar.tsx",
|
|
||||||
"phase": [2, 3],
|
|
||||||
"item": ["PF-02", "AR-02"],
|
|
||||||
"changes": [
|
|
||||||
"替换 tabs 订阅为 activeTab 选择器",
|
|
||||||
"提取 FileTree 组件到独立文件",
|
|
||||||
"提取 resize 逻辑到 useSidebarResize hook"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Editor/useCodeMirror.ts",
|
|
||||||
"phase": 2,
|
|
||||||
"item": "PF-04",
|
|
||||||
"changes": [
|
|
||||||
"引入 Compartment API(@codemirror/state)",
|
|
||||||
"theme 扩展放入 Compartment",
|
|
||||||
"darkMode 变化时通过 compartment.reconfigure() 切换主题",
|
|
||||||
"移除 EditorView 销毁重建逻辑"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Toolbar/Toolbar.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "PF-03",
|
|
||||||
"changes": [
|
|
||||||
"用 React.memo 包装组件导出"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/TabBar/TabBar.tsx",
|
|
||||||
"phase": [3, 4],
|
|
||||||
"item": ["PF-03", "UX-01"],
|
|
||||||
"changes": [
|
|
||||||
"用 React.memo 包装组件导出",
|
|
||||||
"替换 4 处 confirm() 为自定义 ConfirmDialog"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useUnsavedWarning.ts",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-01",
|
|
||||||
"changes": [
|
|
||||||
"替换 confirm() 为 electron.dialog.showMessageBox 或自定义 ConfirmDialog"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useTheme.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-03",
|
|
||||||
"changes": [
|
|
||||||
"移除独立的 settingsRepository.load() 调用",
|
|
||||||
"改为从统一的 useSettingsInit hook 获取设置"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useSettings.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-03",
|
|
||||||
"changes": [
|
|
||||||
"移除独立的 settingsRepository.load() 调用",
|
|
||||||
"改为从统一的 useSettingsInit hook 获取设置"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/hooks/useFileWatch.ts",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-04",
|
|
||||||
"changes": [
|
|
||||||
"移除 window.dispatchEvent(CustomEvent) 调用",
|
|
||||||
"改为调用 Zustand store action 更新文件状态"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Icons.tsx",
|
|
||||||
"phase": 3,
|
|
||||||
"item": "AR-06",
|
|
||||||
"changes": [
|
|
||||||
"拆分为 icons/toolbar-icons.tsx、icons/sidebar-icons.tsx、icons/app-icons.tsx",
|
|
||||||
"原文件改为 re-export barrel 文件(向后兼容)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/components/Toast/Toast.tsx",
|
|
||||||
"phase": 4,
|
|
||||||
"item": "UX-05",
|
|
||||||
"changes": [
|
|
||||||
"支持多条堆叠 Toast",
|
|
||||||
"添加 success/error/warning 类型",
|
|
||||||
"添加手动关闭按钮"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/styles/global.css",
|
|
||||||
"phase": 4,
|
|
||||||
"item": ["UX-01", "UX-02", "UX-05"],
|
|
||||||
"changes": [
|
|
||||||
"添加 ConfirmDialog 样式",
|
|
||||||
"添加 LoadingSpinner 样式",
|
|
||||||
"添加 Toast 堆叠和类型样式"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/preload/index.ts",
|
|
||||||
"phase": 5,
|
|
||||||
"item": "DX-02",
|
|
||||||
"changes": [
|
|
||||||
"添加 ElectronAPI 类型约束: const api: ElectronAPI = { ... }",
|
|
||||||
"确保 contextBridge.exposeInMainWorld 返回值类型匹配"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/types/settings.ts",
|
|
||||||
"phase": 6,
|
|
||||||
"item": "UX-03",
|
|
||||||
"changes": [
|
|
||||||
"ViewMode 类型扩展为 'editor' | 'preview' | 'split'"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/index.html",
|
|
||||||
"phase": 6,
|
|
||||||
"item": "SE-01",
|
|
||||||
"changes": [
|
|
||||||
"添加 CSP meta tag 或在 main process BrowserWindow 中配置 CSP header"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/main/window-manager.ts",
|
|
||||||
"phase": 6,
|
|
||||||
"item": "SE-01",
|
|
||||||
"changes": [
|
|
||||||
"在 BrowserWindow webPreferences 中配置 CSP header",
|
|
||||||
"确认 nodeIntegration=false, contextIsolation=true, sandbox=true"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/renderer/lib/markdown.ts",
|
|
||||||
"phase": 6,
|
|
||||||
"item": "SE-02",
|
|
||||||
"changes": [
|
|
||||||
"rehypeFixImages 中的路径校验从简单 '..' 检查改为 path.resolve + allowlist 检查"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"deleted_files": []
|
|
||||||
},
|
|
||||||
|
|
||||||
"technical_solutions": {
|
|
||||||
"CQ-01_eslint_config": {
|
|
||||||
"id": "CQ-01",
|
|
||||||
"title": "ESLint v9 Flat Config",
|
|
||||||
"solution": "创建 eslint.config.js,使用 ESM flat config 格式。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "eslint.config.js",
|
|
||||||
"format": "ESM (export default [...])",
|
|
||||||
"config_content": {
|
|
||||||
"imports": [
|
|
||||||
"eslint-plugin-react-hooks",
|
|
||||||
"eslint-plugin-react-refresh",
|
|
||||||
"@typescript-eslint/eslint-plugin"
|
|
||||||
],
|
|
||||||
"ignores": ["dist/**", "node_modules/**", "out/**"],
|
|
||||||
"languageOptions": {
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module",
|
|
||||||
"ecmaFeatures": { "jsx": true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
|
||||||
"no-console": "warn",
|
|
||||||
"prefer-const": "error",
|
|
||||||
"react-hooks/rules-of-hooks": "error",
|
|
||||||
"react-hooks/exhaustive-deps": "warn",
|
|
||||||
"react-refresh/only-export-components": ["warn", { "allowConstantExport": true }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notes": "ESLint v9 不再支持 --ext 参数,flat config 中通过 files glob 控制。需更新 package.json 的 lint 脚本。"
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "LOW"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-01_markdown_cache": {
|
|
||||||
"id": "PF-01",
|
|
||||||
"title": "Markdown Processor 缓存",
|
|
||||||
"solution": "将 unified processor 实例提取到独立模块,使用 Map 按 filePath 缓存。",
|
|
||||||
"implementation": {
|
|
||||||
"new_file": "src/renderer/lib/markdown-processor.ts",
|
|
||||||
"approach": "Module-level singleton cache",
|
|
||||||
"cache_strategy": {
|
|
||||||
"key": "filePath (string)",
|
|
||||||
"value": "Processor instance (unified processor with all 7 plugins)",
|
|
||||||
"invalidation": "filePath 变化时创建新 processor,旧实例保留在 cache 中供回退",
|
|
||||||
"max_cache_size": 10,
|
|
||||||
"eviction": "LRU - 超过 max_cache_size 时移除最久未使用的"
|
|
||||||
},
|
|
||||||
"code_sketch": "const processorCache = new Map<string, Processor>();\nexport function getProcessor(filePath: string): Processor {\n if (processorCache.has(filePath)) return processorCache.get(filePath)!;\n const p = unified().use(remarkParse)...;\n processorCache.set(filePath, p);\n return p;\n}"
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "LOW - unified processor 是无状态的,缓存安全"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-02_zustand_selectors": {
|
|
||||||
"id": "PF-02",
|
|
||||||
"title": "Zustand 选择器优化",
|
|
||||||
"solution": "在 tabStore 中添加 getActiveTab 派生选择器,各组件只订阅 activeTab 引用。",
|
|
||||||
"implementation": {
|
|
||||||
"approach": "在 store 中添加 getter 方法 + useShallow",
|
|
||||||
"store_changes": {
|
|
||||||
"new_methods": [
|
|
||||||
"getActiveTab(): Tab | undefined — 从 state.tabs 和 state.activeTabId 派生",
|
|
||||||
"getActiveTabId(): string | null — 直接返回 state.activeTabId"
|
|
||||||
],
|
|
||||||
"notes": "Zustand 5 中 useShallow 来自 'zustand/react/shallow'"
|
|
||||||
},
|
|
||||||
"component_changes": {
|
|
||||||
"before": "const tabs = useTabStore(s => s.tabs);\nconst activeTab = tabs.find(t => t.id === activeTabId);",
|
|
||||||
"after": "const activeTab = useTabStore(s => s.tabs.find(t => t.id === s.activeTabId));",
|
|
||||||
"note": "直接在选择器中 find,当 activeTab 的引用未变时不会触发重渲染。或使用 useShallow + getActiveTab 方法。"
|
|
||||||
},
|
|
||||||
"affected_files": [
|
|
||||||
"src/renderer/components/Editor/Editor.tsx",
|
|
||||||
"src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
|
||||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": ["PF-01"],
|
|
||||||
"risk": "MEDIUM - 需验证所有订阅点,遗漏会导致功能异常"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-04_codemirror_compartment": {
|
|
||||||
"id": "PF-04",
|
|
||||||
"title": "CodeMirror Compartment 主题切换",
|
|
||||||
"solution": "使用 @codemirror/state 的 Compartment API 封装主题扩展,darkMode 变化时 reconfigure 而非销毁重建。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "src/renderer/components/Editor/useCodeMirror.ts",
|
|
||||||
"approach": "Compartment + reconfigure",
|
|
||||||
"code_sketch": "import { Compartment } from '@codemirror/state';\nconst themeCompartment = new Compartment();\n\n// 创建时:\nconst extensions = [\n // ... other extensions\n themeCompartment.of(darkMode ? oneDark : [])\n];\n\n// darkMode 变化时:\nuseEffect(() => {\n if (editorView) {\n editorView.dispatch({\n effects: themeCompartment.reconfigure(darkMode ? oneDark : [])\n });\n }\n}, [darkMode]);",
|
|
||||||
"key_changes": [
|
|
||||||
"移除 useEffect 中 EditorView 销毁重建逻辑",
|
|
||||||
"theme 扩展改为 Compartment.of() 包装",
|
|
||||||
"darkMode 变化改为 dispatch reconfigure effect",
|
|
||||||
"保留其他扩展不变(keymap、language 等)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "HIGH - 影响编辑器核心初始化流程,需仔细测试切换前后编辑器状态保留"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-07_preview_debounce": {
|
|
||||||
"id": "PF-07",
|
|
||||||
"title": "Preview 渲染防抖",
|
|
||||||
"solution": "创建 useDebounce hook,在 Preview.tsx 中对 content 变化进行 150ms 防抖。",
|
|
||||||
"implementation": {
|
|
||||||
"new_file": "src/renderer/hooks/useDebounce.ts",
|
|
||||||
"approach": "标准 useDebounce hook + useEffect",
|
|
||||||
"code_sketch": "function useDebounce<T>(value: T, delay: number): T {\n const [debouncedValue, setDebouncedValue] = useState(value);\n useEffect(() => {\n const timer = setTimeout(() => setDebouncedValue(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debouncedValue;\n}",
|
|
||||||
"usage_in_preview": "const content = activeTab?.content ?? '';\nconst debouncedContent = useDebounce(content, 150);\nuseEffect(() => {\n // renderMarkdown(debouncedContent)\n}, [debouncedContent]);"
|
|
||||||
},
|
|
||||||
"dependencies": ["PF-01", "PF-02"],
|
|
||||||
"risk": "LOW"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-08_indexeddb_debounce": {
|
|
||||||
"id": "PF-08",
|
|
||||||
"title": "IndexedDB 保存去抖",
|
|
||||||
"solution": "将 tabStore 中的 setTimeout(saveToDB, 0) 替换为 debounce(saveToDB, 500)。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "src/renderer/stores/tabStore.ts",
|
|
||||||
"approach": "引入 debounce 工具函数",
|
|
||||||
"code_sketch": "// utils/debounce.ts\nexport function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {\n let timer: ReturnType<typeof setTimeout>;\n return ((...args: any[]) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n }) as T;\n}\n\n// tabStore.ts\nconst debouncedSaveToDB = debounce(() => saveToDB(), 500);\nconst debouncedSaveActiveTab = debounce((id: string) => tabRepository.saveActiveTabId(id), 300);",
|
|
||||||
"affected_patterns": [
|
|
||||||
"setTimeout(() => saveToDB(), 0) → debouncedSaveToDB()",
|
|
||||||
"setTimeout(() => tabRepository.saveActiveTabId(activeTabId), 0) → debouncedSaveActiveTab(activeTabId)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "LOW - 去抖延迟可配置,用户关闭窗口前需 flush"
|
|
||||||
},
|
|
||||||
|
|
||||||
"AR-05_error_boundary": {
|
|
||||||
"id": "AR-05",
|
|
||||||
"title": "React ErrorBoundary",
|
|
||||||
"solution": "创建 class component ErrorBoundary,捕获渲染错误,显示友好错误页面。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "src/renderer/components/ErrorBoundary/ErrorBoundary.tsx",
|
|
||||||
"features": [
|
|
||||||
"componentDidCatch + getDerivedStateFromError",
|
|
||||||
"显示错误信息、错误栈(开发模式)",
|
|
||||||
"「重新加载」按钮调用 location.reload() 或 window.electronAPI 重启",
|
|
||||||
"「复制错误信息」按钮",
|
|
||||||
"支持 fallback 自定义 UI"
|
|
||||||
],
|
|
||||||
"integration": "在 main.tsx 中 <ErrorBoundary><App /></ErrorBoundary>"
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "LOW"
|
|
||||||
},
|
|
||||||
|
|
||||||
"AR-01_app_split": {
|
|
||||||
"id": "AR-01",
|
|
||||||
"title": "App.tsx 拆分",
|
|
||||||
"solution": "提取 AboutDialog 组件和 useFileOperations hook。",
|
|
||||||
"implementation": {
|
|
||||||
"extractions": [
|
|
||||||
{
|
|
||||||
"name": "AboutDialog",
|
|
||||||
"source": "App.tsx 内联 JSX (~40 行)",
|
|
||||||
"target": "src/renderer/components/AboutDialog/AboutDialog.tsx",
|
|
||||||
"props": "{ visible: boolean; onClose: () => void; version: string }"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "useFileOperations",
|
|
||||||
"source": "App.tsx 中的 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 函数",
|
|
||||||
"target": "src/renderer/hooks/useFileOperations.ts",
|
|
||||||
"returns": "{ handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expected_result": "App.tsx 从 ~307 行减少到 ~120 行"
|
|
||||||
},
|
|
||||||
"dependencies": ["CQ-01", "AR-05"],
|
|
||||||
"risk": "LOW - 提取式重构,行为不变"
|
|
||||||
},
|
|
||||||
|
|
||||||
"AR-03_unified_settings": {
|
|
||||||
"id": "AR-03",
|
|
||||||
"title": "统一设置加载",
|
|
||||||
"solution": "创建 useSettingsInit hook,一次性从 IndexedDB 加载所有设置,分发到各 store。",
|
|
||||||
"implementation": {
|
|
||||||
"new_file": "src/renderer/hooks/useSettingsInit.ts",
|
|
||||||
"approach": "单次加载 + Zustand setState 分发",
|
|
||||||
"code_sketch": "export function useSettingsInit() {\n const [loaded, setLoaded] = useState(false);\n useEffect(() => {\n settingsRepository.load().then(settings => {\n useThemeStore.getState().applySettings(settings);\n useEditorStore.getState().applySettings(settings);\n setLoaded(true);\n });\n }, []);\n return loaded;\n}",
|
|
||||||
"affected_files": [
|
|
||||||
"src/renderer/hooks/useTheme.ts — 移除独立 load()",
|
|
||||||
"src/renderer/hooks/useSettings.ts — 移除独立 load()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "MEDIUM - 设置加载时序变化可能影响首次渲染"
|
|
||||||
},
|
|
||||||
|
|
||||||
"UX-01_confirm_dialog": {
|
|
||||||
"id": "UX-01",
|
|
||||||
"title": "自定义确认对话框",
|
|
||||||
"solution": "创建 ConfirmDialog 组件 + useConfirm hook,替代原生 confirm()。",
|
|
||||||
"implementation": {
|
|
||||||
"approach": "Promise-based confirm API",
|
|
||||||
"code_sketch": "// useConfirm.ts\nexport function useConfirm() {\n const [state, setState] = useState<{ message: string; resolve: (v: boolean) => void } | null>(null);\n const confirm = (message: string) => new Promise<boolean>(resolve => setState({ message, resolve }));\n const handleConfirm = () => { state?.resolve(true); setState(null); };\n const handleCancel = () => { state?.resolve(false); setState(null); };\n return { confirm, dialog: state ? <ConfirmDialog .../> : null };\n}",
|
|
||||||
"affected_locations": [
|
|
||||||
"TabBar.tsx — 4 处 confirm() 调用",
|
|
||||||
"useUnsavedWarning.ts — 1 处 confirm() 调用"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": ["AR-01"],
|
|
||||||
"risk": "MEDIUM - Promise-based API 与原生 confirm() 的同步行为不同,需确保调用方正确 await"
|
|
||||||
},
|
|
||||||
|
|
||||||
"UX-03_split_mode": {
|
|
||||||
"id": "UX-03",
|
|
||||||
"title": "分屏模式",
|
|
||||||
"solution": "扩展 ViewMode 为 'split',布局组件根据 mode 同时渲染 Editor 和 Preview。",
|
|
||||||
"implementation": {
|
|
||||||
"approach": "CSS Grid/ResizeObserver 双栏布局",
|
|
||||||
"key_changes": [
|
|
||||||
"types/settings.ts: ViewMode = 'editor' | 'preview' | 'split'",
|
|
||||||
"App.tsx 或新 Layout 组件: 根据 viewMode 条件渲染",
|
|
||||||
"split 模式: 左 50% Editor + 右 50% Preview,可拖拽调整比例",
|
|
||||||
"添加 Ctrl+3 快捷键切换到 split 模式",
|
|
||||||
"可选:编辑器滚动同步预览滚动位置"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": ["PF-01", "PF-02", "PF-04"],
|
|
||||||
"risk": "HIGH - 需要同时维护两个视图实例,内存和性能需关注"
|
|
||||||
},
|
|
||||||
|
|
||||||
"PF-06_code_splitting": {
|
|
||||||
"id": "PF-06",
|
|
||||||
"title": "代码分割",
|
|
||||||
"solution": "在 electron.vite.config.ts 中配置 Rollup manualChunks,按依赖类型分割。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "electron.vite.config.ts",
|
|
||||||
"approach": "manualChunks + dynamic import",
|
|
||||||
"chunks_config": {
|
|
||||||
"vendor-react": ["react", "react-dom"],
|
|
||||||
"vendor-codemirror": ["@codemirror/state", "@codemirror/view", "@codemirror/commands", "@codemirror/lang-markdown", "@codemirror/language", "@codemirror/search", "@codemirror/theme-one-dark"],
|
|
||||||
"vendor-markdown": ["unified", "remark-parse", "remark-gfm", "remark-rehype", "rehype-raw", "rehype-sanitize", "rehype-stringify", "rehype-highlight"],
|
|
||||||
"vendor-db": ["dexie"]
|
|
||||||
},
|
|
||||||
"dynamic_import_targets": [
|
|
||||||
"rehype-highlight (仅 Preview 组件需要)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": [],
|
|
||||||
"risk": "MEDIUM - 代码分割后需验证 Electron 打包正确性和加载时序"
|
|
||||||
},
|
|
||||||
|
|
||||||
"SE-01_csp": {
|
|
||||||
"id": "SE-01",
|
|
||||||
"title": "CSP 配置",
|
|
||||||
"solution": "在 main process 的 BrowserWindow 中通过 session.defaultSession.webRequest 配置 CSP header。",
|
|
||||||
"implementation": {
|
|
||||||
"file": "src/main/window-manager.ts",
|
|
||||||
"approach": "session.webRequest.onHeadersReceived",
|
|
||||||
"csp_policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data:; font-src 'self' data:;",
|
|
||||||
"notes": "style-src 需要 'unsafe-inline' 因为 CodeMirror 通过 JS 注入内联样式。rehype-highlight 引入 highlight.js 样式也需要考虑。"
|
|
||||||
},
|
|
||||||
"dependencies": ["PF-04"],
|
|
||||||
"risk": "MEDIUM - CSP 可能阻断 CodeMirror 样式注入,需测试"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"dependency_graph": {
|
|
||||||
"description": "优化项之间的依赖关系。箭头表示 'A -> B' 意味着 B 依赖 A(A 需先完成)。",
|
|
||||||
"edges": [
|
|
||||||
{ "from": "CQ-01", "to": "CQ-02", "reason": "Prettier 需与 ESLint 集成 (eslint-config-prettier)" },
|
|
||||||
{ "from": "CQ-01", "to": "AR-01", "reason": "组件拆分前需确保代码质量基线" },
|
|
||||||
{ "from": "CQ-01", "to": "AR-02", "reason": "组件拆分前需确保代码质量基线" },
|
|
||||||
{ "from": "CQ-01", "to": "PF-03", "reason": "React.memo 添加前需 ESLint 规则配合" },
|
|
||||||
{ "from": "CQ-01", "to": "DX-05", "reason": "lint-staged 依赖 ESLint 配置" },
|
|
||||||
{ "from": "CQ-01", "to": "DX-01", "reason": "测试代码也需遵循 ESLint 规则" },
|
|
||||||
{ "from": "CQ-04", "to": "DX-02", "reason": "preload 类型安全依赖类型完善" },
|
|
||||||
{ "from": "PF-01", "to": "PF-02", "reason": "Zustand 选择器优化涉及 Preview 组件,需配合 Markdown 缓存" },
|
|
||||||
{ "from": "PF-01", "to": "PF-07", "reason": "Preview 防抖依赖渲染管道已优化" },
|
|
||||||
{ "from": "PF-02", "to": "PF-07", "reason": "Preview 防抖依赖 activeTab 选择器" },
|
|
||||||
{ "from": "PF-02", "to": "PF-03", "reason": "React.memo 效果依赖选择器优化减少不必要更新" },
|
|
||||||
{ "from": "AR-01", "to": "UX-01", "reason": "ConfirmDialog 依赖组件拆分后的目录结构" },
|
|
||||||
{ "from": "AR-01", "to": "UX-02", "reason": "LoadingSpinner 依赖组件拆分后的目录结构" },
|
|
||||||
{ "from": "PF-01", "to": "UX-03", "reason": "分屏模式的预览面板依赖 Markdown 缓存" },
|
|
||||||
{ "from": "PF-02", "to": "UX-03", "reason": "分屏模式需要高效的 store 订阅" },
|
|
||||||
{ "from": "PF-04", "to": "UX-03", "reason": "分屏模式的编辑器依赖 Compartment 动态配置" },
|
|
||||||
{ "from": "PF-04", "to": "SE-01", "reason": "CSP 配置需在 Compartment 稳定后进行,避免相互影响" },
|
|
||||||
{ "from": "AR-05", "to": "AR-01", "reason": "ErrorBoundary 应在 App.tsx 拆分前完成,保护重构过程" }
|
|
||||||
],
|
|
||||||
"parallelizable": [
|
|
||||||
["CQ-01", "AR-05", "DX-07"],
|
|
||||||
["PF-01", "PF-04", "PF-08"],
|
|
||||||
["CQ-04", "CQ-05", "AR-03", "AR-04"],
|
|
||||||
["UX-01", "UX-02", "UX-05"],
|
|
||||||
["DX-01", "DX-04"]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"risk_assessment": {
|
|
||||||
"high_risk": [
|
|
||||||
{
|
|
||||||
"id": "PF-04",
|
|
||||||
"title": "CodeMirror Compartment 重构",
|
|
||||||
"risk_level": "HIGH",
|
|
||||||
"description": "影响编辑器核心初始化流程。Compartment reconfigure 如果处理不当,可能导致编辑器状态丢失(光标位置、选区、undo 历史)。",
|
|
||||||
"impact": "编辑器完全不可用",
|
|
||||||
"probability": "MEDIUM",
|
|
||||||
"mitigation": [
|
|
||||||
"保留现有销毁重建逻辑作为 fallback(feature flag 控制)",
|
|
||||||
"编写测试验证切换主题后光标位置和 undo 历史保留",
|
|
||||||
"逐步迁移:先用 Compartment 切换主题,验证稳定后再扩展到其他扩展"
|
|
||||||
],
|
|
||||||
"rollback": "revert useCodeMirror.ts 到使用前的版本"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-03",
|
|
||||||
"title": "分屏模式",
|
|
||||||
"risk_level": "HIGH",
|
|
||||||
"description": "需要同时维护 Editor 和 Preview 两个实例,可能增加内存占用和 CPU 消耗。滚动同步逻辑复杂。",
|
|
||||||
"impact": "性能下降、内存溢出(大文件场景)",
|
|
||||||
"probability": "MEDIUM",
|
|
||||||
"mitigation": [
|
|
||||||
"Preview 面板使用延迟加载(只在 split 模式激活时挂载)",
|
|
||||||
"大文件场景下限制预览刷新频率(500ms debounce)",
|
|
||||||
"考虑虚拟化预览(只渲染可视区域)"
|
|
||||||
],
|
|
||||||
"rollback": "ViewMode 从 'editor' | 'preview' | 'split' 移除 'split' 值"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"medium_risk": [
|
|
||||||
{
|
|
||||||
"id": "PF-02",
|
|
||||||
"title": "Zustand 选择器重构",
|
|
||||||
"risk_level": "MEDIUM",
|
|
||||||
"description": "选择器重构可能遗漏某些订阅点,导致某些组件在 activeTab 变化时不再更新。",
|
|
||||||
"impact": "编辑器/预览/状态栏内容不同步",
|
|
||||||
"probability": "LOW",
|
|
||||||
"mitigation": [
|
|
||||||
"使用 React DevTools Profiler 逐一验证每个受影响组件的重渲染行为",
|
|
||||||
"逐个文件修改,每修改一个文件后手动测试",
|
|
||||||
"编写 tabStore 选择器的单元测试"
|
|
||||||
],
|
|
||||||
"rollback": "逐文件恢复原始订阅方式"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SE-01",
|
|
||||||
"title": "CSP 配置",
|
|
||||||
"risk_level": "MEDIUM",
|
|
||||||
"description": "CSP 配置过于严格可能阻断 CodeMirror 内联样式注入或 rehype-highlight 的样式。",
|
|
||||||
"impact": "编辑器或预览样式丢失",
|
|
||||||
"probability": "LOW",
|
|
||||||
"mitigation": [
|
|
||||||
"使用 'unsafe-inline' 允许 style-src(CodeMirror 依赖)",
|
|
||||||
"分步启用 CSP 先 report-only 模式测试",
|
|
||||||
"测试所有主题切换和 Markdown 语法高亮场景"
|
|
||||||
],
|
|
||||||
"rollback": "移除 CSP header 配置"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-03",
|
|
||||||
"title": "统一设置加载",
|
|
||||||
"risk_level": "MEDIUM",
|
|
||||||
"description": "将两次 IndexedDB 读取合并为一次可能改变设置加载时序,导致首次渲染闪烁或主题闪烁。",
|
|
||||||
"impact": "首次加载时短暂显示默认主题再切换到用户主题",
|
|
||||||
"probability": "LOW",
|
|
||||||
"mitigation": [
|
|
||||||
"在设置加载完成前显示 loading 状态(不渲染 App)",
|
|
||||||
"使用 init() 方式在 main.tsx 中等待设置加载完成后再渲染"
|
|
||||||
],
|
|
||||||
"rollback": "恢复 useTheme 和 useSettings 各自独立加载"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-08",
|
|
||||||
"title": "IndexedDB 去抖",
|
|
||||||
"risk_level": "MEDIUM",
|
|
||||||
"description": "去抖延迟期间如果 Electron 进程被杀或用户关闭窗口,可能丢失最后一次保存。",
|
|
||||||
"impact": "用户数据丢失(最后一次编辑)",
|
|
||||||
"probability": "LOW",
|
|
||||||
"mitigation": [
|
|
||||||
"在 beforeunload / window close 事件中 flush debounce",
|
|
||||||
"使用 navigator.sendBeacon 或 Electron app.on('before-quit') 事件触发同步保存",
|
|
||||||
"记录 dirty flag,关闭前检查"
|
|
||||||
],
|
|
||||||
"rollback": "恢复 setTimeout(fn, 0) 立即保存"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"low_risk": [
|
|
||||||
{
|
|
||||||
"id": "CQ-01",
|
|
||||||
"title": "ESLint 配置",
|
|
||||||
"risk_level": "LOW",
|
|
||||||
"description": "纯增量添加,不影响运行时行为。",
|
|
||||||
"mitigation": ["确保 .eslintignore 排除构建产物"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-01",
|
|
||||||
"title": "Markdown 处理器缓存",
|
|
||||||
"risk_level": "LOW",
|
|
||||||
"description": "unified processor 是无状态管道,缓存复用安全。",
|
|
||||||
"mitigation": ["设置 max cache size 防止内存泄漏"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-07",
|
|
||||||
"title": "Preview 防抖",
|
|
||||||
"risk_level": "LOW",
|
|
||||||
"description": "防抖只是延迟渲染,不影响最终结果。",
|
|
||||||
"mitigation": ["防抖时间可配置,默认 150ms"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-05",
|
|
||||||
"title": "ErrorBoundary",
|
|
||||||
"risk_level": "LOW",
|
|
||||||
"description": "纯增量添加,只在出错时生效。",
|
|
||||||
"mitigation": ["测试注入错误验证 ErrorBoundary 正常捕获"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"summary": {
|
|
||||||
"total_optimization_items": 35,
|
|
||||||
"total_new_files": 28,
|
|
||||||
"total_modified_files": 22,
|
|
||||||
"total_phases": 6,
|
|
||||||
"estimated_total_effort": "12-18 天",
|
|
||||||
"critical_path": "CQ-01 → AR-05 → AR-01 → UX-01 → DX-05",
|
|
||||||
"parallel_speedup_potential": "约 30-40%(Phase 1 和 Phase 2 可部分并行)",
|
|
||||||
"highest_impact_items": ["PF-02", "PF-01", "PF-04", "AR-05"],
|
|
||||||
"recommended_execution_order": [
|
|
||||||
"Phase 1 (Day 1-2): ESLint + Prettier + ErrorBoundary — 建立基线",
|
|
||||||
"Phase 2 (Day 3-4): 性能修复 — 解决核心瓶颈",
|
|
||||||
"Phase 3 (Day 5-7): 架构重构 — 提升可维护性",
|
|
||||||
"Phase 4 (Day 8-10): UX 改进 — 提升用户体验",
|
|
||||||
"Phase 5 (Day 11-13): 测试 + 文档 — 建立质量保障",
|
|
||||||
"Phase 6 (Day 14-18): 高级功能 — 锦上添花"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
{
|
|
||||||
"reportTitle": "MarkLite v0.1.1 代码质量优化交付报告",
|
|
||||||
"reportDate": "2026-06-03",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"author": "Asset Custodian (多Agent协作)",
|
|
||||||
|
|
||||||
"optimizationSummary": {
|
|
||||||
"objective": "通过多Agent协作系统对 MarkLite 项目进行全方位代码质量优化和性能改进,涵盖代码质量审查、性能优化、UI/UX改进、类型完善和测试覆盖",
|
|
||||||
"scope": "src/renderer/ 目录下全部前端代码,包括组件、状态管理、工具库、样式和测试",
|
|
||||||
"totalPhases": 5,
|
|
||||||
"completedOptimizations": [
|
|
||||||
{
|
|
||||||
"phase": 1,
|
|
||||||
"name": "基础设施建设",
|
|
||||||
"items": [
|
|
||||||
"ESLint flat config (typescript-eslint + react-hooks + react-refresh)",
|
|
||||||
"lint-staged + husky Git pre-commit 钩子",
|
|
||||||
"ErrorBoundary 全局错误边界组件"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 2,
|
|
||||||
"name": "性能优化",
|
|
||||||
"items": [
|
|
||||||
"Markdown 处理器 LRU 缓存(最大10条,PF-01)",
|
|
||||||
"Zustand store 选择器优化(细粒度订阅,PF-02)",
|
|
||||||
"CodeMirror Compartment 主题热切换(PF-04)",
|
|
||||||
"预览区防抖机制(150ms,PF-05)"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 3,
|
|
||||||
"name": "代码架构改进",
|
|
||||||
"items": [
|
|
||||||
"sidebarStore 精简与 loadFromDB 优化",
|
|
||||||
"useTheme 统一由 useSettingsInit 管理(AR-04)",
|
|
||||||
"统一错误处理模块 errorHandler.ts(CQ-05)",
|
|
||||||
"组件选择器优化减少不必要重渲染"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 4,
|
|
||||||
"name": "UI/UX 改进",
|
|
||||||
"items": [
|
|
||||||
"ConfirmDialog 通用确认对话框(支持 focus trap)",
|
|
||||||
"LoadingSpinner 加载状态组件",
|
|
||||||
"Toast 通知系统升级",
|
|
||||||
"可访问性改进(ARIA属性、键盘导航)"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phase": 5,
|
|
||||||
"name": "测试与文档",
|
|
||||||
"items": [
|
|
||||||
"Vitest 测试框架配置(jsdom + globals)",
|
|
||||||
"5个测试文件,78个测试用例全部通过",
|
|
||||||
"DESIGN.md 和 README.md 文档更新",
|
|
||||||
"husky + lint-staged Git hooks 集成"
|
|
||||||
],
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"qualityReview": {
|
|
||||||
"score": 82,
|
|
||||||
"maxScore": 100,
|
|
||||||
"issuesFound": "multiple",
|
|
||||||
"issuesResolved": "all",
|
|
||||||
"status": "通过"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"fileChangeStatistics": {
|
|
||||||
"scope": "v0.1.1 优化提交 (commit 3cecb0f)",
|
|
||||||
"totalFilesChanged": 16,
|
|
||||||
"insertions": 106,
|
|
||||||
"deletions": 66,
|
|
||||||
"netChange": "+40 lines",
|
|
||||||
"modifiedFiles": [
|
|
||||||
"DESIGN.md",
|
|
||||||
"README.md",
|
|
||||||
"package.json",
|
|
||||||
"src/renderer/App.tsx",
|
|
||||||
"src/renderer/components/Editor/Editor.tsx",
|
|
||||||
"src/renderer/components/Editor/useCodeMirror.ts",
|
|
||||||
"src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"src/renderer/components/Sidebar/Sidebar.tsx",
|
|
||||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
|
||||||
"src/renderer/components/TabBar/TabBar.tsx",
|
|
||||||
"src/renderer/components/Toolbar/Toolbar.tsx",
|
|
||||||
"src/renderer/hooks/useDragDrop.ts",
|
|
||||||
"src/renderer/hooks/useSettings.ts",
|
|
||||||
"src/renderer/hooks/useTheme.ts",
|
|
||||||
"src/renderer/stores/sidebarStore.ts",
|
|
||||||
"src/renderer/stores/tabStore.ts"
|
|
||||||
],
|
|
||||||
"newFiles": [],
|
|
||||||
"projectTotals": {
|
|
||||||
"sourceFileCount": 62,
|
|
||||||
"totalSourceLines": 4808,
|
|
||||||
"componentDirs": 16,
|
|
||||||
"testFiles": 5,
|
|
||||||
"storeModules": 3,
|
|
||||||
"hookModules": 14,
|
|
||||||
"typeModules": 5,
|
|
||||||
"libModules": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"performanceImprovements": {
|
|
||||||
"markdownProcessorCache": {
|
|
||||||
"id": "PF-01",
|
|
||||||
"mechanism": "LRU 缓存(Map-based,最大10条)",
|
|
||||||
"implementation": "src/renderer/lib/markdown.ts",
|
|
||||||
"description": "基于文件路径缓存已构建的 unified 处理器实例,LRU淘汰策略避免内存泄漏",
|
|
||||||
"expectedImprovement": "重复渲染同一文件时跳过处理器构建阶段,预览刷新延迟降低"
|
|
||||||
},
|
|
||||||
"zustandSelectorOptimization": {
|
|
||||||
"id": "PF-02",
|
|
||||||
"mechanism": "细粒度选择器订阅",
|
|
||||||
"implementation": "src/renderer/stores/sidebarStore.ts, tabStore.ts, editorStore.ts",
|
|
||||||
"description": "sidebarStore 精简为单一状态源,移除冗余字段;组件通过原子选择器订阅最小状态集",
|
|
||||||
"expectedImprovement": "减少无关状态变化触发的组件重渲染"
|
|
||||||
},
|
|
||||||
"codemirrorThemeCompartment": {
|
|
||||||
"id": "PF-04",
|
|
||||||
"mechanism": "CodeMirror Compartment.reconfigure()",
|
|
||||||
"implementation": "src/renderer/components/Editor/useCodeMirror.ts",
|
|
||||||
"description": "创建 themeCompartment,darkMode 变化时通过 reconfigure() 交换主题扩展,不重建 EditorView",
|
|
||||||
"expectedImprovement": "主题切换零闪烁,无需销毁重建编辑器实例,保持光标和滚动位置"
|
|
||||||
},
|
|
||||||
"previewDebounce": {
|
|
||||||
"id": "PF-05",
|
|
||||||
"mechanism": "150ms 防抖(setTimeout + useRef)",
|
|
||||||
"implementation": "src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"description": "编辑内容变化后延迟150ms再触发预览渲染,快速连续输入时合并为单次渲染",
|
|
||||||
"expectedImprovement": "大量快速输入时预览区渲染次数显著减少,降低 CPU 使用"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"qualityMetrics": {
|
|
||||||
"typescript": {
|
|
||||||
"command": "npx tsc --noEmit",
|
|
||||||
"errors": 0,
|
|
||||||
"status": "PASS",
|
|
||||||
"strictMode": true
|
|
||||||
},
|
|
||||||
"eslint": {
|
|
||||||
"command": "npx eslint src/ --ext .ts,.tsx",
|
|
||||||
"errors": 0,
|
|
||||||
"warnings": 7,
|
|
||||||
"warningDetails": [
|
|
||||||
"react-hooks/exhaustive-deps: useEffect missing dependency 'activeTab' (1处,可接受的业务逻辑)",
|
|
||||||
"no-console: console 语句 (6处,错误处理和调试日志中合理使用)"
|
|
||||||
],
|
|
||||||
"status": "PASS"
|
|
||||||
},
|
|
||||||
"tests": {
|
|
||||||
"framework": "Vitest 4.1.8",
|
|
||||||
"environment": "jsdom",
|
|
||||||
"totalTestFiles": 5,
|
|
||||||
"totalTests": 78,
|
|
||||||
"passed": 78,
|
|
||||||
"failed": 0,
|
|
||||||
"skipped": 0,
|
|
||||||
"duration": "55ms (tests only), 9.68s (total)",
|
|
||||||
"testSuites": [
|
|
||||||
{ "file": "src/renderer/lib/__tests__/errorHandler.test.ts", "tests": 12 },
|
|
||||||
{ "file": "src/renderer/stores/__tests__/tabStore.test.ts", "tests": 17 },
|
|
||||||
{ "file": "src/renderer/lib/__tests__/fileUtils.test.ts", "tests": 21 },
|
|
||||||
{ "file": "src/renderer/stores/__tests__/editorStore.test.ts", "tests": 12 },
|
|
||||||
{ "file": "src/renderer/lib/__tests__/markdown.test.ts", "tests": 16 }
|
|
||||||
],
|
|
||||||
"status": "PASS"
|
|
||||||
},
|
|
||||||
"coverage": {
|
|
||||||
"status": "未配置 @vitest/coverage-v8 依赖",
|
|
||||||
"note": "vitest.config.ts 已配置 v8 provider,需安装 @vitest/coverage-v8 后启用"
|
|
||||||
},
|
|
||||||
"qualityScore": {
|
|
||||||
"reviewScore": 82,
|
|
||||||
"maxScore": 100,
|
|
||||||
"rating": "良好",
|
|
||||||
"breakdown": {
|
|
||||||
"codeStructure": "85/100 - 组件划分合理,目录结构清晰",
|
|
||||||
"typeScript": "90/100 - 0编译错误,类型覆盖良好",
|
|
||||||
"errorHandling": "80/100 - 统一错误处理模块,ErrorBoundary",
|
|
||||||
"performance": "78/100 - LRU缓存、防抖、Compartment优化",
|
|
||||||
"testing": "75/100 - 78测试覆盖核心逻辑,缺少集成测试",
|
|
||||||
"documentation": "80/100 - DESIGN.md + README.md 更新完善",
|
|
||||||
"accessibility": "70/100 - ConfirmDialog focus trap,待扩展"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"subsequentRecommendations": {
|
|
||||||
"phase6AdvancedFeatures": [
|
|
||||||
{
|
|
||||||
"id": "AF-01",
|
|
||||||
"title": "虚拟滚动",
|
|
||||||
"description": "FileTree 和 TabBar 在大量文件/标签时引入虚拟列表(react-window 或自实现)",
|
|
||||||
"priority": "中",
|
|
||||||
"effort": "中"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AF-02",
|
|
||||||
"title": "Web Worker Markdown 渲染",
|
|
||||||
"description": "将 Markdown 解析移至 Web Worker,主线程零阻塞",
|
|
||||||
"priority": "高",
|
|
||||||
"effort": "高"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AF-03",
|
|
||||||
"title": "增量编辑同步",
|
|
||||||
"description": "基于 CodeMirror transactions 的增量 diff 更新预览,替代全量重渲染",
|
|
||||||
"priority": "高",
|
|
||||||
"effort": "高"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AF-04",
|
|
||||||
"title": "i18n 国际化",
|
|
||||||
"description": "提取硬编码中文字符串到 i18n 资源文件",
|
|
||||||
"priority": "低",
|
|
||||||
"effort": "中"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AF-05",
|
|
||||||
"title": "代码覆盖率提升",
|
|
||||||
"description": "安装 @vitest/coverage-v8,目标覆盖率 70%+,补充组件集成测试",
|
|
||||||
"priority": "高",
|
|
||||||
"effort": "中"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"longTermMaintenance": [
|
|
||||||
"定期更新依赖(Electron、CodeMirror、Zustand),关注 breaking changes",
|
|
||||||
"保持 ESLint 规则集更新,跟进 typescript-eslint 新版本",
|
|
||||||
"每季度进行一次代码质量审查(参照本次多Agent审查流程)",
|
|
||||||
"考虑引入 CI/CD 流水线自动化测试和 lint 检查",
|
|
||||||
"监控 Electron 安全公告,及时升级版本修复漏洞",
|
|
||||||
"补充 E2E 测试(Playwright 或 Spectron),覆盖核心用户流程",
|
|
||||||
"建立代码贡献规范(CONTRIBUTING.md),统一 PR 审查流程"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,589 +0,0 @@
|
|||||||
{
|
|
||||||
"project": "MarkLite",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"analysisDate": "2026-06-03",
|
|
||||||
"techStack": {
|
|
||||||
"electron": "28.3.3",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"typescript": "5.6.3",
|
|
||||||
"vite": "electron-vite 3.0.0",
|
|
||||||
"stateManagement": "zustand 5.0.6",
|
|
||||||
"editor": "CodeMirror 6",
|
|
||||||
"database": "Dexie (IndexedDB)"
|
|
||||||
},
|
|
||||||
"currentStatus": {
|
|
||||||
"typescriptCheck": "PASS",
|
|
||||||
"eslintConfig": "MISSING",
|
|
||||||
"testCoverage": "NONE",
|
|
||||||
"documentation": "NONE",
|
|
||||||
"ci": "NONE"
|
|
||||||
},
|
|
||||||
"codebaseStats": {
|
|
||||||
"totalSourceFiles": 44,
|
|
||||||
"mainProcessFiles": 5,
|
|
||||||
"preloadFiles": 1,
|
|
||||||
"rendererFiles": 36,
|
|
||||||
"sharedFiles": 2,
|
|
||||||
"components": 14,
|
|
||||||
"stores": 3,
|
|
||||||
"hooks": 7,
|
|
||||||
"dbRepositories": 3,
|
|
||||||
"typeDefinitions": 5,
|
|
||||||
"styleFiles": 3
|
|
||||||
},
|
|
||||||
"optimizationRequirements": {
|
|
||||||
"1_codeQuality": {
|
|
||||||
"title": "代码质量与规范",
|
|
||||||
"priority": "HIGH",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "CQ-01",
|
|
||||||
"title": "配置 ESLint (Flat Config)",
|
|
||||||
"priority": "P0-CRITICAL",
|
|
||||||
"description": "package.json 中已有 @typescript-eslint/eslint-plugin、eslint、eslint-plugin-react-hooks 等依赖,但缺少 eslint.config.js 配置文件。npm run lint 无法正常工作。",
|
|
||||||
"currentState": "devDependencies 已安装但无配置文件",
|
|
||||||
"expectedState": "创建 eslint.config.js (ESLint v9 flat config 格式),配置 TypeScript + React + React Hooks 规则",
|
|
||||||
"effort": "small",
|
|
||||||
"details": {
|
|
||||||
"configFormat": "ESM flat config (eslint.config.js)",
|
|
||||||
"plugins": ["@typescript-eslint/eslint-plugin", "eslint-plugin-react-hooks", "eslint-plugin-react-refresh"],
|
|
||||||
"rules": [
|
|
||||||
"typescript-eslint/recommended",
|
|
||||||
"react-hooks/recommended",
|
|
||||||
"no-unused-vars (error)",
|
|
||||||
"no-console (warn)",
|
|
||||||
"prefer-const"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "CQ-02",
|
|
||||||
"title": "添加 Prettier 代码格式化",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "项目无统一代码格式化工具,代码风格靠人工维护(缩进、引号、分号等不一致风险高)。",
|
|
||||||
"currentState": "无 Prettier 配置",
|
|
||||||
"expectedState": "添加 .prettierrc 配置 + 与 ESLint 集成",
|
|
||||||
"effort": "small",
|
|
||||||
"details": {
|
|
||||||
"config": {
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "CQ-03",
|
|
||||||
"title": "添加 .editorconfig",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "统一编辑器行为配置,确保不同开发者环境一致。",
|
|
||||||
"currentState": "无 .editorconfig",
|
|
||||||
"expectedState": "添加 .editorconfig 文件",
|
|
||||||
"effort": "trivial"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "CQ-04",
|
|
||||||
"title": "修复 TypeScript 严格模式下的隐式 any",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "虽然 tsc --noEmit 通过,但部分回调参数缺少类型标注(如 CustomEvent detail),依赖类型推断。",
|
|
||||||
"currentState": "编译通过但存在隐式类型",
|
|
||||||
"expectedState": "所有函数参数显式标注类型",
|
|
||||||
"effort": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "CQ-05",
|
|
||||||
"title": "统一错误处理模式",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "多处 catch 块为空或仅 console.error,用户看不到有意义的错误信息。App.tsx 中 showToast('操作失败') 过于笼统。",
|
|
||||||
"currentState": "分散的 try-catch,错误消息不具体",
|
|
||||||
"expectedState": "统一的错误处理函数,包含具体错误描述和日志",
|
|
||||||
"effort": "medium"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2_performance": {
|
|
||||||
"title": "性能优化",
|
|
||||||
"priority": "HIGH",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "PF-01",
|
|
||||||
"title": "Markdown 渲染处理器缓存",
|
|
||||||
"priority": "P0-CRITICAL",
|
|
||||||
"description": "renderMarkdown() 每次调用都创建新的 unified() 处理器管道(含 7 个插件),大文件渲染时开销显著。unified 处理器创建后应缓存复用。",
|
|
||||||
"currentState": "每次渲染创建新 processor (7个插件)",
|
|
||||||
"expectedState": "缓存 processor 实例,仅在 filePath 变化时重建",
|
|
||||||
"effort": "small",
|
|
||||||
"impact": "HIGH - 减少每次渲染的初始化开销",
|
|
||||||
"details": {
|
|
||||||
"location": "src/renderer/lib/markdown.ts",
|
|
||||||
"solution": "使用 Map<string, Processor> 按 filePath 缓存,或使用 useMemo 在组件层面缓存"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-02",
|
|
||||||
"title": "Zustand 选择器优化 - 避免订阅整个 tabs 数组",
|
|
||||||
"priority": "P0-CRITICAL",
|
|
||||||
"description": "Editor.tsx、Preview.tsx、StatusBar.tsx、Sidebar.tsx 都订阅 tabs 数组然后 .find() 查找 activeTab。每次任何 tab 的 content 变化都会触发这些组件重渲染。",
|
|
||||||
"currentState": "const tabs = useTabStore(s => s.tabs); const activeTab = tabs.find(...)",
|
|
||||||
"expectedState": "使用 useShallow 或自定义选择器仅订阅 activeTab",
|
|
||||||
"effort": "medium",
|
|
||||||
"impact": "HIGH - 减少不必要的重渲染",
|
|
||||||
"affectedFiles": [
|
|
||||||
"src/renderer/components/Editor/Editor.tsx",
|
|
||||||
"src/renderer/components/Preview/Preview.tsx",
|
|
||||||
"src/renderer/components/StatusBar/StatusBar.tsx",
|
|
||||||
"src/renderer/components/Sidebar/Sidebar.tsx"
|
|
||||||
],
|
|
||||||
"solution": "在 tabStore 中添加 getActiveTab 选择器方法,或使用 zustand useShallow"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-03",
|
|
||||||
"title": "React.memo 包装纯展示组件",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "Toolbar、StatusBar、Icons 等纯展示组件未使用 React.memo,父组件重渲染时会连带重渲染。",
|
|
||||||
"currentState": "无任何组件使用 React.memo",
|
|
||||||
"expectedState": "对 Toolbar、StatusBar、TabBar、Preview 等组件使用 memo 包装",
|
|
||||||
"effort": "small",
|
|
||||||
"impact": "MEDIUM"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-04",
|
|
||||||
"title": "CodeMirror 主题切换优化",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "useCodeMirror.ts 中 darkMode 变化时销毁并重建整个 EditorView(第93行),导致编辑器完全重新初始化。",
|
|
||||||
"currentState": "darkMode 变化 → 整个 EditorView 销毁重建",
|
|
||||||
"expectedState": "使用 CodeMirror Compartment 动态切换主题扩展",
|
|
||||||
"effort": "medium",
|
|
||||||
"impact": "HIGH - 切换主题时保持编辑器状态",
|
|
||||||
"details": {
|
|
||||||
"location": "src/renderer/components/Editor/useCodeMirror.ts",
|
|
||||||
"solution": "使用 @codemirror/state 的 Compartment API 动态 reconfigure 主题扩展"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-05",
|
|
||||||
"title": "CodeMirror 内容更新优化",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "setContent() 使用 from:0 到 length 全量替换,对大文件(10万行+)性能差。应使用事务 diff 或仅在内容实际变化时替换。",
|
|
||||||
"currentState": "全量替换: changes: { from: 0, to: current.length, insert: newContent }",
|
|
||||||
"expectedState": "仅在内容不同时替换,大文件考虑增量更新",
|
|
||||||
"effort": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-06",
|
|
||||||
"title": "Bundle 大小优化 - 代码分割",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "所有渲染进程代码打包为单一 bundle。rehype-highlight (含 highlight.js)、CodeMirror 等大型库未做懒加载。",
|
|
||||||
"currentState": "单 bundle,无代码分割",
|
|
||||||
"expectedState": "动态 import 预览相关库、分割 vendor chunk",
|
|
||||||
"effort": "medium",
|
|
||||||
"details": {
|
|
||||||
"chunks": [
|
|
||||||
"vendor-react: react + react-dom",
|
|
||||||
"vendor-codemirror: @codemirror/*",
|
|
||||||
"vendor-markdown: unified + remark + rehype 生态",
|
|
||||||
"app: 业务代码"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-07",
|
|
||||||
"title": "Preview 渲染防抖",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "Preview.tsx 在 activeTab?.content 变化时立即触发异步渲染。快速输入时会频繁调用 renderMarkdown。",
|
|
||||||
"currentState": "content 变化立即渲染",
|
|
||||||
"expectedState": "添加 150-300ms 防抖,避免快速输入时频繁渲染",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "PF-08",
|
|
||||||
"title": "保存到 IndexedDB 的去抖优化",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "tabStore 中多处使用 setTimeout(() => saveToDB(), 0) 异步保存,但没有去抖。频繁操作时会多次写入。",
|
|
||||||
"currentState": "setTimeout(fn, 0) 立即触发",
|
|
||||||
"expectedState": "使用 debounce (300-500ms) 合并多次保存",
|
|
||||||
"effort": "small"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"3_architecture": {
|
|
||||||
"title": "架构优化",
|
|
||||||
"priority": "MEDIUM",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "AR-01",
|
|
||||||
"title": "App.tsx 拆分 - About 对话框提取",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "App.tsx 有 307 行,About 对话框 JSX 内联(约 40 行),文件打开/保存逻辑也堆积在组件中。",
|
|
||||||
"currentState": "App.tsx 包含 About 弹窗、文件操作、事件监听等所有逻辑",
|
|
||||||
"expectedState": "提取 AboutDialog 组件、提取 useFileOperations hook",
|
|
||||||
"effort": "medium",
|
|
||||||
"details": {
|
|
||||||
"extractions": [
|
|
||||||
{
|
|
||||||
"name": "AboutDialog",
|
|
||||||
"from": "App.tsx lines 261-300",
|
|
||||||
"to": "src/renderer/components/AboutDialog/AboutDialog.tsx"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "useFileOperations",
|
|
||||||
"description": "提取 handleOpenFile, handleSave, handleSaveAs, handleOpenRecent 为自定义 hook",
|
|
||||||
"to": "src/renderer/hooks/useFileOperations.ts"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-02",
|
|
||||||
"title": "Sidebar 组件拆分",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "Sidebar.tsx 有 241 行,包含 FileTree 递归组件、resize 逻辑、独立文件列表等。应拆分为独立文件。",
|
|
||||||
"currentState": "FileTree 组件定义在 Sidebar.tsx 内",
|
|
||||||
"expectedState": "FileTree 提取为独立组件,resize 逻辑提取为 hook",
|
|
||||||
"effort": "medium",
|
|
||||||
"details": {
|
|
||||||
"extractions": [
|
|
||||||
{
|
|
||||||
"name": "FileTree",
|
|
||||||
"from": "Sidebar.tsx lines 180-239",
|
|
||||||
"to": "src/renderer/components/Sidebar/FileTree.tsx"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "useSidebarResize",
|
|
||||||
"description": "提取 resize 相关逻辑为自定义 hook",
|
|
||||||
"to": "src/renderer/hooks/useSidebarResize.ts"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-03",
|
|
||||||
"title": "统一设置加载 - 消除 useTheme 和 useSettings 的重复加载",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "useTheme 和 useSettings 各自独立调用 settingsRepository.load(),导致启动时两次 IndexedDB 读取。",
|
|
||||||
"currentState": "useTheme.load() + useSettings.load() = 两次 DB 读取",
|
|
||||||
"expectedState": "统一为单个 useSettingsInit hook,一次加载分发到 store",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-04",
|
|
||||||
"title": "消除 DOM CustomEvent 中间层",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "useFileWatch.ts 通过 window.dispatchEvent(CustomEvent) 传递文件修改事件,App.tsx 再 addEventListener 监听。应直接通过 Zustand store 或回调传递。",
|
|
||||||
"currentState": "IPC → CustomEvent DOM → App.tsx listener",
|
|
||||||
"expectedState": "IPC → Zustand store action → 组件响应",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-05",
|
|
||||||
"title": "添加 React ErrorBoundary",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "无全局错误边界。任何渲染错误会导致白屏。Electron 应用中尤其需要,因为用户无法刷新页面恢复。",
|
|
||||||
"currentState": "无 ErrorBoundary",
|
|
||||||
"expectedState": "添加 ErrorBoundary 组件包裹 App,显示错误信息和重启按钮",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-06",
|
|
||||||
"title": "Icons 组件按需加载",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "Icons.tsx 有 216 行,包含 16 个 SVG 图标组件。虽然是函数组件有 tree-shaking,但单文件过大影响维护。",
|
|
||||||
"currentState": "16 个图标定义在单文件",
|
|
||||||
"expectedState": "按功能模块拆分: toolbar-icons.tsx, sidebar-icons.tsx, app-icons.tsx",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "AR-07",
|
|
||||||
"title": "tabStore 异步操作模式统一",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "tabStore 中大量使用 setTimeout(() => saveToDB(), 0) 和 setTimeout(() => tabRepository.saveActiveTabId(), 0)。模式不统一且容易出错。",
|
|
||||||
"currentState": "分散的 setTimeout 调用",
|
|
||||||
"expectedState": "使用 Zustand middleware (subscribeWithSelector) 或统一的 persist 逻辑",
|
|
||||||
"effort": "medium"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"4_userExperience": {
|
|
||||||
"title": "用户体验",
|
|
||||||
"priority": "MEDIUM",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "UX-01",
|
|
||||||
"title": "替换原生 confirm() 为自定义确认对话框",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "TabBar.tsx 和 useUnsavedWarning.ts 使用浏览器原生 confirm() 对话框,样式丑陋且不可自定义。Electron 应用应使用自定义模态框或 electron.dialog。",
|
|
||||||
"currentState": "5 处使用 confirm() 原生对话框",
|
|
||||||
"expectedState": "自定义 ConfirmDialog 组件或使用 electron.dialog.showMessageBox",
|
|
||||||
"effort": "medium",
|
|
||||||
"affectedLocations": [
|
|
||||||
"src/renderer/components/TabBar/TabBar.tsx (4处)",
|
|
||||||
"src/renderer/hooks/useUnsavedWarning.ts (1处)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-02",
|
|
||||||
"title": "添加加载状态指示",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "文件打开、目录树加载、Markdown 渲染等异步操作无加载状态。用户无法感知操作是否正在进行。",
|
|
||||||
"currentState": "无 loading 状态",
|
|
||||||
"expectedState": "添加文件打开 spinner、目录树加载骨架屏、预览渲染指示器",
|
|
||||||
"effort": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-03",
|
|
||||||
"title": "添加编辑器+预览分屏模式",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "当前只有 'editor' 和 'preview' 两种模式,缺少 VS Code 风格的左右分屏同时编辑和预览。",
|
|
||||||
"currentState": "viewMode: 'editor' | 'preview'",
|
|
||||||
"expectedState": "新增 'split' 模式: 左侧编辑器 + 右侧实时预览",
|
|
||||||
"effort": "large",
|
|
||||||
"details": {
|
|
||||||
"implementation": [
|
|
||||||
"Settings.ViewMode 新增 'split'",
|
|
||||||
"Editor 和 Preview 同时渲染",
|
|
||||||
"编辑器滚动同步预览",
|
|
||||||
"Ctrl+3 快捷键"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-04",
|
|
||||||
"title": "TabBar 拖拽排序",
|
|
||||||
"priority": "P3-LOW",
|
|
||||||
"description": "标签页不支持拖拽排序,无法自定义标签顺序。",
|
|
||||||
"currentState": "标签页固定顺序",
|
|
||||||
"expectedState": "支持拖拽重新排列标签页",
|
|
||||||
"effort": "large"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-05",
|
|
||||||
"title": "改进 Toast 通知系统",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "当前 Toast 仅支持单条消息,3秒后消失。不支持不同类型(success/error/warning)和堆叠。",
|
|
||||||
"currentState": "单条 toast,无类型区分",
|
|
||||||
"expectedState": "支持多条堆叠、不同类型(成功/错误/警告)、可关闭",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-06",
|
|
||||||
"title": "添加文件搜索功能 (Ctrl+P)",
|
|
||||||
"priority": "P3-LOW",
|
|
||||||
"description": "无快速文件搜索/打开功能。当打开文件夹后,应支持按文件名快速搜索打开。",
|
|
||||||
"currentState": "仅支持文件夹树浏览",
|
|
||||||
"expectedState": "Ctrl+P 快速搜索面板,模糊匹配文件名",
|
|
||||||
"effort": "large"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-07",
|
|
||||||
"title": "可访问性增强",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "部分组件缺少 ARIA 属性。Sidebar resize handle 无键盘支持。文件树展开/折叠无键盘导航。",
|
|
||||||
"currentState": "基本的 role/aria-label 已添加",
|
|
||||||
"expectedState": "完整的键盘导航、focus 管理、屏幕阅读器支持",
|
|
||||||
"effort": "medium",
|
|
||||||
"details": {
|
|
||||||
"missing": [
|
|
||||||
"resize handle 需要 role='separator' + 键盘调整",
|
|
||||||
"文件树需要 aria-expanded、aria-owns",
|
|
||||||
"右键菜单需要 role='menu' + 键盘导航",
|
|
||||||
"About 对话框需要 focus trap"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"5_developmentExperience": {
|
|
||||||
"title": "开发体验",
|
|
||||||
"priority": "MEDIUM",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "DX-01",
|
|
||||||
"title": "添加单元测试框架",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "项目零测试覆盖。核心逻辑(tabStore、fileUtils、markdown 渲染)应有单元测试。",
|
|
||||||
"currentState": "无测试文件、无测试框架",
|
|
||||||
"expectedState": "Vitest + React Testing Library,核心模块测试覆盖 > 60%",
|
|
||||||
"effort": "large",
|
|
||||||
"details": {
|
|
||||||
"framework": "vitest + @testing-library/react + jsdom",
|
|
||||||
"priority_test_targets": [
|
|
||||||
"src/renderer/stores/tabStore.ts - 标签页 CRUD 逻辑",
|
|
||||||
"src/renderer/lib/fileUtils.ts - 文件名/路径工具",
|
|
||||||
"src/renderer/lib/markdown.ts - Markdown 渲染",
|
|
||||||
"src/renderer/db/schema.ts - 数据库 schema",
|
|
||||||
"src/main/file-system.ts - 文件系统操作"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"test:coverage": "vitest run --coverage"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-02",
|
|
||||||
"title": "强化 preload 类型安全",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "types/ipc.ts 定义了 ElectronAPI 接口和 IpcInvokeMap,但 preload/index.ts 未使用这些类型约束。contextBridge.exposeInMainWorld 返回值无类型检查。",
|
|
||||||
"currentState": "preload 使用字面量对象,未引用 ElectronAPI 类型",
|
|
||||||
"expectedState": "preload 实现强制匹配 ElectronAPI 接口类型",
|
|
||||||
"effort": "small",
|
|
||||||
"details": {
|
|
||||||
"solution": "const api: ElectronAPI = { ... } 在 preload 中强制类型检查"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-03",
|
|
||||||
"title": "添加类型安全的 IPC 通信层",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "types/ipc.ts 定义了 IpcInvokeMap 但未被使用。应建立类型安全的 IPC 调用包装器。",
|
|
||||||
"currentState": "IpcInvokeMap 已定义但未使用",
|
|
||||||
"expectedState": "渲染进程通过类型安全的 invoke 函数调用 IPC",
|
|
||||||
"effort": "medium"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-04",
|
|
||||||
"title": "添加项目文档",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "无 README.md、无 CONTRIBUTING.md、无开发环境搭建文档。",
|
|
||||||
"currentState": "无文档",
|
|
||||||
"expectedState": "README.md 含项目介绍、安装步骤、开发指南、构建说明",
|
|
||||||
"effort": "small",
|
|
||||||
"details": {
|
|
||||||
"files": [
|
|
||||||
"README.md - 项目介绍、截图、安装、开发、构建",
|
|
||||||
"CONTRIBUTING.md - 贡献指南、代码规范、PR 流程",
|
|
||||||
"CHANGELOG.md - 版本变更记录"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-05",
|
|
||||||
"title": "添加 Git hooks (husky + lint-staged)",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "无 pre-commit 钩子,代码质量无法在提交时保证。",
|
|
||||||
"currentState": "无 Git hooks",
|
|
||||||
"expectedState": "husky + lint-staged: 提交前自动运行 ESLint + 类型检查",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-06",
|
|
||||||
"title": "添加 CI/CD 配置",
|
|
||||||
"priority": "P3-LOW",
|
|
||||||
"description": "无持续集成配置。应添加 GitHub Actions 或 Gitee CI 配置。",
|
|
||||||
"currentState": "无 CI 配置",
|
|
||||||
"expectedState": "CI 流水线: lint → typecheck → test → build",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DX-07",
|
|
||||||
"title": "添加路径别名到 Vite 配置",
|
|
||||||
"priority": "P3-LOW",
|
|
||||||
"description": "tsconfig.json 定义了 @renderer/*、@main/*、@shared/* 路径别名,但 electron.vite.config.ts 中 renderer 的 alias 只有 @renderer 和 @shared,缺少 @main。",
|
|
||||||
"currentState": "renderer alias 缺少 @main 映射",
|
|
||||||
"expectedState": "补全所有路径别名映射",
|
|
||||||
"effort": "trivial"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"6_security": {
|
|
||||||
"title": "安全加固",
|
|
||||||
"priority": "HIGH",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "SE-01",
|
|
||||||
"title": "CSP (Content Security Policy) 配置",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "未配置 CSP。Preview 使用 dangerouslySetInnerHTML 渲染 HTML,虽然 rehype-sanitize 做了清理,但缺少 CSP 作为第二道防线。",
|
|
||||||
"currentState": "无 CSP 配置",
|
|
||||||
"expectedState": "在 index.html 和 BrowserWindow 中配置严格 CSP",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SE-02",
|
|
||||||
"title": "Preview 中 file:// 协议安全",
|
|
||||||
"priority": "P1-HIGH",
|
|
||||||
"description": "rehypeFixImages 将相对路径图片转为 file:// URL。虽然拒绝了 '..' 但未做更严格的路径校验(如 symlink 跟踪)。",
|
|
||||||
"currentState": "仅检查 '..' 路径遍历",
|
|
||||||
"expectedState": "使用 path.resolve + 检查结果路径是否在允许目录内",
|
|
||||||
"effort": "small"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SE-03",
|
|
||||||
"title": "Electron 安全最佳实践检查",
|
|
||||||
"priority": "P2-MEDIUM",
|
|
||||||
"description": "需确认 BrowserWindow 配置中 webPreferences 的安全设置(nodeIntegration、contextIsolation、sandbox 等)。",
|
|
||||||
"currentState": "未检查 window-manager.ts 中的 webPreferences",
|
|
||||||
"expectedState": "确保 nodeIntegration=false、contextIsolation=true、sandbox=true",
|
|
||||||
"effort": "small"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"implementationPlan": {
|
|
||||||
"phase1_foundation": {
|
|
||||||
"name": "基础补全 (1-2天)",
|
|
||||||
"priority": "P0",
|
|
||||||
"items": ["CQ-01", "CQ-02", "CQ-03", "AR-05", "DX-07"],
|
|
||||||
"description": "ESLint + Prettier + EditorConfig + ErrorBoundary + 路径别名修复"
|
|
||||||
},
|
|
||||||
"phase2_criticalPerf": {
|
|
||||||
"name": "关键性能修复 (1-2天)",
|
|
||||||
"priority": "P0",
|
|
||||||
"items": ["PF-01", "PF-02", "PF-04", "PF-07", "PF-08"],
|
|
||||||
"description": "Markdown 缓存 + Zustand 选择器 + CodeMirror Compartment + 防抖"
|
|
||||||
},
|
|
||||||
"phase3_codeQuality": {
|
|
||||||
"name": "代码质量提升 (2-3天)",
|
|
||||||
"priority": "P1",
|
|
||||||
"items": ["CQ-04", "CQ-05", "AR-01", "AR-02", "AR-03", "AR-04", "PF-03"],
|
|
||||||
"description": "类型完善 + 错误处理统一 + 组件拆分 + 重复逻辑消除"
|
|
||||||
},
|
|
||||||
"phase4_userExperience": {
|
|
||||||
"name": "用户体验改进 (2-3天)",
|
|
||||||
"priority": "P1",
|
|
||||||
"items": ["UX-01", "UX-02", "UX-05", "UX-07"],
|
|
||||||
"description": "自定义确认框 + 加载状态 + Toast 改进 + 可访问性"
|
|
||||||
},
|
|
||||||
"phase5_developerExperience": {
|
|
||||||
"name": "开发体验 (2-3天)",
|
|
||||||
"priority": "P2",
|
|
||||||
"items": ["DX-01", "DX-02", "DX-04", "DX-05"],
|
|
||||||
"description": "单元测试 + 类型安全 + 文档 + Git hooks"
|
|
||||||
},
|
|
||||||
"phase6_advanced": {
|
|
||||||
"name": "高级功能 (3-5天)",
|
|
||||||
"priority": "P2-P3",
|
|
||||||
"items": ["PF-06", "UX-03", "UX-04", "UX-06", "SE-01", "SE-02", "SE-03", "DX-06"],
|
|
||||||
"description": "代码分割 + 分屏模式 + 拖拽排序 + 搜索 + 安全加固 + CI"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"riskAssessment": {
|
|
||||||
"highRisk": [
|
|
||||||
{
|
|
||||||
"id": "PF-04",
|
|
||||||
"risk": "CodeMirror Compartment 重构可能影响编辑器初始化和标签切换逻辑",
|
|
||||||
"mitigation": "保留现有逻辑作为 fallback,逐步迁移到 Compartment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "UX-03",
|
|
||||||
"risk": "分屏模式需要同时维护两个编辑器实例,内存和性能需关注",
|
|
||||||
"mitigation": "使用虚拟化、延迟加载预览面板"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mediumRisk": [
|
|
||||||
{
|
|
||||||
"id": "PF-02",
|
|
||||||
"risk": "Zustand 选择器重构可能遗漏某些订阅点",
|
|
||||||
"mitigation": "使用 React DevTools Profiler 验证重渲染情况"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SE-01",
|
|
||||||
"risk": "CSP 配置过于严格可能阻断 CodeMirror 样式注入",
|
|
||||||
"mitigation": "使用 nonce 或 hash 方式允许 CodeMirror 内联样式"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-13
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.3.12",
|
"version": "0.4.3",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,18 +28,8 @@
|
|||||||
"author": "MarkLite",
|
"author": "MarkLite",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@milkdown/core": "7.21.1",
|
"@metona-team/metona-editor": "0.1.14",
|
||||||
"@milkdown/ctx": "7.21.1",
|
"@metona-team/metona-toast": "^0.1.2",
|
||||||
"@milkdown/plugin-clipboard": "7.21.1",
|
|
||||||
"@milkdown/plugin-history": "7.21.1",
|
|
||||||
"@milkdown/plugin-indent": "7.21.1",
|
|
||||||
"@milkdown/plugin-listener": "7.21.1",
|
|
||||||
"@milkdown/plugin-trailing": "7.21.1",
|
|
||||||
"@milkdown/preset-commonmark": "7.21.1",
|
|
||||||
"@milkdown/preset-gfm": "7.21.1",
|
|
||||||
"@milkdown/prose": "7.21.1",
|
|
||||||
"@milkdown/react": "7.21.1",
|
|
||||||
"@milkdown/utils": "7.21.1",
|
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
+11
-56
@@ -3,7 +3,6 @@ import { useTabStore } from './stores/tabStore'
|
|||||||
import { flushSaveToDB } from './stores/tabStore'
|
import { flushSaveToDB } from './stores/tabStore'
|
||||||
import { useEditorStore } from './stores/editorStore'
|
import { useEditorStore } from './stores/editorStore'
|
||||||
import { useTheme } from './hooks/useTheme'
|
import { useTheme } from './hooks/useTheme'
|
||||||
import { useSettings } from './hooks/useSettings'
|
|
||||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||||
import { useKeyboard } from './hooks/useKeyboard'
|
import { useKeyboard } from './hooks/useKeyboard'
|
||||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||||
@@ -12,46 +11,18 @@ import { useFileOperations } from './hooks/useFileOperations'
|
|||||||
import { useDragDrop } from './hooks/useDragDrop'
|
import { useDragDrop } from './hooks/useDragDrop'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||||
import { useToast } from './hooks/useToast'
|
|
||||||
import { useConfirm } from './hooks/useConfirm'
|
import { useConfirm } from './hooks/useConfirm'
|
||||||
import { useDefaultStatusBarItems } from './hooks/useStatusBarItems'
|
|
||||||
import { useStatusBarItem } from './hooks/useStatusBarItem'
|
|
||||||
import { useAutoSaveStore } from './stores/autoSaveStore'
|
|
||||||
import { toggleAutoSaveExternal } from './hooks/useAutoSave'
|
|
||||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||||
import { TabBar } from './components/TabBar/TabBar'
|
import { TabBar } from './components/TabBar/TabBar'
|
||||||
import { Editor } from './components/Editor/Editor'
|
import { Editor } from './components/Editor/Editor'
|
||||||
import { SourceEditor } from './components/SourceEditor/SourceEditor'
|
|
||||||
import { Preview } from './components/Preview/Preview'
|
|
||||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
|
||||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||||
import { ToastContainer } from './components/Toast/Toast'
|
|
||||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import { AboutDialog } from './components/AboutDialog'
|
import { AboutDialog } from './components/AboutDialog'
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
||||||
|
|
||||||
/** 状态栏 — 自动保存开关组件 */
|
|
||||||
function AutoSaveStatusItem() {
|
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
|
||||||
const { isAutoSaving, autoSaveEnabled } = useAutoSaveStore()
|
|
||||||
|
|
||||||
if (!activeTab?.filePath) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
|
|
||||||
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
|
||||||
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
|
||||||
onClick={toggleAutoSaveExternal}
|
|
||||||
>
|
|
||||||
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
@@ -62,20 +33,19 @@ export function App() {
|
|||||||
const viewMode = useEditorStore(s => s.viewMode)
|
const viewMode = useEditorStore(s => s.viewMode)
|
||||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||||
const { darkMode, toggleDarkMode } = useTheme()
|
const { themeMode, cycleTheme } = useTheme()
|
||||||
const { saveViewMode } = useSettings()
|
|
||||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
|
||||||
const { confirm, confirmDialogProps } = useConfirm()
|
const { confirm, confirmDialogProps } = useConfirm()
|
||||||
const [showAbout, setShowAbout] = useState(false)
|
const [showAbout, setShowAbout] = useState(false)
|
||||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||||
|
|
||||||
useSettingsInit()
|
useSettingsInit()
|
||||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
useEffect(() => { loadFromDB() }, [loadFromDB])
|
||||||
|
|
||||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
|
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
|
||||||
useDragDrop(showToast)
|
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
|
||||||
|
useDragDrop()
|
||||||
useFileWatch()
|
useFileWatch()
|
||||||
useAutoSave()
|
// useAutoSave() already called above to get state for toolbar
|
||||||
|
|
||||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||||
@@ -92,16 +62,6 @@ export function App() {
|
|||||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||||
useIpcListeners()
|
useIpcListeners()
|
||||||
|
|
||||||
// ── 状态栏扩展点架构 ───────────────────────────────────
|
|
||||||
useDefaultStatusBarItems()
|
|
||||||
|
|
||||||
useStatusBarItem({
|
|
||||||
id: 'statusbar.auto-save',
|
|
||||||
alignment: 'right',
|
|
||||||
priority: 200,
|
|
||||||
Component: AutoSaveStatusItem
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||||
@@ -111,9 +71,6 @@ export function App() {
|
|||||||
const handleReloadModified = useCallback(async () => {
|
const handleReloadModified = useCallback(async () => {
|
||||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||||
// BUG-10: Don't overwrite editor content the user has modified since the last save.
|
|
||||||
// The watcher restart after save can trigger a false 'change' event, and re-reading
|
|
||||||
// the file at that point would replace newer editor content with the just-saved content.
|
|
||||||
if (tab?.isModified) return
|
if (tab?.isModified) return
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||||
@@ -129,9 +86,11 @@ export function App() {
|
|||||||
<div id="app" className={`mode-${viewMode}`}>
|
<div id="app" className={`mode-${viewMode}`}>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
onOpen={handleOpenFile} onSave={handleSave}
|
onOpen={handleOpenFile} onSave={handleSave}
|
||||||
viewMode={viewMode} onViewModeChange={saveViewMode}
|
themeMode={themeMode} onCycleTheme={cycleTheme}
|
||||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
|
||||||
onShowAbout={() => setShowAbout(true)}
|
onShowAbout={() => setShowAbout(true)}
|
||||||
|
isAutoSaving={isAutoSaving}
|
||||||
|
autoSaveEnabled={autoSaveEnabled}
|
||||||
|
onToggleAutoSave={toggleAutoSave}
|
||||||
/>
|
/>
|
||||||
<div id="workspace">
|
<div id="workspace">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -142,17 +101,13 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
{tabs.length > 0 ? (
|
{tabs.length > 0 ? (
|
||||||
<div id="content-wrapper">
|
<div id="content-wrapper">
|
||||||
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
|
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
|
||||||
{viewMode === 'source' && <div id="editor-panel"><SourceEditor darkMode={darkMode} /></div>}
|
|
||||||
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
|
||||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
|
||||||
<DropOverlay />
|
<DropOverlay />
|
||||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +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
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 760 KiB |
@@ -10,9 +10,9 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
|||||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (window.electronAPI?.openExternal) {
|
if (window.electronAPI?.openExternal) {
|
||||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
window.electronAPI.openExternal('https://git.metona.cn/MetonaTeam/MarkLite')
|
||||||
} else {
|
} else {
|
||||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
window.open('https://git.metona.cn/MetonaTeam/MarkLite', '_blank')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
|||||||
<div className="about-footer">
|
<div className="about-footer">
|
||||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||||
<Gitee size={16} />
|
<Gitee size={16} />
|
||||||
<span>gitee.com/thzxx/MarkLite</span>
|
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||||
</a>
|
</a>
|
||||||
<p>基于 Electron + React + TypeScript 构建</p>
|
<p>基于 Electron + React + TypeScript 构建</p>
|
||||||
<p className="about-copyright">© 2026 thzxx</p>
|
<p className="about-copyright">© 2026 thzxx</p>
|
||||||
|
|||||||
@@ -1,127 +1,207 @@
|
|||||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
import { callCommand } from '@milkdown/utils'
|
import MeEditor from '@metona-team/metona-editor'
|
||||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
import type { MarkdownEditor, MarkdownEditorOptions, EditMode, ThemeName } from '@metona-team/metona-editor'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useMilkdown } from './useMilkdown'
|
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
|
||||||
import { EditorToolbar } from './EditorToolbar'
|
import { settingsRepository } from '../../db/settingsRepository'
|
||||||
import { SearchReplace } from '../SearchReplace'
|
import { renderMarkdownSync } from '../../lib/markdown'
|
||||||
import { setEditorViewGetter } from '../../stores/editorStore'
|
import type { ThemeMode } from '../../types/settings'
|
||||||
|
|
||||||
interface EditorProps {
|
// v0.1.9 demo 验证过但类型声明未收录的配置项
|
||||||
darkMode: boolean
|
interface ExtendedEditorOptions extends MarkdownEditorOptions {
|
||||||
|
lineNumbers?: boolean
|
||||||
|
autoBrackets?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
interface EditorProps {
|
||||||
|
themeMode: ThemeMode
|
||||||
|
onAppSave?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
|
||||||
|
function mapViewMode(vm: string): EditMode {
|
||||||
|
if (vm === 'source') return 'edit'
|
||||||
|
if (vm === 'preview') return 'preview'
|
||||||
|
return 'split'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 MetonaEditor mode 反向映射到应用 viewMode */
|
||||||
|
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
|
||||||
|
if (mode === 'edit') return 'source'
|
||||||
|
if (mode === 'preview') return 'preview'
|
||||||
|
return 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预设插件(字符串形式,与 demo 一致) */
|
||||||
|
const EDITOR_PLUGINS: string[] = [
|
||||||
|
'searchReplace', // Ctrl+F/Ctrl+H
|
||||||
|
'imagePaste', // Ctrl+V 粘贴图片
|
||||||
|
'exportTool', // 导出 MD/HTML
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
|
||||||
|
*/
|
||||||
|
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
const activeTab = useTabStore(s => s.getActiveTab())
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
const setModified = useTabStore(s => s.setModified)
|
const setModified = useTabStore(s => s.setModified)
|
||||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||||
const [showSearch, setShowSearch] = useState(false)
|
const viewMode = useEditorStore(s => s.viewMode)
|
||||||
|
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const editorRef = useRef<MarkdownEditor | null>(null)
|
||||||
const currentContentRef = useRef('')
|
const currentContentRef = useRef('')
|
||||||
|
|
||||||
const {
|
// 稳定的回调引用
|
||||||
containerRef,
|
const activeTabIdRef = useRef(activeTabId)
|
||||||
action,
|
activeTabIdRef.current = activeTabId
|
||||||
setContent,
|
|
||||||
getScrollTop,
|
|
||||||
setScrollTop,
|
|
||||||
getSelection,
|
|
||||||
setSelection,
|
|
||||||
getView
|
|
||||||
} = useMilkdown({
|
|
||||||
content: activeTab?.content ?? '',
|
|
||||||
onChange: useCallback((value: string) => {
|
|
||||||
if (!activeTabId) return
|
|
||||||
// B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated),
|
|
||||||
// 避免将文档误标记为已修改
|
|
||||||
const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId)
|
|
||||||
if (tab?.content === value) return
|
|
||||||
updateTabContent(activeTabId, value)
|
|
||||||
setModified(activeTabId, true)
|
|
||||||
}, [activeTabId, updateTabContent, setModified]),
|
|
||||||
darkMode
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
// ── 初始化 MetonaEditor ──────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return
|
const container = containerRef.current
|
||||||
if (activeTab.content !== currentContentRef.current) {
|
if (!container) return
|
||||||
currentContentRef.current = activeTab.content
|
|
||||||
// Queue setContent after Milkdown editor has finished async create()
|
const config: ExtendedEditorOptions = {
|
||||||
requestAnimationFrame(() => {
|
value: activeTab?.content ?? '',
|
||||||
setContent(activeTab.content)
|
mode: mapViewMode(viewMode),
|
||||||
requestAnimationFrame(() => {
|
height: '100%',
|
||||||
setScrollTop(activeTab.scrollTop)
|
toolbar: [
|
||||||
setSelection()
|
'bold', 'italic', 'strikethrough', 'underline', 'code', '|',
|
||||||
})
|
'h1', 'h2', 'h3', '|',
|
||||||
})
|
'quote', 'ul', 'ol', 'indent', 'outdent', '|',
|
||||||
|
'link', 'image', 'table', 'hr', '|',
|
||||||
|
'undo', 'redo', '|',
|
||||||
|
'edit', 'split', 'preview', 'fullscreen'
|
||||||
|
],
|
||||||
|
locale: 'zh-CN',
|
||||||
|
theme: themeMode as ThemeName,
|
||||||
|
placeholder: '在此输入 Markdown 内容...',
|
||||||
|
spellcheck: false,
|
||||||
|
tabSize: 2,
|
||||||
|
wordCount: true,
|
||||||
|
autofocus: true,
|
||||||
|
lineNumbers: true,
|
||||||
|
autoBrackets: true,
|
||||||
|
readOnly: viewMode === 'preview',
|
||||||
|
plugins: EDITOR_PLUGINS,
|
||||||
|
|
||||||
|
// 使用 unified 管线渲染,保留图片路径修复能力
|
||||||
|
render: (md: string) => {
|
||||||
|
const tabId = activeTabIdRef.current
|
||||||
|
const filePath = tabId
|
||||||
|
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
|
||||||
|
: null
|
||||||
|
return renderMarkdownSync(md, filePath)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 内容变化 → 同步到 tabStore
|
||||||
|
onChange: (value: string) => {
|
||||||
|
const tabId = activeTabIdRef.current
|
||||||
|
if (!tabId) return
|
||||||
|
const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
|
||||||
|
if (tab?.content === value) return
|
||||||
|
currentContentRef.current = value
|
||||||
|
updateTabContent(tabId, value)
|
||||||
|
setModified(tabId, true)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 模式切换 → 同步到 editorStore 并持久化
|
||||||
|
onModeChange: (mode: EditMode) => {
|
||||||
|
const mapped = reverseMapMode(mode)
|
||||||
|
setViewMode(mapped)
|
||||||
|
settingsRepository.save({ viewMode: mapped })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
|
||||||
|
onSave: () => {
|
||||||
|
onAppSave?.()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
|
||||||
|
const editor = MeEditor.create(container, config)
|
||||||
|
|
||||||
|
editorRef.current = editor
|
||||||
|
setMetonaEditorGetter(() => editor)
|
||||||
|
currentContentRef.current = activeTab?.content ?? ''
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.destroy()
|
||||||
|
editorRef.current = null
|
||||||
|
setMetonaEditorGetter(() => null)
|
||||||
|
}
|
||||||
|
// 仅在挂载时创建一次
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── 标签切换:同步内容到编辑器 ──────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTab || !editorRef.current) return
|
||||||
|
if (activeTab.content === currentContentRef.current) return
|
||||||
|
|
||||||
|
currentContentRef.current = activeTab.content
|
||||||
|
|
||||||
|
// silent: true — 不触发 onChange,避免重复更新 tabStore
|
||||||
|
editorRef.current.setValue(activeTab.content, { silent: true })
|
||||||
|
|
||||||
|
// 恢复滚动位置
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const c = containerRef.current
|
||||||
|
if (!c) return
|
||||||
|
const textarea = c.querySelector('textarea')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.scrollTop = activeTab.scrollTop
|
||||||
|
}
|
||||||
|
const preview = c.querySelector('.me-preview') as HTMLElement | null
|
||||||
|
if (preview) {
|
||||||
|
preview.scrollTop = activeTab.scrollTop
|
||||||
|
}
|
||||||
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// Save current tab state on unmount or tab switch
|
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const c = containerRef.current
|
||||||
return () => {
|
return () => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId || !editorRef.current || !c) return
|
||||||
// Snapshot the current editor state before the new tab replaces content
|
const textarea = c.querySelector('textarea')
|
||||||
const scrollPos = getScrollTop()
|
const preview = c.querySelector('.me-preview') as HTMLElement | null
|
||||||
const sel = getSelection()
|
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
|
||||||
// Use a microtask to ensure we save before React re-renders the new tab content
|
updateTabScroll(activeTabId, { scrollTop })
|
||||||
Promise.resolve().then(() => {
|
|
||||||
updateTabScroll(activeTabId, {
|
|
||||||
scrollTop: scrollPos,
|
|
||||||
selectionStart: sel.from,
|
|
||||||
selectionEnd: sel.to
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// Register getView for OutlinePanel navigation
|
// ── 主题同步 ──────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditorViewGetter(getView)
|
try {
|
||||||
return () => setEditorViewGetter(() => null)
|
// 全局主题(documentElement + localStorage)
|
||||||
}, [getView])
|
MeEditor.setTheme(themeMode)
|
||||||
|
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
|
||||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
editorRef.current?.setTheme(themeMode)
|
||||||
useEffect(() => {
|
} catch {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
// 容错
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
|
||||||
if (isCtrl && e.key === 'b') {
|
|
||||||
e.preventDefault()
|
|
||||||
action((editor) => {
|
|
||||||
editor.action(callCommand(toggleStrongCommand.key))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (isCtrl && e.key === 'i') {
|
|
||||||
e.preventDefault()
|
|
||||||
action((editor) => {
|
|
||||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
|
||||||
e.preventDefault()
|
|
||||||
setShowSearch(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
}, [themeMode])
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [action])
|
// ── 视图模式同步 ──────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
const targetMode = mapViewMode(viewMode)
|
||||||
|
if (editor.getMode() !== targetMode) {
|
||||||
|
editor.setMode(targetMode)
|
||||||
|
}
|
||||||
|
// 预览模式设为只读
|
||||||
|
editor.setReadOnly(viewMode === 'preview')
|
||||||
|
}, [viewMode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||||
<EditorToolbar action={action} />
|
<div ref={containerRef} className="metona-editor-wrapper" />
|
||||||
{showSearch && (
|
|
||||||
<SearchReplace
|
|
||||||
getView={getView}
|
|
||||||
onClose={() => setShowSearch(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={containerRef} className="milkdown-wrapper" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import React, { useCallback } from 'react'
|
|
||||||
import type { Editor } from '@milkdown/core'
|
|
||||||
import { callCommand } from '@milkdown/utils'
|
|
||||||
import type { $Command } from '@milkdown/utils'
|
|
||||||
import {
|
|
||||||
toggleStrongCommand,
|
|
||||||
toggleEmphasisCommand,
|
|
||||||
wrapInHeadingCommand,
|
|
||||||
wrapInBlockquoteCommand,
|
|
||||||
wrapInBulletListCommand,
|
|
||||||
wrapInOrderedListCommand,
|
|
||||||
createCodeBlockCommand,
|
|
||||||
toggleInlineCodeCommand,
|
|
||||||
insertImageCommand,
|
|
||||||
toggleLinkCommand,
|
|
||||||
insertHrCommand
|
|
||||||
} from '@milkdown/preset-commonmark'
|
|
||||||
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
|
||||||
action: (fn: (editor: Editor) => void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
|
|
||||||
const exec = useCallback(<T,>(command: $Command<T>, payload?: T) => {
|
|
||||||
action((editor) => {
|
|
||||||
try {
|
|
||||||
editor.action(callCommand(command.key, payload))
|
|
||||||
} catch {
|
|
||||||
// command may not be registered
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [action])
|
|
||||||
|
|
||||||
const handleHeading = useCallback((level: number) => {
|
|
||||||
exec(wrapInHeadingCommand, level)
|
|
||||||
}, [exec])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(toggleStrongCommand)}
|
|
||||||
title="粗体 (Ctrl+B)"
|
|
||||||
aria-label="粗体"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(toggleEmphasisCommand)}
|
|
||||||
title="斜体 (Ctrl+I)"
|
|
||||||
aria-label="斜体"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(toggleStrikethroughCommand)}
|
|
||||||
title="删除线"
|
|
||||||
aria-label="删除线"
|
|
||||||
>
|
|
||||||
<s>S</s>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(toggleInlineCodeCommand)}
|
|
||||||
title="行内代码"
|
|
||||||
aria-label="行内代码"
|
|
||||||
>
|
|
||||||
{'</>'}
|
|
||||||
</button>
|
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => handleHeading(1)}
|
|
||||||
title="标题"
|
|
||||||
aria-label="一级标题"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => handleHeading(2)}
|
|
||||||
title="二级标题"
|
|
||||||
aria-label="二级标题"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => handleHeading(3)}
|
|
||||||
title="三级标题"
|
|
||||||
aria-label="三级标题"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(wrapInBulletListCommand)}
|
|
||||||
title="无序列表"
|
|
||||||
aria-label="无序列表"
|
|
||||||
>
|
|
||||||
•≡
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(wrapInOrderedListCommand)}
|
|
||||||
title="有序列表"
|
|
||||||
aria-label="有序列表"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(wrapInBlockquoteCommand)}
|
|
||||||
title="引用"
|
|
||||||
aria-label="引用"
|
|
||||||
>
|
|
||||||
❝
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(createCodeBlockCommand)}
|
|
||||||
title="代码块"
|
|
||||||
aria-label="代码块"
|
|
||||||
>
|
|
||||||
{'{ }'}
|
|
||||||
</button>
|
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
|
|
||||||
title="链接"
|
|
||||||
aria-label="插入链接"
|
|
||||||
>
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
|
|
||||||
title="图片"
|
|
||||||
aria-label="插入图片"
|
|
||||||
>
|
|
||||||
🖼
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="toolbar-btn-sm"
|
|
||||||
onClick={() => exec(insertHrCommand)}
|
|
||||||
title="分割线"
|
|
||||||
aria-label="插入分割线"
|
|
||||||
>
|
|
||||||
—
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
EditorToolbar.displayName = 'EditorToolbar'
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react'
|
|
||||||
import {
|
|
||||||
Editor,
|
|
||||||
rootCtx,
|
|
||||||
defaultValueCtx,
|
|
||||||
editorViewCtx,
|
|
||||||
prosePluginsCtx
|
|
||||||
} from '@milkdown/core'
|
|
||||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
|
||||||
import { commonmark } from '@milkdown/preset-commonmark'
|
|
||||||
import { gfm } from '@milkdown/preset-gfm'
|
|
||||||
import { history } from '@milkdown/plugin-history'
|
|
||||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
|
||||||
import { indent } from '@milkdown/plugin-indent'
|
|
||||||
import { trailing } from '@milkdown/plugin-trailing'
|
|
||||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
|
||||||
import { Plugin, PluginKey, TextSelection } from '@milkdown/prose/state'
|
|
||||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
|
||||||
import type { EditorState } from '@milkdown/prose/state'
|
|
||||||
|
|
||||||
// --- Auto-pair plugin (D2) ---
|
|
||||||
|
|
||||||
const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
|
|
||||||
|
|
||||||
function createAutoPairPlugin(): Plugin {
|
|
||||||
return new Plugin({
|
|
||||||
props: {
|
|
||||||
handleTextInput(view, from, to, text) {
|
|
||||||
// 选中文本时用配对符号包裹
|
|
||||||
if (from !== to && PAIRS[text]) {
|
|
||||||
const close = PAIRS[text]
|
|
||||||
const { tr } = view.state
|
|
||||||
tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
handleKeyDown(view, event) {
|
|
||||||
const char = event.key
|
|
||||||
|
|
||||||
// Backspace: 删除配对符号(光标在两个配对符号中间时)
|
|
||||||
// 必须在 PAIRS 检查之前处理,因为 'Backspace' 不在 PAIRS 映射中
|
|
||||||
if (char === 'Backspace') {
|
|
||||||
const { state } = view
|
|
||||||
const { from, to } = state.selection
|
|
||||||
if (from !== to || from < 2) return false
|
|
||||||
const before = state.doc.textBetween(from - 1, from)
|
|
||||||
const after = state.doc.textBetween(from, from + 1)
|
|
||||||
if (PAIRS[before] === after) {
|
|
||||||
const tr = state.tr.delete(from - 1, from + 1)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理配对符号的自动闭合
|
|
||||||
if (!PAIRS[char]) return false
|
|
||||||
|
|
||||||
const { state } = view
|
|
||||||
const { from, to } = state.selection
|
|
||||||
|
|
||||||
if (from !== to) {
|
|
||||||
// 有选区时:包裹
|
|
||||||
const close = PAIRS[char]
|
|
||||||
const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无选区:插入配对并光标置中
|
|
||||||
const close = PAIRS[char]
|
|
||||||
const tr = state.tr.insertText(char + close, from)
|
|
||||||
// 光标置于中间
|
|
||||||
tr.setSelection(
|
|
||||||
TextSelection.create(tr.doc, from + 1)
|
|
||||||
)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search highlight plugin ---
|
|
||||||
|
|
||||||
export interface SearchMatch {
|
|
||||||
from: number
|
|
||||||
to: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchPluginState {
|
|
||||||
matches: SearchMatch[]
|
|
||||||
currentIndex: number
|
|
||||||
query: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
|
||||||
|
|
||||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
|
||||||
return new Plugin<SearchPluginState>({
|
|
||||||
key: searchPluginKey,
|
|
||||||
state: {
|
|
||||||
init(): SearchPluginState {
|
|
||||||
return { matches: [], currentIndex: -1, query: '' }
|
|
||||||
},
|
|
||||||
apply(tr, value): SearchPluginState {
|
|
||||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
|
||||||
if (meta) return meta
|
|
||||||
// When the document changes, map existing match positions through the change
|
|
||||||
// but only if there's an active search
|
|
||||||
if (tr.docChanged && value.query) {
|
|
||||||
// The SearchReplace component will re-search when doc changes,
|
|
||||||
// but we map positions so decorations stay roughly correct in the interim
|
|
||||||
return { ...value }
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state: EditorState) {
|
|
||||||
const pluginState = searchPluginKey.getState(state)
|
|
||||||
if (!pluginState || pluginState.matches.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
|
||||||
const cls =
|
|
||||||
index === pluginState.currentIndex
|
|
||||||
? 'search-match-active'
|
|
||||||
: 'search-match-highlight'
|
|
||||||
return Decoration.inline(match.from, match.to, { class: cls })
|
|
||||||
})
|
|
||||||
|
|
||||||
return DecorationSet.create(state.doc, decos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Hook ---
|
|
||||||
|
|
||||||
interface UseMilkdownOptions {
|
|
||||||
content: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
darkMode: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const editorRef = useRef<Editor | null>(null)
|
|
||||||
const onChangeRef = useRef(onChange)
|
|
||||||
const isExternalUpdate = useRef(false)
|
|
||||||
const initialContentRef = useRef(content)
|
|
||||||
// H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件
|
|
||||||
const setContentRequestId = useRef(0)
|
|
||||||
|
|
||||||
// Keep onChangeRef fresh
|
|
||||||
useEffect(() => {
|
|
||||||
onChangeRef.current = onChange
|
|
||||||
}, [onChange])
|
|
||||||
|
|
||||||
// Initialize editor
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return
|
|
||||||
|
|
||||||
const editor = Editor.make()
|
|
||||||
.config((ctx) => {
|
|
||||||
ctx.set(rootCtx, containerRef.current!)
|
|
||||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
|
||||||
|
|
||||||
// Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
|
|
||||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
|
|
||||||
|
|
||||||
// Configure listener for content changes
|
|
||||||
const lm = ctx.get(listenerCtx)
|
|
||||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
|
||||||
if (markdown === prevMarkdown) return
|
|
||||||
// H-03: 只有未被外部更新抑制时才触发 onChange。
|
|
||||||
// 计数器匹配确保用户按键不会在 setContent 期间被丢弃。
|
|
||||||
if (setContentRequestId.current === 0 && !isExternalUpdate.current) {
|
|
||||||
onChangeRef.current(markdown)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for blur events (used for potential future state saving)
|
|
||||||
lm.blur(() => {
|
|
||||||
// blur handler placeholder
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.use(commonmark)
|
|
||||||
.use(gfm)
|
|
||||||
.use(history)
|
|
||||||
.use(listener)
|
|
||||||
.use(indent)
|
|
||||||
.use(trailing)
|
|
||||||
.use(clipboard)
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
editor.create().then((created) => {
|
|
||||||
if (cancelled) {
|
|
||||||
// 组件已卸载,立即销毁以避免内存泄漏
|
|
||||||
created.destroy()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editorRef.current = created
|
|
||||||
}).catch((err) => {
|
|
||||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
|
||||||
console.error('Milkdown editor creation failed:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
editorRef.current?.destroy()
|
|
||||||
editorRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Dark mode: update CSS custom properties on the container
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!container) return
|
|
||||||
if (darkMode) {
|
|
||||||
container.classList.add('milkdown-dark')
|
|
||||||
} else {
|
|
||||||
container.classList.remove('milkdown-dark')
|
|
||||||
}
|
|
||||||
}, [darkMode])
|
|
||||||
|
|
||||||
// External content update (tab switch)
|
|
||||||
const setContent = useCallback((newContent: string) => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
|
|
||||||
// H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制
|
|
||||||
const requestId = ++setContentRequestId.current
|
|
||||||
isExternalUpdate.current = true
|
|
||||||
try {
|
|
||||||
editor.action(milkdownReplaceAll(newContent))
|
|
||||||
} catch {
|
|
||||||
// replaceAll may fail if editor is not fully ready
|
|
||||||
} finally {
|
|
||||||
// 仅当没有新的 setContent 启动时才重置标志
|
|
||||||
if (setContentRequestId.current === requestId) {
|
|
||||||
setContentRequestId.current = 0
|
|
||||||
isExternalUpdate.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Scroll position management
|
|
||||||
const getScrollTop = useCallback((): number => {
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!container) return 0
|
|
||||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
|
||||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setScrollTop = useCallback((top: number) => {
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!container) return
|
|
||||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
|
||||||
if (scroller) {
|
|
||||||
scroller.scrollTop = top
|
|
||||||
} else {
|
|
||||||
container.scrollTop = top
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Selection management
|
|
||||||
const getSelection = useCallback((): { from: number; to: number } => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return { from: 0, to: 0 }
|
|
||||||
try {
|
|
||||||
return editor.action((ctx) => {
|
|
||||||
const view = ctx.get(editorViewCtx)
|
|
||||||
if (view?.state?.selection) {
|
|
||||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
|
||||||
}
|
|
||||||
return { from: 0, to: 0 }
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return { from: 0, to: 0 }
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setSelection = useCallback(() => {
|
|
||||||
// ProseMirror selection setting requires the view instance
|
|
||||||
// which we access lazily; for now we focus the editor
|
|
||||||
const container = containerRef.current
|
|
||||||
if (!container) return
|
|
||||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
|
||||||
pmEditor?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Get ProseMirror EditorView instance
|
|
||||||
const getView = useCallback(() => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return null
|
|
||||||
try {
|
|
||||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Execute a Milkdown action (for toolbar commands)
|
|
||||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
if (!editor) return
|
|
||||||
fn(editor)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerRef,
|
|
||||||
editorRef,
|
|
||||||
action,
|
|
||||||
setContent,
|
|
||||||
getScrollTop,
|
|
||||||
setScrollTop,
|
|
||||||
getSelection,
|
|
||||||
setSelection,
|
|
||||||
getView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import appIconUrl from '../assets/icon.png'
|
||||||
|
|
||||||
// 统一图标 Props
|
// 统一图标 Props
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
@@ -9,31 +10,10 @@ interface IconProps {
|
|||||||
|
|
||||||
const defaultProps: Partial<IconProps> = { size: 18 }
|
const defaultProps: Partial<IconProps> = { size: 18 }
|
||||||
|
|
||||||
// ===== 应用图标(现代化简洁风格) =====
|
// ===== 应用图标 =====
|
||||||
export function AppIcon({ size = 80 }: IconProps) {
|
export function AppIcon({ size = 80 }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
|
||||||
<defs>
|
|
||||||
<linearGradient id="app-bg" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#4F8EF7"/>
|
|
||||||
<stop offset="100%" stopColor="#2563EB"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="app-highlight" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stopColor="white" stopOpacity="0.15"/>
|
|
||||||
<stop offset="50%" stopColor="white" stopOpacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
{/* 圆角矩形背景 */}
|
|
||||||
<rect x="4" y="4" width="72" height="72" rx="16" fill="url(#app-bg)"/>
|
|
||||||
{/* 左上角高光 */}
|
|
||||||
<rect x="4" y="4" width="72" height="72" rx="16" fill="url(#app-highlight)"/>
|
|
||||||
{/* MD 文字 */}
|
|
||||||
<text x="40" y="46" textAnchor="middle" dominantBaseline="central"
|
|
||||||
fontFamily="Georgia, 'Times New Roman', serif" fontWeight="bold" fontSize="24"
|
|
||||||
fill="white" letterSpacing="1.5">MD</text>
|
|
||||||
{/* 底部装饰线 */}
|
|
||||||
<rect x="20" y="58" width="40" height="2.5" rx="1.25" fill="white" fillOpacity="0.25"/>
|
|
||||||
</svg>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,35 +38,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditMode({ size = defaultProps.size }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
||||||
<circle cx="18" cy="6" r="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PreviewMode({ size = defaultProps.size }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<circle cx="12" cy="12" r="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceMode({ size = defaultProps.size }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="16 18 22 12 16 6"/>
|
|
||||||
<polyline points="8 6 2 12 8 18"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Moon({ size = defaultProps.size }: IconProps) {
|
export function Moon({ size = defaultProps.size }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
|
||||||
import { useEditorStore } from '../../stores/editorStore'
|
|
||||||
import { renderMarkdown } from '../../lib/markdown'
|
|
||||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
|
||||||
|
|
||||||
// PF-07: 防抖延迟常量
|
|
||||||
const PREVIEW_DEBOUNCE_MS = 150
|
|
||||||
|
|
||||||
export const Preview = React.memo(function Preview() {
|
|
||||||
const [html, setHtml] = useState('')
|
|
||||||
const [rendering, setRendering] = useState(false)
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
|
||||||
const requestIdRef = useRef(0)
|
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
|
||||||
const setLoading = useEditorStore(s => s.setLoading)
|
|
||||||
|
|
||||||
// PF-07: 响应式渲染 + 防抖
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeTab) {
|
|
||||||
setHtml('')
|
|
||||||
setRendering(false)
|
|
||||||
setLoading('markdown-render', false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
setRendering(true)
|
|
||||||
setLoading('markdown-render', true)
|
|
||||||
|
|
||||||
debounceTimerRef.current = setTimeout(() => {
|
|
||||||
const requestId = ++requestIdRef.current
|
|
||||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setHtml(result)
|
|
||||||
setRendering(false)
|
|
||||||
setLoading('markdown-render', false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, PREVIEW_DEBOUNCE_MS)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
|
||||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
|
||||||
|
|
||||||
// 拦截链接点击
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
const link = (e.target as HTMLElement).closest('a')
|
|
||||||
if (!link) return
|
|
||||||
e.preventDefault()
|
|
||||||
const href: string | null = link.getAttribute('href')
|
|
||||||
if (!href) return
|
|
||||||
if (href.startsWith('#')) {
|
|
||||||
const target = previewRef.current?.querySelector(href)
|
|
||||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(href)
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (window.electronAPI?.openExternal) {
|
|
||||||
window.electronAPI.openExternal(href)
|
|
||||||
} else {
|
|
||||||
window.open(href, '_blank', 'noopener')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={previewRef}
|
|
||||||
id="preview"
|
|
||||||
className="markdown-body"
|
|
||||||
role="region"
|
|
||||||
aria-label="Markdown预览"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-busy={rendering}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
{rendering && html === '' && (
|
|
||||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
|
||||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Preview.displayName = 'Preview'
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, memo } from 'react'
|
|
||||||
import type { EditorView } from '@milkdown/prose/view'
|
|
||||||
import { TextSelection } from '@milkdown/prose/state'
|
|
||||||
import type { Node as ProseMirrorNode } from '@milkdown/prose/model'
|
|
||||||
import { searchPluginKey, type SearchMatch, type SearchPluginState } from '../Editor/useMilkdown'
|
|
||||||
|
|
||||||
interface SearchReplaceProps {
|
|
||||||
/** 获取 ProseMirror EditorView 实例 */
|
|
||||||
getView: () => EditorView | null
|
|
||||||
/** 关闭面板 */
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 ProseMirror 文档中查找所有匹配位置
|
|
||||||
* D6: 支持正则表达式 + 大小写敏感
|
|
||||||
*/
|
|
||||||
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] {
|
|
||||||
const matches: SearchMatch[] = []
|
|
||||||
if (!query) return matches
|
|
||||||
|
|
||||||
if (useRegex) {
|
|
||||||
let regex: RegExp
|
|
||||||
try {
|
|
||||||
const flags = caseSensitive ? 'g' : 'gi'
|
|
||||||
regex = new RegExp(query, flags)
|
|
||||||
} catch {
|
|
||||||
// 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (!node.isText) return true
|
|
||||||
const text = node.text || ''
|
|
||||||
regex.lastIndex = 0
|
|
||||||
let result: RegExpExecArray | null
|
|
||||||
while ((result = regex.exec(text)) !== null) {
|
|
||||||
matches.push({ from: pos + result.index, to: pos + result.index + result[0].length })
|
|
||||||
if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
// 普通字符串匹配
|
|
||||||
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (!node.isText) return true
|
|
||||||
const text = node.text || ''
|
|
||||||
const searchText = caseSensitive ? text : text.toLowerCase()
|
|
||||||
let offset = 0
|
|
||||||
let idx: number
|
|
||||||
while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
|
|
||||||
matches.push({ from: pos + idx, to: pos + idx + query.length })
|
|
||||||
offset = idx + 1
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动到指定文档位置
|
|
||||||
*/
|
|
||||||
function scrollToPos(view: EditorView, pos: number) {
|
|
||||||
try {
|
|
||||||
const { node } = view.domAtPos(pos)
|
|
||||||
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
|
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
} catch {
|
|
||||||
// Position may be out of range
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 ProseMirror plugin state(触发 decorations 重绘)
|
|
||||||
*/
|
|
||||||
function dispatchSearchState(view: EditorView, state: SearchPluginState) {
|
|
||||||
const tr = view.state.tr.setMeta(searchPluginKey, state)
|
|
||||||
view.dispatch(tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除搜索高亮(dispatch 空状态)
|
|
||||||
*/
|
|
||||||
function clearSearchDecorations(view: EditorView) {
|
|
||||||
dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SearchReplace — 编辑器内搜索/替换面板
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - Ctrl+F 打开搜索,Ctrl+H 打开替换
|
|
||||||
* - 大小写敏感切换
|
|
||||||
* - Enter 下一个,Shift+Enter 上一个
|
|
||||||
* - 替换当前匹配 / 全部替换
|
|
||||||
* - ESC 关闭面板
|
|
||||||
*/
|
|
||||||
export const SearchReplace = memo(function SearchReplace({
|
|
||||||
getView,
|
|
||||||
onClose
|
|
||||||
}: SearchReplaceProps) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [replacement, setReplacement] = useState('')
|
|
||||||
const [showReplace, setShowReplace] = useState(false)
|
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
|
||||||
const [useRegex, setUseRegex] = useState(false)
|
|
||||||
const [regexError, setRegexError] = useState(false)
|
|
||||||
const [matchCount, setMatchCount] = useState(0)
|
|
||||||
const [currentMatch, setCurrentMatch] = useState(-1)
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Keep latest state in refs for use in callbacks without stale closures
|
|
||||||
const matchesRef = useRef<SearchMatch[]>([])
|
|
||||||
const currentIndexRef = useRef(-1)
|
|
||||||
const caseSensitiveRef = useRef(false)
|
|
||||||
const useRegexRef = useRef(false)
|
|
||||||
const queryRef = useRef('')
|
|
||||||
|
|
||||||
// Sync refs
|
|
||||||
caseSensitiveRef.current = caseSensitive
|
|
||||||
useRegexRef.current = useRegex
|
|
||||||
queryRef.current = query
|
|
||||||
|
|
||||||
// 执行搜索
|
|
||||||
const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => {
|
|
||||||
const view = getView()
|
|
||||||
if (!view) return
|
|
||||||
|
|
||||||
// D6: 检查正则语法
|
|
||||||
setRegexError(false)
|
|
||||||
if (rx && searchQuery.trim()) {
|
|
||||||
try {
|
|
||||||
new RegExp(searchQuery, 'g')
|
|
||||||
} catch {
|
|
||||||
setRegexError(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchQuery.trim()) {
|
|
||||||
clearSearchDecorations(view)
|
|
||||||
matchesRef.current = []
|
|
||||||
currentIndexRef.current = -1
|
|
||||||
setMatchCount(0)
|
|
||||||
setCurrentMatch(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = findMatches(view.state.doc, searchQuery, cs, rx)
|
|
||||||
matchesRef.current = matches
|
|
||||||
const newIdx = matches.length > 0 ? 0 : -1
|
|
||||||
currentIndexRef.current = newIdx
|
|
||||||
|
|
||||||
setMatchCount(matches.length)
|
|
||||||
setCurrentMatch(newIdx)
|
|
||||||
|
|
||||||
// Dispatch to ProseMirror plugin to render decorations
|
|
||||||
dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery })
|
|
||||||
|
|
||||||
// Scroll to first match
|
|
||||||
if (matches.length > 0) {
|
|
||||||
scrollToPos(view, matches[0].from)
|
|
||||||
}
|
|
||||||
}, [getView])
|
|
||||||
|
|
||||||
// 搜索框输入变化
|
|
||||||
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value
|
|
||||||
setQuery(value)
|
|
||||||
doSearch(value, caseSensitiveRef.current, useRegexRef.current)
|
|
||||||
}, [doSearch])
|
|
||||||
|
|
||||||
// 大小写切换
|
|
||||||
const toggleCaseSensitive = useCallback(() => {
|
|
||||||
const newCS = !caseSensitiveRef.current
|
|
||||||
setCaseSensitive(newCS)
|
|
||||||
doSearch(queryRef.current, newCS, useRegexRef.current)
|
|
||||||
}, [doSearch])
|
|
||||||
|
|
||||||
// 正则切换
|
|
||||||
const toggleRegex = useCallback(() => {
|
|
||||||
const newRx = !useRegexRef.current
|
|
||||||
setUseRegex(newRx)
|
|
||||||
doSearch(queryRef.current, caseSensitiveRef.current, newRx)
|
|
||||||
}, [doSearch])
|
|
||||||
|
|
||||||
// 导航到下一个/上一个匹配
|
|
||||||
const navigateMatch = useCallback((direction: 1 | -1) => {
|
|
||||||
const view = getView()
|
|
||||||
if (!view) return
|
|
||||||
|
|
||||||
const matches = matchesRef.current
|
|
||||||
if (matches.length === 0) return
|
|
||||||
|
|
||||||
let next = currentIndexRef.current + direction
|
|
||||||
if (next >= matches.length) next = 0
|
|
||||||
if (next < 0) next = matches.length - 1
|
|
||||||
|
|
||||||
currentIndexRef.current = next
|
|
||||||
setCurrentMatch(next)
|
|
||||||
|
|
||||||
// Update plugin state to move the active highlight
|
|
||||||
dispatchSearchState(view, {
|
|
||||||
matches,
|
|
||||||
currentIndex: next,
|
|
||||||
query: queryRef.current
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scroll to match and select it
|
|
||||||
const match = matches[next]
|
|
||||||
try {
|
|
||||||
const tr = view.state.tr.setSelection(
|
|
||||||
TextSelection.create(view.state.doc, match.from, match.to)
|
|
||||||
)
|
|
||||||
view.dispatch(tr)
|
|
||||||
} catch {
|
|
||||||
// Selection range might be invalid
|
|
||||||
}
|
|
||||||
scrollToPos(view, match.from)
|
|
||||||
}, [getView])
|
|
||||||
|
|
||||||
// 替换当前匹配
|
|
||||||
const replaceCurrent = useCallback(() => {
|
|
||||||
const view = getView()
|
|
||||||
if (!view) return
|
|
||||||
|
|
||||||
const matches = matchesRef.current
|
|
||||||
const idx = currentIndexRef.current
|
|
||||||
if (idx < 0 || idx >= matches.length) return
|
|
||||||
|
|
||||||
const match = matches[idx]
|
|
||||||
|
|
||||||
// Replace text in ProseMirror document
|
|
||||||
const tr = view.state.tr.insertText(replacement, match.from, match.to)
|
|
||||||
view.dispatch(tr)
|
|
||||||
|
|
||||||
// Re-search after replacement (document changed)
|
|
||||||
// Use requestAnimationFrame to let ProseMirror process the transaction
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
|
||||||
})
|
|
||||||
}, [getView, replacement, doSearch])
|
|
||||||
|
|
||||||
// 全部替换
|
|
||||||
const replaceAll = useCallback(() => {
|
|
||||||
const view = getView()
|
|
||||||
if (!view) return
|
|
||||||
|
|
||||||
// 重新搜索以获取匹配的最新位置
|
|
||||||
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
|
||||||
if (freshMatches.length === 0) return
|
|
||||||
if (freshMatches.length === 0) return
|
|
||||||
|
|
||||||
// 从后往前替换以保持位置正确
|
|
||||||
const tr = view.state.tr
|
|
||||||
for (let i = freshMatches.length - 1; i >= 0; i--) {
|
|
||||||
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
|
|
||||||
}
|
|
||||||
view.dispatch(tr)
|
|
||||||
|
|
||||||
// 更新ref
|
|
||||||
matchesRef.current = []
|
|
||||||
|
|
||||||
// 替换后重新搜索
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
|
|
||||||
})
|
|
||||||
}, [getView, replacement, doSearch])
|
|
||||||
|
|
||||||
// 键盘事件
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
const view = getView()
|
|
||||||
if (view) clearSearchDecorations(view)
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.shiftKey) {
|
|
||||||
navigateMatch(-1)
|
|
||||||
} else {
|
|
||||||
navigateMatch(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [onClose, navigateMatch, getView])
|
|
||||||
|
|
||||||
// 全局 Ctrl+F / Ctrl+H 拦截
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalKey = (e: KeyboardEvent) => {
|
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
|
||||||
if (isCtrl && e.key === 'f') {
|
|
||||||
e.preventDefault()
|
|
||||||
setShowReplace(false)
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
searchInputRef.current?.select()
|
|
||||||
}
|
|
||||||
if (isCtrl && e.key === 'h') {
|
|
||||||
e.preventDefault()
|
|
||||||
setShowReplace(true)
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
searchInputRef.current?.select()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleGlobalKey)
|
|
||||||
return () => document.removeEventListener('keydown', handleGlobalKey)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 打开时聚焦搜索框
|
|
||||||
useEffect(() => {
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
searchInputRef.current?.select()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 清理高亮(组件卸载时)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
const view = getView()
|
|
||||||
if (view) clearSearchDecorations(view)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
className="search-replace-panel"
|
|
||||||
role="search"
|
|
||||||
aria-label="搜索和替换"
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div className="search-row">
|
|
||||||
<div className="search-input-group">
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="搜索..."
|
|
||||||
value={query}
|
|
||||||
onChange={handleQueryChange}
|
|
||||||
aria-label="搜索文本"
|
|
||||||
/>
|
|
||||||
<span className="search-count" aria-live="polite">
|
|
||||||
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={`search-btn ${caseSensitive ? 'active' : ''}`}
|
|
||||||
onClick={toggleCaseSensitive}
|
|
||||||
title="区分大小写 (点击切换)"
|
|
||||||
aria-label="区分大小写"
|
|
||||||
aria-pressed={caseSensitive}
|
|
||||||
>
|
|
||||||
Aa
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`search-btn ${useRegex ? 'active' : ''}`}
|
|
||||||
onClick={toggleRegex}
|
|
||||||
title="使用正则表达式 (点击切换)"
|
|
||||||
aria-label="正则表达式"
|
|
||||||
aria-pressed={useRegex}
|
|
||||||
>
|
|
||||||
.*
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="search-btn"
|
|
||||||
onClick={() => navigateMatch(-1)}
|
|
||||||
title="上一个 (Shift+Enter)"
|
|
||||||
aria-label="上一个匹配"
|
|
||||||
disabled={matchCount === 0}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="search-btn"
|
|
||||||
onClick={() => navigateMatch(1)}
|
|
||||||
title="下一个 (Enter)"
|
|
||||||
aria-label="下一个匹配"
|
|
||||||
disabled={matchCount === 0}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="search-btn"
|
|
||||||
onClick={() => setShowReplace(!showReplace)}
|
|
||||||
title="切换替换模式 (Ctrl+H)"
|
|
||||||
aria-label="替换模式"
|
|
||||||
aria-expanded={showReplace}
|
|
||||||
>
|
|
||||||
⇄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="search-btn search-close"
|
|
||||||
onClick={() => {
|
|
||||||
const view = getView()
|
|
||||||
if (view) clearSearchDecorations(view)
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
title="关闭 (Esc)"
|
|
||||||
aria-label="关闭搜索"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showReplace && (
|
|
||||||
<div className="search-row">
|
|
||||||
<div className="search-input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="替换..."
|
|
||||||
value={replacement}
|
|
||||||
onChange={e => setReplacement(e.target.value)}
|
|
||||||
aria-label="替换文本"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="search-btn replace-btn"
|
|
||||||
onClick={replaceCurrent}
|
|
||||||
title="替换当前"
|
|
||||||
aria-label="替换当前匹配"
|
|
||||||
disabled={currentMatch < 0}
|
|
||||||
>
|
|
||||||
替换
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="search-btn replace-btn"
|
|
||||||
onClick={replaceAll}
|
|
||||||
title="全部替换"
|
|
||||||
aria-label="替换全部匹配"
|
|
||||||
disabled={matchCount === 0}
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
SearchReplace.displayName = 'SearchReplace'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SearchReplace } from './SearchReplace'
|
|
||||||
@@ -11,11 +11,14 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
|||||||
import { useActiveHeading } from '../../hooks/useActiveHeading'
|
import { useActiveHeading } from '../../hooks/useActiveHeading'
|
||||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||||
import type { Heading } from '../OutlinePanel'
|
import type { Heading } from '../OutlinePanel'
|
||||||
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
|
||||||
import { TextSelection } from '@milkdown/prose/state'
|
|
||||||
|
|
||||||
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
export const Sidebar = React.memo(function Sidebar() {
|
export const Sidebar = React.memo(function Sidebar() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
@@ -43,12 +46,11 @@ export const Sidebar = React.memo(function Sidebar() {
|
|||||||
return parseHeadings(activeTab.content)
|
return parseHeadings(activeTab.content)
|
||||||
}, [activeTab?.content])
|
}, [activeTab?.content])
|
||||||
|
|
||||||
// D4: 追踪预览面板中的活跃标题
|
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview)
|
||||||
const previewRef = useRef<HTMLElement | null>(null)
|
const previewRef = useRef<HTMLElement | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 仅在预览模式时获取 preview DOM 节点
|
|
||||||
if (viewMode === 'preview') {
|
if (viewMode === 'preview') {
|
||||||
previewRef.current = document.getElementById('preview')
|
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
|
||||||
} else {
|
} else {
|
||||||
previewRef.current = null
|
previewRef.current = null
|
||||||
}
|
}
|
||||||
@@ -59,37 +61,40 @@ export const Sidebar = React.memo(function Sidebar() {
|
|||||||
headings
|
headings
|
||||||
)
|
)
|
||||||
|
|
||||||
// Navigate to heading in ProseMirror document tree
|
// Navigate to heading in MetonaEditor
|
||||||
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
|
const handleHeadingNavigate = useCallback((heading: Heading) => {
|
||||||
const view = getEditorView()
|
const editor = getMetonaEditor()
|
||||||
if (!view) return
|
if (!editor) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let foundPos: number | null = null
|
// 获取当前内容,查找标题文本在源代码中的位置
|
||||||
let currentIndex = 0
|
const content = editor.getValue()
|
||||||
|
const headingPattern = new RegExp(
|
||||||
|
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
|
||||||
|
'm'
|
||||||
|
)
|
||||||
|
const match = headingPattern.exec(content)
|
||||||
|
if (!match) return
|
||||||
|
|
||||||
view.state.doc.descendants((node, pos) => {
|
const pos = match.index
|
||||||
if (foundPos !== null) return false
|
|
||||||
if (node.type.name === 'heading' && node.attrs.level === heading.level) {
|
|
||||||
if (node.textContent.trim() === heading.text) {
|
|
||||||
if (currentIndex === index) {
|
|
||||||
foundPos = pos
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
currentIndex++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (foundPos === null) return
|
// 通过 DOM 操作滚动 textarea 到对应位置
|
||||||
|
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
const tr = view.state.tr
|
const textarea = container.querySelector('textarea')
|
||||||
.setSelection(TextSelection.create(view.state.doc, foundPos, foundPos))
|
if (!textarea) return
|
||||||
.scrollIntoView()
|
|
||||||
view.dispatch(tr)
|
// 估算滚动位置(简单方法:按行数比例)
|
||||||
view.focus()
|
const linesBefore = content.substring(0, pos).split('\n').length
|
||||||
|
const lineHeight = 24 // 估算行高
|
||||||
|
textarea.scrollTop = linesBefore * lineHeight
|
||||||
|
|
||||||
|
// 设置光标位置
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(pos, pos)
|
||||||
} catch {
|
} catch {
|
||||||
// Position may be invalid if content has changed
|
// 导航失败,静默忽略
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react'
|
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
|
||||||
|
|
||||||
interface SourceEditorProps {
|
|
||||||
darkMode: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
|
|
||||||
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
|
|
||||||
*
|
|
||||||
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
|
|
||||||
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
|
|
||||||
*/
|
|
||||||
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
|
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
|
||||||
const setModified = useTabStore(s => s.setModified)
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
|
|
||||||
const pendingSelectionRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingSelectionRef.current === null) return
|
|
||||||
const textarea = textareaRef.current
|
|
||||||
if (!textarea) return
|
|
||||||
const pos = pendingSelectionRef.current
|
|
||||||
// microtask:在 React 完成 DOM 更新后恢复光标
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textarea.selectionStart = pos
|
|
||||||
textarea.selectionEnd = pos
|
|
||||||
})
|
|
||||||
pendingSelectionRef.current = null
|
|
||||||
}, [activeTab?.content])
|
|
||||||
|
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (!activeTabId) return
|
|
||||||
updateTabContent(activeTabId, e.target.value)
|
|
||||||
setModified(activeTabId, true)
|
|
||||||
}, [activeTabId, updateTabContent, setModified])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
|
||||||
const textarea = textareaRef.current
|
|
||||||
if (!textarea || !activeTabId) return
|
|
||||||
|
|
||||||
// Tab 插入两个空格而非跳转焦点
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault()
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
const value = textarea.value
|
|
||||||
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
|
||||||
const newPos = start + 2
|
|
||||||
updateTabContent(activeTabId, newValue)
|
|
||||||
setModified(activeTabId, true)
|
|
||||||
pendingSelectionRef.current = newPos
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+B: 粗体
|
|
||||||
if (isCtrl && e.key === 'b') {
|
|
||||||
e.preventDefault()
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
const value = textarea.value
|
|
||||||
const selected = value.substring(start, end)
|
|
||||||
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
|
|
||||||
updateTabContent(activeTabId, newValue)
|
|
||||||
setModified(activeTabId, true)
|
|
||||||
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+I: 斜体
|
|
||||||
if (isCtrl && e.key === 'i') {
|
|
||||||
e.preventDefault()
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
const value = textarea.value
|
|
||||||
const selected = value.substring(start, end)
|
|
||||||
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
|
|
||||||
updateTabContent(activeTabId, newValue)
|
|
||||||
setModified(activeTabId, true)
|
|
||||||
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [activeTabId, updateTabContent, setModified])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`source-editor-wrapper${darkMode ? ' source-editor-dark' : ''}`}>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
className="source-editor-textarea"
|
|
||||||
value={activeTab?.content ?? ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
spellCheck={false}
|
|
||||||
wrap="off"
|
|
||||||
aria-label="Markdown 源码编辑器"
|
|
||||||
placeholder="在此输入 Markdown 内容..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
SourceEditor.displayName = 'SourceEditor'
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { useMemo } from 'react'
|
|
||||||
import { useStatusBarStore, type StatusBarItem } from '../../stores/statusBarStore'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* StatusBar — 纯渲染层
|
|
||||||
*
|
|
||||||
* 不包含任何业务逻辑。所有状态栏项由各功能模块通过
|
|
||||||
* useStatusBarStore.register() 注入,本组件按 alignment + priority 排序渲染。
|
|
||||||
* 每个 item.Component 是独立 React 组件,可自由使用 hooks。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const StatusBar = React.memo(function StatusBar() {
|
|
||||||
const items = useStatusBarStore(s => s.items)
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
const list = Object.values(items)
|
|
||||||
if (list.length === 0) return { left: [] as StatusBarItem[], right: [] as StatusBarItem[] }
|
|
||||||
const byAlignment = (a: 'left' | 'right') =>
|
|
||||||
list.filter(i => i.alignment === a).sort((x, y) => x.priority - y.priority)
|
|
||||||
return { left: byAlignment('left'), right: byAlignment('right') }
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="statusbar" role="status" aria-live="polite">
|
|
||||||
<div className="status-left">
|
|
||||||
{sorted.left.map(({ id, Component }) => (
|
|
||||||
<Component key={id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="status-right">
|
|
||||||
{sorted.right.map(({ id, Component }, i) => (
|
|
||||||
<React.Fragment key={id}>
|
|
||||||
{i > 0 && <span className="status-divider">|</span>}
|
|
||||||
<Component />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
StatusBar.displayName = 'StatusBar'
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useTabStore } from '../../stores/tabStore'
|
|
||||||
import { useEditorStore } from '../../stores/editorStore'
|
|
||||||
import { getFileName } from '../../lib/fileUtils'
|
|
||||||
import { useDocStats } from '../../hooks/useDocStats'
|
|
||||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
|
||||||
|
|
||||||
/** 文件信息 — 文件名或"就绪" */
|
|
||||||
export function FileInfoItem() {
|
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
|
||||||
return (
|
|
||||||
<span id="status-text">
|
|
||||||
{activeTab ? (activeTab.filePath ? getFileName(activeTab.filePath) : '未命名') : '就绪'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 加载指示器 — 打开文件/加载目录/渲染中 */
|
|
||||||
export function LoadingItem() {
|
|
||||||
const loadingStates = useEditorStore(s => s.loadingStates)
|
|
||||||
const hasLoading = Object.values(loadingStates).some(Boolean)
|
|
||||||
if (!hasLoading) return null
|
|
||||||
|
|
||||||
const label = loadingStates['file-open']
|
|
||||||
? '正在打开文件...'
|
|
||||||
: loadingStates['dir-load']
|
|
||||||
? '正在加载目录...'
|
|
||||||
: loadingStates['markdown-render']
|
|
||||||
? '正在渲染...'
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="status-loading" aria-busy="true">
|
|
||||||
<LoadingSpinner size="small" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文档统计 — 行数 / 词数 / 字符数 */
|
|
||||||
export function DocStatsItem() {
|
|
||||||
const activeTab = useTabStore(s => s.getActiveTab())
|
|
||||||
const stats = useDocStats(activeTab?.content)
|
|
||||||
if (!activeTab) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="status-stat" title="行数" aria-label={`${stats.lines} 行`}>{stats.lines} 行</span>
|
|
||||||
<span className="status-divider">|</span>
|
|
||||||
<span className="status-stat" title="单词数" aria-label={`${stats.words} 个单词`}>{stats.words} 词</span>
|
|
||||||
<span className="status-divider">|</span>
|
|
||||||
<span className="status-stat" title="字符数(含空格)" aria-label={`${stats.chars} 个字符`}>{stats.chars} 字符</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 静态项 — 编码 */
|
|
||||||
export function EncodingItem() {
|
|
||||||
return <span id="status-encoding">UTF-8</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 静态项 — 语言 */
|
|
||||||
export function LangItem() {
|
|
||||||
return <span id="status-lang">Markdown</span>
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
||||||
|
|
||||||
/** Toast 类型 */
|
|
||||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
|
||||||
|
|
||||||
/** 单条 Toast 数据 */
|
|
||||||
export interface ToastItem {
|
|
||||||
id: string
|
|
||||||
message: string
|
|
||||||
type: ToastType
|
|
||||||
duration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toast 容器组件属性 */
|
|
||||||
interface ToastContainerProps {
|
|
||||||
toasts: ToastItem[]
|
|
||||||
onDismiss: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单条 Toast 组件 */
|
|
||||||
const SingleToast = React.memo(function SingleToast({
|
|
||||||
toast,
|
|
||||||
onDismiss
|
|
||||||
}: {
|
|
||||||
toast: ToastItem
|
|
||||||
onDismiss: (id: string) => void
|
|
||||||
}) {
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 进入动画
|
|
||||||
requestAnimationFrame(() => setVisible(true))
|
|
||||||
|
|
||||||
// 自动消失
|
|
||||||
if (toast.duration > 0) {
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
setVisible(false)
|
|
||||||
// 等待退出动画完成后再移除
|
|
||||||
setTimeout(() => onDismiss(toast.id), 300)
|
|
||||||
}, toast.duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [toast.id, toast.duration, onDismiss])
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
setVisible(false)
|
|
||||||
setTimeout(() => onDismiss(toast.id), 300)
|
|
||||||
}, [toast.id, onDismiss])
|
|
||||||
|
|
||||||
const typeIcons: Record<ToastType, string> = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✕',
|
|
||||||
warning: '⚠',
|
|
||||||
info: 'ℹ'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`toast-item toast-${toast.type} ${visible ? 'toast-visible' : ''}`}
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
>
|
|
||||||
<span className="toast-icon" aria-hidden="true">
|
|
||||||
{typeIcons[toast.type]}
|
|
||||||
</span>
|
|
||||||
<span className="toast-message">{toast.message}</span>
|
|
||||||
<button
|
|
||||||
className="toast-close"
|
|
||||||
onClick={handleClose}
|
|
||||||
aria-label="关闭通知"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UX-05: Toast 容器组件
|
|
||||||
* 支持多条堆叠、类型区分、关闭按钮和自动消失
|
|
||||||
*/
|
|
||||||
export const ToastContainer = React.memo(function ToastContainer({
|
|
||||||
toasts,
|
|
||||||
onDismiss
|
|
||||||
}: ToastContainerProps) {
|
|
||||||
if (toasts.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="toast-container" aria-label="通知区域">
|
|
||||||
{toasts.map(toast => (
|
|
||||||
<SingleToast key={toast.id} toast={toast} onDismiss={onDismiss} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
ToastContainer.displayName = 'ToastContainer'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ToastContainer } from './Toast'
|
|
||||||
export type { ToastType, ToastItem } from './Toast'
|
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FolderOpen, Save, EditMode, PreviewMode, SourceMode, Moon, Sun, Info } from '../Icons'
|
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
|
||||||
import type { ViewMode } from '../../types/settings'
|
import type { ThemeMode } from '../../types/settings'
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
viewMode: ViewMode
|
themeMode: ThemeMode
|
||||||
onViewModeChange: (mode: ViewMode) => void
|
onCycleTheme: () => void
|
||||||
darkMode: boolean
|
|
||||||
onToggleDark: () => void
|
|
||||||
onShowAbout: () => void
|
onShowAbout: () => void
|
||||||
|
isAutoSaving: boolean
|
||||||
|
autoSaveEnabled: boolean
|
||||||
|
onToggleAutoSave: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark, onShowAbout }: ToolbarProps) {
|
const THEME_LABELS: Record<ThemeMode, string> = {
|
||||||
|
light: '亮色',
|
||||||
|
dark: '暗色',
|
||||||
|
warm: '暖色',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
|
||||||
|
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
|
||||||
|
*/
|
||||||
|
export const Toolbar = React.memo(function Toolbar({
|
||||||
|
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
|
||||||
|
isAutoSaving, autoSaveEnabled, onToggleAutoSave
|
||||||
|
}: ToolbarProps) {
|
||||||
|
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
<div id="toolbar" role="toolbar" aria-label="工具栏">
|
||||||
<div className="toolbar-left" role="group" aria-label="文件操作">
|
<div className="toolbar-left" role="group" aria-label="文件操作">
|
||||||
@@ -26,39 +42,23 @@ export const Toolbar = React.memo(function Toolbar({ onOpen, onSave, viewMode, o
|
|||||||
</button>
|
</button>
|
||||||
<div className="toolbar-divider" role="separator" />
|
<div className="toolbar-divider" role="separator" />
|
||||||
<button
|
<button
|
||||||
className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`}
|
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
|
||||||
onClick={() => onViewModeChange('editor')}
|
onClick={onToggleAutoSave}
|
||||||
title="WYSIWYG 编辑 (Ctrl+1)"
|
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
|
||||||
aria-label="WYSIWYG 编辑模式"
|
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
|
||||||
aria-pressed={viewMode === 'editor'}
|
|
||||||
>
|
>
|
||||||
<EditMode size={18} />
|
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
|
||||||
<span>编辑</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`toolbar-btn ${viewMode === 'source' ? 'active' : ''}`}
|
|
||||||
onClick={() => onViewModeChange('source')}
|
|
||||||
title="源码编辑 (Ctrl+3)"
|
|
||||||
aria-label="源码编辑模式"
|
|
||||||
aria-pressed={viewMode === 'source'}
|
|
||||||
>
|
|
||||||
<SourceMode size={18} />
|
|
||||||
<span>源码</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`}
|
|
||||||
onClick={() => onViewModeChange('preview')}
|
|
||||||
title="预览 (Ctrl+2)"
|
|
||||||
aria-label="预览模式"
|
|
||||||
aria-pressed={viewMode === 'preview'}
|
|
||||||
>
|
|
||||||
<PreviewMode size={18} />
|
|
||||||
<span>预览</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-right" role="group" aria-label="设置">
|
<div className="toolbar-right" role="group" aria-label="设置">
|
||||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题" aria-label={darkMode ? '切换到亮色主题' : '切换到暗色主题'}>
|
<button
|
||||||
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
|
className="toolbar-btn"
|
||||||
|
onClick={onCycleTheme}
|
||||||
|
title={`当前 ${nextLabel} — 点击切换`}
|
||||||
|
aria-label={`当前${nextLabel}主题,点击切换`}
|
||||||
|
>
|
||||||
|
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
|
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
|
||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface ActiveTabRecord {
|
|||||||
|
|
||||||
export interface SettingsRecord {
|
export interface SettingsRecord {
|
||||||
id: string // 固定为 'default'
|
id: string // 固定为 'default'
|
||||||
darkMode: boolean
|
themeMode: 'light' | 'dark' | 'warm'
|
||||||
viewMode: 'editor' | 'preview' | 'source'
|
viewMode: 'editor' | 'preview' | 'source'
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const settingsRepository = {
|
|||||||
const record: SettingsRecord | undefined = await db.settings.get('default')
|
const record: SettingsRecord | undefined = await db.settings.get('default')
|
||||||
if (record) {
|
if (record) {
|
||||||
return {
|
return {
|
||||||
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
|
||||||
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
||||||
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
||||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* D4: 基于视口的活跃标题追踪 hook
|
* D4: 基于视口的活跃标题追踪 hook
|
||||||
@@ -16,7 +16,6 @@ export function useActiveHeading(
|
|||||||
headings: { level: number; text: string }[]
|
headings: { level: number; text: string }[]
|
||||||
): number | null {
|
): number | null {
|
||||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
@@ -71,10 +70,8 @@ export function useActiveHeading(
|
|||||||
// 初始计算
|
// 初始计算
|
||||||
handleScroll()
|
handleScroll()
|
||||||
|
|
||||||
const observer = observerRef.current
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('scroll', handleScroll)
|
container.removeEventListener('scroll', handleScroll)
|
||||||
observer?.disconnect()
|
|
||||||
}
|
}
|
||||||
}, [containerRef, headings, handleScroll])
|
}, [containerRef, headings, handleScroll])
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useTabStore } from '../stores/tabStore'
|
|||||||
import { isAllowedFile } from '../lib/fileUtils'
|
import { isAllowedFile } from '../lib/fileUtils'
|
||||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
export function useDragDrop(showToast: (msg: string) => void) {
|
export function useDragDrop() {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||||
@@ -50,7 +51,7 @@ export function useDragDrop(showToast: (msg: string) => void) {
|
|||||||
if (rejected > 0) {
|
if (rejected > 0) {
|
||||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||||
}
|
}
|
||||||
}, [createTab, showToast])
|
}, [createTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevent = (e: DragEvent) => {
|
const prevent = (e: DragEvent) => {
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||||
import { logError } from '../lib/errorHandler'
|
import { logError } from '../lib/errorHandler'
|
||||||
import type { ToastType } from '../components/Toast/Toast'
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||||
* UX-02: 添加 loading 状态指示
|
* UX-02: 添加 loading 状态指示
|
||||||
|
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
|
||||||
*/
|
*/
|
||||||
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
|
export function useFileOperations() {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
const createTab = useTabStore(s => s.createTab)
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||||
const setLoading = useEditorStore(s => s.setLoading)
|
const setLoading = useEditorStore(s => s.setLoading)
|
||||||
|
|
||||||
|
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
|
||||||
|
const savingGate = useRef(false)
|
||||||
|
|
||||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
@@ -30,9 +34,12 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading('file-open', false)
|
setLoading('file-open', false)
|
||||||
}
|
}
|
||||||
}, [createTab, showToast, setLoading])
|
}, [createTab, setLoading])
|
||||||
|
|
||||||
const handleSave = useCallback(async (): Promise<void> => {
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
|
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
|
||||||
|
if (savingGate.current) return
|
||||||
|
savingGate.current = true
|
||||||
try {
|
try {
|
||||||
const tab = getActiveTab()
|
const tab = getActiveTab()
|
||||||
if (!tab || !window.electronAPI) return
|
if (!tab || !window.electronAPI) return
|
||||||
@@ -47,8 +54,11 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('保存文件失败', error)
|
logError('保存文件失败', error)
|
||||||
showToast('保存失败', 'error')
|
showToast('保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
// 300ms 后释放锁,允许下次保存
|
||||||
|
setTimeout(() => { savingGate.current = false }, 300)
|
||||||
}
|
}
|
||||||
}, [getActiveTab, showToast])
|
}, [getActiveTab])
|
||||||
|
|
||||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +73,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
|
|||||||
logError('另存为失败', error)
|
logError('另存为失败', error)
|
||||||
showToast('另存为失败', 'error')
|
showToast('另存为失败', 'error')
|
||||||
}
|
}
|
||||||
}, [getActiveTab, showToast])
|
}, [getActiveTab])
|
||||||
|
|
||||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局键盘快捷键 hook。
|
||||||
|
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
|
||||||
|
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
|
||||||
|
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
|
||||||
|
*/
|
||||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
|
||||||
|
|
||||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||||
|
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
|
||||||
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
||||||
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||||
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
|
|
||||||
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
|
|
||||||
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('source'); return }
|
|
||||||
|
|
||||||
const tabState = useTabStore.getState()
|
const tabState = useTabStore.getState()
|
||||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||||
@@ -23,27 +24,27 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Tab / Ctrl+Shift+Tab
|
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
|
||||||
if (isCtrl && e.key === 'Tab') {
|
if (isCtrl && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const { tabs, activeTabId, mruStack } = tabState
|
const { tabs, activeTabId, mruStack } = tabState
|
||||||
if (tabs.length > 1) {
|
if (tabs.length > 1) {
|
||||||
if (e.shiftKey) {
|
if (mruStack.length > 0) {
|
||||||
if (mruStack.length > 0) {
|
const targetId = mruStack[0]
|
||||||
const targetId = mruStack[0]
|
if (tabs.find(t => t.id === targetId)) {
|
||||||
if (tabs.find(t => t.id === targetId)) {
|
tabState.switchToTab(targetId)
|
||||||
tabState.switchToTab(targetId)
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
|
||||||
const next = (idx + 1) % tabs.length
|
|
||||||
tabState.switchToTab(tabs[next].id)
|
|
||||||
}
|
}
|
||||||
|
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||||
|
const next = e.shiftKey
|
||||||
|
? (idx - 1 + tabs.length) % tabs.length
|
||||||
|
: (idx + 1) % tabs.length
|
||||||
|
tabState.switchToTab(tabs[next].id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
}, [handleOpenFile, handleSave, handleSaveAs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
|
||||||
import type { ViewMode } from '../types/settings'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AR-04: 设置 hook
|
|
||||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
|
||||||
* 仅负责视图模式的读取和保存
|
|
||||||
*/
|
|
||||||
export function useSettings() {
|
|
||||||
const viewMode = useEditorStore(s => s.viewMode)
|
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
|
||||||
|
|
||||||
const saveViewMode = useCallback((mode: ViewMode) => {
|
|
||||||
setViewMode(mode)
|
|
||||||
settingsRepository.save({ viewMode: mode })
|
|
||||||
}, [setViewMode])
|
|
||||||
|
|
||||||
return { viewMode, saveViewMode }
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@ import { logError } from '../lib/errorHandler'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-04: 统一设置加载 hook
|
* AR-04: 统一设置加载 hook
|
||||||
* 一次性从 IndexedDB 加载所有设置,分发到各 store
|
* 一次性从 IndexedDB 加载所有设置,分发到各 store,
|
||||||
* 替代 useTheme、useSettings、sidebarStore.loadFromDB 各自独立加载的模式
|
* 替代各 hook 各自独立加载设置的模式。
|
||||||
*/
|
*/
|
||||||
export function useSettingsInit() {
|
export function useSettingsInit() {
|
||||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
const setThemeMode = useEditorStore(s => s.setThemeMode)
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||||
const isInitialized = useRef(false)
|
const isInitialized = useRef(false)
|
||||||
|
|
||||||
@@ -20,11 +20,10 @@ export function useSettingsInit() {
|
|||||||
|
|
||||||
settingsRepository.load()
|
settingsRepository.load()
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
// 主题
|
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
const darkMode = settings.darkMode !== undefined ? settings.darkMode : prefersDark
|
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||||
setDarkMode(darkMode)
|
setThemeMode(themeMode)
|
||||||
document.documentElement.classList.toggle('dark', darkMode)
|
|
||||||
|
|
||||||
// 视图模式
|
// 视图模式
|
||||||
setViewMode(settings.viewMode ?? 'editor')
|
setViewMode(settings.viewMode ?? 'editor')
|
||||||
@@ -42,7 +41,7 @@ export function useSettingsInit() {
|
|||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
logError('加载设置失败', error)
|
logError('加载设置失败', error)
|
||||||
})
|
})
|
||||||
}, [setDarkMode, setViewMode])
|
}, [setThemeMode, setViewMode])
|
||||||
|
|
||||||
return { isInitialized }
|
return { isInitialized }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useSidebarStore } from '../stores/sidebarStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
|
||||||
|
*
|
||||||
|
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
|
||||||
|
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
|
||||||
*/
|
*/
|
||||||
export function useSidebarResize() {
|
export function useSidebarResize() {
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -17,7 +21,15 @@ export function useSidebarResize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = (): void => setIsResizing(false)
|
const handleMouseUp = (): void => {
|
||||||
|
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB)
|
||||||
|
if (sidebarRef.current) {
|
||||||
|
const width = sidebarRef.current.offsetWidth
|
||||||
|
const clamped = Math.max(180, Math.min(500, width))
|
||||||
|
useSidebarStore.getState().setSidebarWidth(clamped)
|
||||||
|
}
|
||||||
|
setIsResizing(false)
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useStatusBarStore, type StatusBarItem } from '../stores/statusBarStore'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 便捷 hook — 挂载时注册一个状态栏项,卸载时自动清除。
|
|
||||||
*
|
|
||||||
* 适合在 App.tsx 或自定义 hook 中注册需要响应式更新的状态栏项。
|
|
||||||
* 当 item 对象变化时(通过 deps 控制),旧项会被新项替换。
|
|
||||||
*
|
|
||||||
* @param item - 状态栏项(含 Component 函数组件)
|
|
||||||
*/
|
|
||||||
export function useStatusBarItem(item: StatusBarItem | null): void {
|
|
||||||
const register = useStatusBarStore(s => s.register)
|
|
||||||
const unregister = useStatusBarStore(s => s.unregister)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!item) return
|
|
||||||
register(item)
|
|
||||||
return () => unregister(item.id)
|
|
||||||
}, [item, register, unregister])
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useStatusBarStore } from '../stores/statusBarStore'
|
|
||||||
import {
|
|
||||||
FileInfoItem,
|
|
||||||
LoadingItem,
|
|
||||||
DocStatsItem,
|
|
||||||
EncodingItem,
|
|
||||||
LangItem
|
|
||||||
} from '../components/StatusBar/StatusBarItems'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useDefaultStatusBarItems — 注册所有默认状态栏项(不含 auto-save)。
|
|
||||||
*
|
|
||||||
* auto-save 项由 App.tsx 单独注册,因为它需要从 autoSaveStore
|
|
||||||
* 读取状态并通过 toggleAutoSaveExternal() 触发切换。
|
|
||||||
*/
|
|
||||||
export function useDefaultStatusBarItems() {
|
|
||||||
const register = useStatusBarStore(s => s.register)
|
|
||||||
const unregister = useStatusBarStore(s => s.unregister)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
register({ id: 'statusbar.file-info', alignment: 'left', priority: 0, Component: FileInfoItem })
|
|
||||||
register({ id: 'statusbar.loading', alignment: 'left', priority: 1, Component: LoadingItem })
|
|
||||||
register({ id: 'statusbar.doc-stats', alignment: 'right', priority: 100, Component: DocStatsItem })
|
|
||||||
register({ id: 'statusbar.encoding', alignment: 'right', priority: 300, Component: EncodingItem })
|
|
||||||
register({ id: 'statusbar.lang', alignment: 'right', priority: 400, Component: LangItem })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unregister('statusbar.file-info')
|
|
||||||
unregister('statusbar.loading')
|
|
||||||
unregister('statusbar.doc-stats')
|
|
||||||
unregister('statusbar.encoding')
|
|
||||||
unregister('statusbar.lang')
|
|
||||||
}
|
|
||||||
}, [register, unregister])
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,90 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import MeEditor from '@metona-team/metona-editor'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
|
import type { ThemeMode } from '../types/settings'
|
||||||
|
|
||||||
|
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
|
||||||
|
function hexWithAlpha(hex: string, alpha: number): string {
|
||||||
|
if (!hex || !hex.startsWith('#')) return hex
|
||||||
|
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
|
||||||
|
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
|
||||||
|
function syncAppColorsToEditor(): void {
|
||||||
|
try {
|
||||||
|
const vars = MeEditor.themes.exportCSSVars()
|
||||||
|
if (!vars || typeof vars !== 'object') return
|
||||||
|
const root = document.documentElement
|
||||||
|
const set = (name: string, value: string | undefined) => {
|
||||||
|
if (value) root.style.setProperty(name, value)
|
||||||
|
}
|
||||||
|
const accent = vars['--md-accent'] ?? '#1a73e8'
|
||||||
|
// 直接映射 — 文本/边框用纯色
|
||||||
|
set('--bg', vars['--md-bg'])
|
||||||
|
set('--text', vars['--md-text'])
|
||||||
|
set('--border', vars['--md-border'])
|
||||||
|
set('--primary', accent)
|
||||||
|
set('--primary-dark', accent)
|
||||||
|
set('--text-secondary', vars['--md-muted'])
|
||||||
|
set('--text-tertiary', vars['--md-muted'])
|
||||||
|
set('--code-bg', vars['--md-code-bg'])
|
||||||
|
// 背景派生 — 用 accent 的低透明度版本
|
||||||
|
const accentBg = hexWithAlpha(accent, 0.12)
|
||||||
|
set('--primary-light', accentBg)
|
||||||
|
set('--sidebar-active', accentBg)
|
||||||
|
set('--sidebar-bg', vars['--md-bg'])
|
||||||
|
set('--sidebar-border', vars['--md-border'])
|
||||||
|
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
|
||||||
|
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||||
|
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
|
||||||
|
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
|
||||||
|
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
|
||||||
|
set('--search-border', vars['--md-border'])
|
||||||
|
// 阴影根据主题适配
|
||||||
|
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
|
||||||
|
root.style.setProperty('--shadow', isDark
|
||||||
|
? '0 1px 3px rgba(0,0,0,0.3)'
|
||||||
|
: '0 1px 3px rgba(0,0,0,0.08)')
|
||||||
|
root.style.setProperty('--shadow-lg', isDark
|
||||||
|
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||||
|
: '0 4px 12px rgba(0,0,0,0.1)')
|
||||||
|
} catch { /* 容错 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_TO_TOAST: Record<ThemeMode, string> = {
|
||||||
|
light: 'light',
|
||||||
|
dark: 'dark',
|
||||||
|
warm: 'warm',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AR-04: 主题 hook
|
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
|
||||||
* 不再独立加载设置(由 useSettingsInit 统一加载)
|
* 应用颜色自动同步编辑器主题,保持一致。
|
||||||
* 仅负责主题切换时的同步和保存
|
|
||||||
*/
|
*/
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const darkMode = useEditorStore(s => s.darkMode)
|
const themeMode = useEditorStore(s => s.themeMode)
|
||||||
const toggleDarkMode = useEditorStore(s => s.toggleDarkMode)
|
const cycleTheme = useEditorStore(s => s.cycleTheme)
|
||||||
|
|
||||||
// darkMode 变化时同步 class 并保存
|
const handleCycleTheme = useCallback(() => {
|
||||||
|
const next = cycleTheme()
|
||||||
|
// 同步编辑器全局主题
|
||||||
|
MeEditor.setTheme(next)
|
||||||
|
// 同步 Toast 主题
|
||||||
|
import('../lib/toast').then(({ MeToast }) => {
|
||||||
|
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
|
||||||
|
})
|
||||||
|
// 持久化
|
||||||
|
settingsRepository.save({ themeMode: next })
|
||||||
|
}, [cycleTheme])
|
||||||
|
|
||||||
|
// themeMode 变化时同步应用颜色
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', darkMode)
|
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
|
||||||
settingsRepository.save({ darkMode })
|
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
|
||||||
}, [darkMode])
|
syncAppColorsToEditor()
|
||||||
|
}, [themeMode])
|
||||||
|
|
||||||
return { darkMode, toggleDarkMode }
|
return { themeMode, cycleTheme: handleCycleTheme }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react'
|
|
||||||
import type { ToastItem, ToastType } from '../components/Toast/Toast'
|
|
||||||
|
|
||||||
/** 默认自动消失时间 (ms) */
|
|
||||||
const DEFAULT_DURATION = 3000
|
|
||||||
|
|
||||||
/** 最大同时显示数量 */
|
|
||||||
const MAX_TOASTS = 5
|
|
||||||
|
|
||||||
// Module-level counter survives component remounts
|
|
||||||
let toastCounter = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UX-05: 升级版 Toast 状态管理 hook
|
|
||||||
* 支持多条堆叠、类型区分、自动消失
|
|
||||||
*/
|
|
||||||
export function useToast() {
|
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
|
||||||
|
|
||||||
const showToast = useCallback((msg: string, type: ToastType = 'info', duration = DEFAULT_DURATION) => {
|
|
||||||
const id = `toast-${++toastCounter}`
|
|
||||||
const newToast: ToastItem = { id, message: msg, type, duration }
|
|
||||||
|
|
||||||
setToasts(prev => {
|
|
||||||
const next = [...prev, newToast]
|
|
||||||
// 超过最大数量时移除最旧的
|
|
||||||
if (next.length > MAX_TOASTS) {
|
|
||||||
return next.slice(next.length - MAX_TOASTS)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
return id
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const dismissToast = useCallback((id: string) => {
|
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
|
||||||
setToasts([])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { toasts, showToast, dismissToast, cleanup }
|
|
||||||
}
|
|
||||||
@@ -138,3 +138,18 @@ export async function renderMarkdown(content: string, filePath?: string | null):
|
|||||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步版本 — 供 MetonaEditor 的 render 钩子使用。
|
||||||
|
* 所有 unified 插件均为同步转换器,可以在 render 钩子中同步调用。
|
||||||
|
*/
|
||||||
|
export function renderMarkdownSync(content: string, filePath?: string | null): string {
|
||||||
|
try {
|
||||||
|
const processor = getCachedProcessor(filePath ?? null)
|
||||||
|
const result = processor.processSync(content)
|
||||||
|
return String(result)
|
||||||
|
} catch (e) {
|
||||||
|
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||||
|
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import MeToast from '@metona-team/metona-toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MeToast 全局配置
|
||||||
|
*
|
||||||
|
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
|
||||||
|
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
|
||||||
|
*/
|
||||||
|
MeToast.configure({
|
||||||
|
position: 'top-right',
|
||||||
|
duration: 3000,
|
||||||
|
max: 5,
|
||||||
|
theme: 'auto',
|
||||||
|
animation: 'slide',
|
||||||
|
pauseOnHover: true,
|
||||||
|
closeOnClick: true,
|
||||||
|
showProgress: true,
|
||||||
|
draggable: true,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 与原 showToast 保持兼容的类型子集 */
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
|
||||||
|
*
|
||||||
|
* @param msg 消息内容
|
||||||
|
* @param type 通知类型(默认 info)
|
||||||
|
* @param duration 显示时长(ms),省略则用全局默认值
|
||||||
|
*/
|
||||||
|
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
|
||||||
|
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步 MeToast 主题到当前暗色模式 */
|
||||||
|
export function syncToastTheme(darkMode: boolean): void {
|
||||||
|
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MeToast }
|
||||||
@@ -5,7 +5,7 @@ describe('editorStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useEditorStore.setState({
|
useEditorStore.setState({
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
darkMode: false,
|
themeMode: 'light',
|
||||||
externallyModified: null,
|
externallyModified: null,
|
||||||
loadingStates: {},
|
loadingStates: {},
|
||||||
})
|
})
|
||||||
@@ -24,29 +24,36 @@ describe('editorStore', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('setDarkMode', () => {
|
describe('themeMode', () => {
|
||||||
it('should enable dark mode', () => {
|
it('should default to light', () => {
|
||||||
useEditorStore.getState().setDarkMode(true)
|
expect(useEditorStore.getState().themeMode).toBe('light')
|
||||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should disable dark mode', () => {
|
it('should set theme mode', () => {
|
||||||
useEditorStore.setState({ darkMode: true })
|
useEditorStore.getState().setThemeMode('dark')
|
||||||
useEditorStore.getState().setDarkMode(false)
|
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
})
|
||||||
|
|
||||||
|
it('should set warm theme', () => {
|
||||||
|
useEditorStore.getState().setThemeMode('warm')
|
||||||
|
expect(useEditorStore.getState().themeMode).toBe('warm')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('toggleDarkMode', () => {
|
describe('cycleTheme', () => {
|
||||||
it('should toggle from false to true', () => {
|
it('should cycle light → dark', () => {
|
||||||
useEditorStore.getState().toggleDarkMode()
|
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
|
||||||
expect(useEditorStore.getState().darkMode).toBe(true)
|
expect(useEditorStore.getState().themeMode).toBe('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle from true to false', () => {
|
it('should cycle dark → warm', () => {
|
||||||
useEditorStore.setState({ darkMode: true })
|
useEditorStore.setState({ themeMode: 'dark' })
|
||||||
useEditorStore.getState().toggleDarkMode()
|
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
|
||||||
expect(useEditorStore.getState().darkMode).toBe(false)
|
})
|
||||||
|
|
||||||
|
it('should cycle warm → light', () => {
|
||||||
|
useEditorStore.setState({ themeMode: 'warm' })
|
||||||
|
expect(useEditorStore.getState().cycleTheme()).toBe('light')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { EditorView } from '@milkdown/prose/view'
|
import type { ViewMode, ThemeMode } from '../types/settings'
|
||||||
import type { ViewMode } from '../types/settings'
|
import type { MarkdownEditor } from '@metona-team/metona-editor'
|
||||||
|
|
||||||
// Module-level getter/setter for the ProseMirror EditorView
|
// Module-level getter for the MetonaEditor instance
|
||||||
// Used by OutlinePanel for heading navigation
|
// Used by OutlinePanel for heading navigation
|
||||||
let _getView: (() => EditorView | null) | null = null
|
let _getEditor: (() => MarkdownEditor | null) | null = null
|
||||||
|
|
||||||
export function setEditorViewGetter(fn: () => EditorView | null) {
|
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
|
||||||
_getView = fn
|
_getEditor = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEditorView(): EditorView | null {
|
export function getMetonaEditor(): MarkdownEditor | null {
|
||||||
return _getView ? _getView() : null
|
return _getEditor ? _getEditor() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
darkMode: boolean
|
themeMode: ThemeMode
|
||||||
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
|
||||||
externallyModified: { filePath: string } | null
|
externallyModified: { filePath: string } | null
|
||||||
// UX-02: 全局加载状态
|
// UX-02: 全局加载状态
|
||||||
loadingStates: Record<string, boolean>
|
loadingStates: Record<string, boolean>
|
||||||
|
|
||||||
setViewMode: (mode: ViewMode) => void
|
setViewMode: (mode: ViewMode) => void
|
||||||
setDarkMode: (dark: boolean) => void
|
setThemeMode: (mode: ThemeMode) => void
|
||||||
toggleDarkMode: () => void
|
cycleTheme: () => ThemeMode
|
||||||
setExternallyModified: (info: { filePath: string } | null) => void
|
setExternallyModified: (info: { filePath: string } | null) => void
|
||||||
// UX-02: 加载状态管理
|
// UX-02: 加载状态管理
|
||||||
setLoading: (key: string, loading: boolean) => void
|
setLoading: (key: string, loading: boolean) => void
|
||||||
@@ -33,13 +35,19 @@ interface EditorState {
|
|||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
darkMode: false,
|
themeMode: 'light',
|
||||||
externallyModified: null,
|
externallyModified: null,
|
||||||
loadingStates: {},
|
loadingStates: {},
|
||||||
|
|
||||||
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
|
||||||
setDarkMode: (dark: boolean) => set({ darkMode: dark }),
|
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
|
||||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
|
cycleTheme: () => {
|
||||||
|
const current = get().themeMode
|
||||||
|
const idx = THEME_CYCLE.indexOf(current)
|
||||||
|
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
|
||||||
|
set({ themeMode: next })
|
||||||
|
return next
|
||||||
|
},
|
||||||
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
|
||||||
setLoading: (key: string, loading: boolean) => set(state => ({
|
setLoading: (key: string, loading: boolean) => set(state => ({
|
||||||
loadingStates: { ...state.loadingStates, [key]: loading }
|
loadingStates: { ...state.loadingStates, [key]: loading }
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import type { ComponentType } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态栏扩展点架构
|
|
||||||
*
|
|
||||||
* 功能模块通过 register/unregister 注入自己的状态栏组件,
|
|
||||||
* StatusBar 只负责按 alignment + priority 排序渲染。
|
|
||||||
* 避免 StatusBar 随功能膨胀,实现类似 VS Code contributions 的注册模式。
|
|
||||||
*
|
|
||||||
* 每个 StatusBarItem 的 Component 是独立的 React 函数组件(无 props),
|
|
||||||
* 内部使用 hooks 读取所需 store 状态。组件在 StatusBar 的渲染中挂载,
|
|
||||||
* 因此 hooks 调用在合法的 React 组件上下文中。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StatusBarItem {
|
|
||||||
id: string
|
|
||||||
alignment: 'left' | 'right'
|
|
||||||
/** 同侧排序权重,数字越小越靠左(left)或越靠中(right) */
|
|
||||||
priority: number
|
|
||||||
Component: ComponentType
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusBarState {
|
|
||||||
/** 以 id 为 key,方便覆盖和删除 */
|
|
||||||
items: Record<string, StatusBarItem>
|
|
||||||
register: (item: StatusBarItem) => void
|
|
||||||
unregister: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useStatusBarStore = create<StatusBarState>((set, get) => ({
|
|
||||||
items: {},
|
|
||||||
|
|
||||||
register: (item: StatusBarItem) => {
|
|
||||||
const { items } = get()
|
|
||||||
if (items[item.id]?.Component === item.Component) return
|
|
||||||
set({ items: { ...items, [item.id]: item } })
|
|
||||||
},
|
|
||||||
|
|
||||||
unregister: (id: string) => {
|
|
||||||
const { items } = get()
|
|
||||||
if (!(id in items)) return
|
|
||||||
const next = { ...items }
|
|
||||||
delete next[id]
|
|
||||||
set({ items: next })
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
+19
-591
@@ -121,234 +121,27 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editor Toolbar */
|
/* MetonaEditor Wrapper */
|
||||||
.editor-toolbar {
|
.metona-editor-wrapper {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar::-webkit-scrollbar {
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn-sm {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 28px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-btn-sm:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-divider-sm {
|
|
||||||
width: 1px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--border);
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Milkdown Wrapper */
|
|
||||||
.milkdown-wrapper {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown {
|
/* FIX: Allow text selection in the preview pane.
|
||||||
height: 100%;
|
global.css sets user-select:none on html/body to prevent
|
||||||
font-family: var(--font-mono);
|
selection in UI chrome (toolbar, tabs, sidebar), but this
|
||||||
font-size: 13px;
|
also blocked mouse-drag selection in the Markdown preview.
|
||||||
color: var(--text);
|
Override back to the browser default for the preview area. */
|
||||||
background: var(--bg);
|
.metona-editor-wrapper .me-preview,
|
||||||
}
|
.metona-editor-wrapper .me-preview * {
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor {
|
|
||||||
overflow: auto;
|
|
||||||
height: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
outline: none;
|
|
||||||
user-select: text;
|
user-select: text;
|
||||||
-webkit-user-select: text;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor p {
|
/* Also restore selection in the editor textarea (MetonaEditor
|
||||||
margin: 0.5em 0;
|
explicitly sets user-select:none on it in some themes) */
|
||||||
}
|
.metona-editor-wrapper textarea {
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor h1,
|
|
||||||
.milkdown-wrapper .milkdown .editor h2,
|
|
||||||
.milkdown-wrapper .milkdown .editor h3,
|
|
||||||
.milkdown-wrapper .milkdown .editor h4,
|
|
||||||
.milkdown-wrapper .milkdown .editor h5,
|
|
||||||
.milkdown-wrapper .milkdown .editor h6 {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 1em 0 0.5em;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor h1 {
|
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
|
||||||
.milkdown-wrapper .milkdown .editor h2 {
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
.milkdown-wrapper .milkdown .editor h3 {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor blockquote {
|
|
||||||
border-left: 3px solid var(--primary);
|
|
||||||
padding-left: 12px;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor code {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor pre {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor pre code {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor ul,
|
|
||||||
.milkdown-wrapper .milkdown .editor ol {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor li {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 1.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ProseMirror selection */
|
|
||||||
.milkdown-wrapper .milkdown .editor .ProseMirror-selectednode {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper .milkdown .editor ::selection {
|
|
||||||
background: rgba(26, 115, 232, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .milkdown-wrapper .milkdown .editor ::selection {
|
|
||||||
background: rgba(100, 160, 255, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ProseMirror cursor */
|
|
||||||
.milkdown-wrapper .milkdown .editor .ProseMirror-focused {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode styles for Milkdown */
|
|
||||||
.milkdown-wrapper.milkdown-dark .milkdown {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.milkdown-wrapper.milkdown-dark .milkdown .editor blockquote {
|
|
||||||
border-left-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preview Panel */
|
|
||||||
#preview-panel {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
min-width: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#preview {
|
|
||||||
padding: 24px 32px;
|
|
||||||
user-select: text;
|
user-select: text;
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Source Editor */
|
|
||||||
.source-editor-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-editor-textarea {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
resize: none;
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
tab-size: 2;
|
|
||||||
color: #1e1e1e;
|
|
||||||
background: #fafafa;
|
|
||||||
user-select: text;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-editor-dark .source-editor-textarea {
|
|
||||||
color: #d4d4d4;
|
|
||||||
background: #1e1e1e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Bar */
|
/* Tab Bar */
|
||||||
@@ -388,14 +181,6 @@ body {
|
|||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#tab-list::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tab-list::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tab-list::-webkit-scrollbar-track {
|
#tab-list::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -579,53 +364,21 @@ body {
|
|||||||
color: #ffd54f;
|
color: #ffd54f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Bar */
|
/* Toolbar auto-save button */
|
||||||
#statusbar {
|
.toolbar-autosave {
|
||||||
height: var(--statusbar-height);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-left,
|
|
||||||
.status-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-divider {
|
|
||||||
color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-stat {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-auto-save {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
border: none;
|
padding: 4px 10px;
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-auto-save:hover {
|
.toolbar-autosave:hover {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-auto-save.saving {
|
.toolbar-autosave.saving {
|
||||||
color: var(--primary, #1a73e8);
|
color: var(--primary);
|
||||||
animation: auto-save-pulse 1s ease-in-out infinite;
|
animation: auto-save-pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,43 +751,6 @@ body {
|
|||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View Modes */
|
|
||||||
#app.mode-preview #editor-panel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#app.mode-editor #preview-panel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast */
|
|
||||||
#toast-notification {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 40px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(20px);
|
|
||||||
background: var(--text);
|
|
||||||
color: var(--bg);
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition:
|
|
||||||
opacity 0.3s ease,
|
|
||||||
transform 0.3s ease;
|
|
||||||
z-index: 9999;
|
|
||||||
max-width: 480px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toast-notification.show {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -1358,151 +1074,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status bar loading indicator */
|
|
||||||
.status-loading {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-loading .loading-spinner-svg {
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== UX-05: Toast Container (升级版) ===== */
|
|
||||||
.toast-container {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 44px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: 8px;
|
|
||||||
z-index: 9999;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 480px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px) scale(0.95);
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-item.toast-visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast 类型样式 */
|
|
||||||
.toast-success {
|
|
||||||
background: #e6f4ea;
|
|
||||||
color: #137333;
|
|
||||||
border: 1px solid #a8dab5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-error {
|
|
||||||
background: #fce8e6;
|
|
||||||
color: #c5221f;
|
|
||||||
border: 1px solid #f28b82;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-warning {
|
|
||||||
background: #fef7e0;
|
|
||||||
color: #945700;
|
|
||||||
border: 1px solid #fdd663;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-info {
|
|
||||||
background: var(--text);
|
|
||||||
color: var(--bg);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .toast-success {
|
|
||||||
background: #0d3a1a;
|
|
||||||
color: #81c995;
|
|
||||||
border-color: #1e5a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .toast-error {
|
|
||||||
background: #3c1214;
|
|
||||||
color: #f28b82;
|
|
||||||
border-color: #5c1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .toast-warning {
|
|
||||||
background: #3a3000;
|
|
||||||
color: #fdd663;
|
|
||||||
border-color: #5a4a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .toast-info {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-message {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close:focus-visible {
|
|
||||||
outline: 2px solid currentColor;
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== UX-07: 通用可访问性增强 ===== */
|
/* ===== UX-07: 通用可访问性增强 ===== */
|
||||||
|
|
||||||
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
|
||||||
@@ -1589,149 +1160,6 @@ button:focus-visible,
|
|||||||
background: var(--primary-dark);
|
background: var(--primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Search & Replace Panel ===== */
|
|
||||||
|
|
||||||
.search-replace-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
animation: slideDown 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input-group {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input::placeholder {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-count {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
pointer-events: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* D6: 正则语法错误提示 */
|
|
||||||
.search-input-group .search-count {
|
|
||||||
color: #c5221f;
|
|
||||||
}
|
|
||||||
.search-input-group .search-count:not(:empty) {
|
|
||||||
/* 仅当有正则错误时变红 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn.active {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-close:hover {
|
|
||||||
background: #e74c3c20;
|
|
||||||
color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replace-btn {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search match highlights */
|
|
||||||
.search-match-highlight {
|
|
||||||
background: rgba(255, 213, 0, 0.4);
|
|
||||||
color: inherit;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-match-active {
|
|
||||||
background: rgba(255, 150, 0, 0.6);
|
|
||||||
outline: 1px solid rgba(255, 150, 0, 0.8);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .search-match-highlight {
|
|
||||||
background: rgba(255, 213, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .search-match-active {
|
|
||||||
background: rgba(255, 180, 0, 0.4);
|
|
||||||
outline-color: rgba(255, 180, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Outline Panel (Table of Contents) ===== */
|
/* ===== Outline Panel (Table of Contents) ===== */
|
||||||
|
|
||||||
.sidebar-outline-section {
|
.sidebar-outline-section {
|
||||||
|
|||||||
@@ -23,23 +23,6 @@
|
|||||||
--sidebar-active: var(--primary-light);
|
--sidebar-active: var(--primary-light);
|
||||||
--search-bg: var(--bg-secondary);
|
--search-bg: var(--bg-secondary);
|
||||||
--search-border: var(--border);
|
--search-border: var(--border);
|
||||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark {
|
|
||||||
--primary: #8ab4f8;
|
|
||||||
--primary-light: #1a3a5c;
|
|
||||||
--primary-dark: #aecbfa;
|
|
||||||
--bg: #1e1e1e;
|
|
||||||
--bg-secondary: #252526;
|
|
||||||
--bg-tertiary: #2d2d2d;
|
|
||||||
--text: #d4d4d4;
|
|
||||||
--text-secondary: #9e9e9e;
|
|
||||||
--text-tertiary: #6e6e6e;
|
|
||||||
--border: #3e3e3e;
|
|
||||||
--border-light: #333333;
|
|
||||||
--code-bg: #2d2d2d;
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
export type ThemeMode = 'light' | 'dark' | 'warm'
|
||||||
export type ViewMode = 'editor' | 'preview' | 'source'
|
export type ViewMode = 'editor' | 'preview' | 'source'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
darkMode: boolean
|
themeMode: ThemeMode
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
sidebarCollapsed: boolean
|
sidebarCollapsed: boolean
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
darkMode: false,
|
themeMode: 'light',
|
||||||
viewMode: 'editor',
|
viewMode: 'editor',
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
sidebarWidth: 240
|
sidebarWidth: 240
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 共享常量 — 主进程和渲染进程共用
|
// 共享常量 — 主进程和渲染进程共用
|
||||||
export const APP_VERSION = 'v0.3.12'
|
export const APP_VERSION = 'v0.4.3'
|
||||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||||
export const SKIP_DIRS = new Set([
|
export const SKIP_DIRS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user