Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.DS_Store
# 本地 npm 发布凭据(含 _auth,勿提交)
.npmrc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 thzxx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+837
View File
@@ -0,0 +1,837 @@
# MetonaEditor
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
[![npm version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://www.npmjs.com/package/@metona-team/metona-editor)
[![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-407%20passed-brightgreen.svg)](./tests)
[![coverage](https://img.shields.io/badge/coverage-97%25-brightgreen.svg)](./tests)
---
## 特性
- **零依赖** — 单文件打包,无任何运行时依赖,UMD/ESM/CJS 三种格式
- **现代化** — 原生 ES2020+ 实现,CSS 变量主题系统,响应式布局,无障碍支持
- **易扩展** — 完整插件系统(install/destroy 生命周期),自定义工具栏按钮,预设插件开箱即用
- **易维护** — 模块化源码,JSDoc 注释完整,TypeScript 类型声明,291 个单元测试覆盖
- **易使用** — 工厂函数 `create()` 一行接入,链式 API,中文优先文档与翻译
- **安全** — 内置 XSS 防护(`javascript:`/`vbscript:`/`file:` 协议过滤),HTML 转义,`safeUrl` 净化
- **内置解析器** — 自研 CommonMark 子集 + GFM 扩展(表格、任务列表、删除线、自动链接),可整体替换
- **三模式视图** — edit / split / preview 自由切换,分屏模式支持拖拽调整比例
- **主题系统** — light / dark / auto / warm 四套预设,CSS 变量定制,跟随系统主题
- **国际化** — zh-CN / en-US 完整翻译,RTL 支持,`Intl` 数字/货币/日期格式化
- **历史栈** — 撤销/重做,400ms 防抖合并,可配置上限
- **预设插件** — autoSave(草稿)、exportTool(导出 .md/.html)、searchReplaceCtrl+F 查找替换)
---
## 目录
- [快速开始](#快速开始)
- [配置项](#配置项)
- [API 参考](#api-参考)
- [事件系统](#事件系统)
- [插件系统](#插件系统)
- [主题系统](#主题系统)
- [国际化](#国际化)
- [Markdown 解析器](#markdown-解析器)
- [TypeScript 支持](#typescript-支持)
- [测试](#测试)
- [构建](#构建)
- [浏览器兼容性](#浏览器兼容性)
- [License](#license)
---
## 快速开始
### 安装
```bash
npm install @metona-team/metona-editor
```
### 浏览器直接引入(UMD
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://unpkg.com/@metona-team/metona-editor/dist/metona-editor.min.css" />
</head>
<body>
<div id="editor"></div>
<script src="https://unpkg.com/@metona-team/metona-editor/dist/metona-editor.min.js"></script>
<script>
const editor = MeEditor.create('#editor', {
value: '# Hello World',
mode: 'split',
});
</script>
</body>
</html>
```
### ES Module
```javascript
import MeEditor from '@metona-team/metona-editor';
const editor = MeEditor.create('#editor', {
value: '# Hello World',
mode: 'split',
onChange: (value, editor) => {
console.log('内容变化:', value);
},
});
```
### 直接使用类
```javascript
import { MarkdownEditor } from '@metona-team/metona-editor';
const editor = new MarkdownEditor('#editor', {
value: '# Hello World',
});
```
### CommonJS
```javascript
const MeEditor = require('@metona-team/metona-editor');
const editor = MeEditor.create('#editor', {});
```
---
## 配置项
所有配置项均为可选,以下是默认值:
```javascript
MeEditor.create(container, {
// 内容
value: '', // 初始 Markdown 文本
placeholder: '', // 占位符
// 视图
mode: 'split', // 'edit' | 'split' | 'preview'
height: 400, // 数字为 px,字符串原样使用
toolbar: DEFAULT_TOOLBAR, // 工具栏配置,false 隐藏
wordCount: true, // 字数统计状态栏
// 行为
autofocus: false, // 自动聚焦
spellcheck: false, // 拼写检查
historyLimit: 100, // 历史栈上限
syncScroll: true, // 分屏模式同步滚动
tabSize: 2, // 制表符空格数(0 表示 \t
// 主题与国际化
theme: 'auto', // 'light' | 'dark' | 'auto' | 'warm' | 自定义
locale: 'zh-CN', // 'zh-CN' | 'en-US' | 自定义
// 自定义渲染
render: null, // (md, env) => html,覆盖内置解析器
highlight: null, // (code, lang) => html,代码高亮钩子
sanitize: null, // (html) => safeHtmlHTML 净化钩子
// 外观
className: '', // 容器额外 class
style: {}, // 内联样式
// 插件
plugins: [], // 实例级插件数组
// 生命周期回调
onChange: null, // (value, editor) => void
onInput: null, // (value, editor) => void
onFocus: null, // (editor) => void
onBlur: null, // (editor) => void
onSave: null, // (editor) => voidCtrl+S
onModeChange: null, // (mode, editor) => void
onFullscreen: null, // (fullscreen, editor) => void
onCreate: null, // (editor) => void
onDestroy: null, // (editor) => void
});
```
### 工具栏配置
```javascript
// 使用默认工具栏
MeEditor.create(container, { toolbar: true });
// 隐藏工具栏
MeEditor.create(container, { toolbar: false });
// 自定义工具栏('|' 为分隔符)
MeEditor.create(container, {
toolbar: ['bold', 'italic', '|', 'h1', 'h2', '|', 'undo', 'redo'],
});
// 可用动作
// bold italic strikethrough underline code
// h1 h2 h3 quote ul ol indent outdent hr
// link image table
// undo redo
// edit split preview fullscreen
```
---
## API 参考
### 实例 API
#### 内容操作
```javascript
editor.getValue(); // 获取 Markdown 文本
editor.setValue(md, { silent }); // 设置内容,silent 不触发 change
editor.getHTML(); // 获取渲染后的 HTML
editor.insert(text, { replace }); // 在光标处插入文本
editor.wrap(before, after); // 包裹选区
editor.focus(); // 聚焦编辑器
editor.blur(); // 失焦
```
#### 命令执行
```javascript
editor.exec(action, ...args); // 执行命令,返回 this 支持链式
// 支持的 action
// bold italic strikethrough underline code
// h1 h2 h3 quote ul ol indent outdent hr
// link image table
// undo redo
// edit split preview fullscreen
editor.exec('bold').exec('h1'); // 链式调用
```
#### 历史栈
```javascript
editor.undo(); // 撤销
editor.redo(); // 重做
editor.canUndo(); // 是否可撤销
editor.canRedo(); // 是否可重做
```
#### 模式与全屏
```javascript
editor.setMode('split'); // 设置模式:edit / split / preview
editor.getMode(); // 获取当前模式
editor.toggleFullscreen(); // 切换全屏
editor.exitFullscreen(); // 退出全屏
editor.isFullscreen(); // 是否全屏
```
#### 统计与状态
```javascript
editor.getStats();
// 返回:{ characters, words, chineseChars, englishWords, lines, readingTime }
editor.getStatus();
// 返回:{ id, mode, theme, locale, fullscreen, disabled, destroyed, plugins }
```
#### 事件
```javascript
const unsub = editor.on('change', (value, editor) => {});
editor.off('change', handler);
unsub(); // 取消监听(等价于 off
```
#### 插件
```javascript
editor.use(plugin, options); // 安装插件
editor.getPlugins(); // 获取已安装插件列表
editor.addToolbarButton(config); // 追加工具栏按钮
```
#### 启用/禁用
```javascript
editor.enable(); // 启用
editor.disable(); // 禁用
editor.isDisabled(); // 是否禁用
```
#### 销毁
```javascript
editor.destroy(); // 销毁实例,清理 DOM 与事件
editor.isDestroyed(); // 是否已销毁
```
### 静态 API(顶层)
```javascript
import MeEditor from '@metona-team/metona-editor';
// 工厂函数(推荐入口)
MeEditor.create(container, options);
// 全局默认插件(应用于所有后续创建的实例)
MeEditor.use(presetPlugins.autoSave, { delay: 2000 });
MeEditor.use('searchReplace'); // 字符串形式引用预设
MeEditor.use(customPlugin); // 自定义插件对象
// 全局事件钩子(所有实例共享)
MeEditor.on('beforeCreate', (editor) => {});
MeEditor.off('beforeCreate', handler);
// 全局主题与国际化
MeEditor.setTheme('dark');
MeEditor.setLocale('en-US');
// 状态查询
MeEditor.getStatus();
// 返回:{ version, theme, locale, globalPlugins, presetPlugins }
// 销毁所有全局资源(不销毁实例)
MeEditor.destroy();
```
### 内置解析器(可单独使用)
```javascript
import { parseMarkdown, safeUrl, slugify } from '@metona-team/metona-editor';
parseMarkdown('# Hello'); // => '<h1 id="hello">Hello</h1>'
parseMarkdown('**bold**'); // => '<p><strong>bold</strong></p>'
safeUrl('javascript:alert(1)'); // => ''(过滤危险协议)
safeUrl('https://example.com'); // => 'https://example.com'
slugify('Hello World'); // => 'hello-world'
slugify('你好世界'); // => '你好世界'
```
---
## 事件系统
MetonaEditor 提供两层事件系统:
### 实例事件
```javascript
editor.on('change', (value, editor) => {
console.log('内容变化');
});
editor.on('modeChange', (mode, editor) => {
console.log('模式切换:', mode);
});
editor.on('destroy', () => {
console.log('编辑器已销毁');
});
```
**可用事件**
| 事件 | 触发时机 | 回调参数 |
|------|---------|---------|
| `input` | textarea 原生 input | `(value, editor)` |
| `change` | 内容变化(input/setValue/exec | `(value, editor)` |
| `focus` | 聚焦 | `(editor)` |
| `blur` | 失焦 | `(editor)` |
| `save` | Ctrl+S | `(editor)` |
| `modeChange` | 模式切换 | `(mode, editor)` |
| `fullscreen` | 全屏切换 | `(fullscreen, editor)` |
| `destroy` | 销毁 | `()` |
| `autosave` | autoSave 插件触发 | `({ key, value })` |
### 静态钩子(全局)
```javascript
MarkdownEditor.on('beforeCreate', (editor) => {
console.log('实例即将创建');
});
MarkdownEditor.on('afterDestroy', (editor) => {
console.log('实例已销毁');
});
```
**可用钩子**
| 钩子 | 触发时机 |
|------|---------|
| `beforeCreate` | 构造函数初始化前 |
| `afterCreate` | 构造函数初始化完成 |
| `beforeRender` | 每次渲染前 |
| `afterRender` | 每次渲染后 |
| `beforeDestroy` | destroy 前 |
| `afterDestroy` | destroy 后 |
---
## 插件系统
### 插件约定
```javascript
const myPlugin = {
name: 'myPlugin',
description: '我的自定义插件',
// 安装时调用,this 指向插件对象本身
install(editor, options) {
// 在 this 上存放运行时状态
this._timer = null;
// 暴露实例方法
editor.doSomething = () => {
console.log(editor.getValue());
};
// 监听事件
editor.on('change', this._onChange);
},
// 卸载时调用,清理事件与 DOM
destroy(editor) {
if (this._timer) clearTimeout(this._timer);
if (editor && typeof editor.off === 'function') {
editor.off('change', this._onChange);
}
},
};
```
### 使用插件
```javascript
// 实例级安装
editor.use(myPlugin, { option1: 'value' });
// 全局默认插件(所有新实例自动安装)
MeEditor.use(myPlugin);
MeEditor.use(presetPlugins.autoSave, { delay: 2000 });
```
### 预设插件
#### autoSave — 自动保存草稿
```javascript
editor.use(presetPlugins.autoSave, {
key: 'me-draft-' + editor.id, // localStorage key
delay: 1000, // 防抖延迟(ms
});
// 暴露的 API
editor.getDraftKey(); // 获取存储 key
editor.restoreDraft(); // 从 localStorage 恢复
editor.clearDraft(); // 清除草稿
```
#### exportTool — 导出文件
```javascript
editor.use(presetPlugins.exportTool);
editor.exportMarkdown('my-doc.md');
editor.exportHTML('my-doc.html', {
title: '文档标题',
css: 'body { font-family: sans-serif; }',
lang: 'zh-CN',
});
```
#### searchReplace — 查找替换
```javascript
editor.use(presetPlugins.searchReplace);
// 快捷键
// Ctrl+F / Cmd+F — 打开查找面板
// Ctrl+H / Cmd+H — 打开查找替换面板
// Enter — 下一个
// Shift+Enter — 上一个
// Escape — 关闭
```
### 自定义工具栏按钮
```javascript
editor.addToolbarButton({
name: 'timestamp',
title: '插入时间戳',
icon: '<svg>...</svg>', // SVG 字符串
action: () => {
editor.insert(`\n${new Date().toISOString()}\n`);
},
className: 'my-btn', // 可选
});
```
### PluginManager 与 pluginUtils
```javascript
import { pluginUtils, PluginManager } from '@metona-team/metona-editor';
// 创建独立管理器
const pm = new PluginManager();
pm.register('my', myPlugin);
pm.has('my'); // true
pm.get('my'); // myPlugin
pm.enable('my');
pm.disable('my');
pm.unregister('my');
pm.destroy();
// pluginUtils 工具集
pluginUtils.createPlugin({ name: 'x', install: () => {} });
pluginUtils.validatePlugin(plugin); // => { valid: boolean, errors: string[] }
pluginUtils.getPreset('autoSave'); // => 副本
pluginUtils.getAllPresets(); // => { autoSave, exportTool, searchReplace }
```
---
## 主题系统
### 内置主题
```javascript
MeEditor.setTheme('light'); // 亮色
MeEditor.setTheme('dark'); // 暗色
MeEditor.setTheme('warm'); // 暖色
MeEditor.setTheme('auto'); // 跟随系统(默认)
```
### CSS 变量定制
```css
:root {
--md-bg: #ffffff;
--md-text: #1f2937;
--md-border: rgba(0, 0, 0, 0.08);
--md-shadow: 0 10px 36px -10px rgba(0,0,0,0.18);
--md-toolbar-bg: rgba(248, 249, 250, 0.92);
--md-textarea-bg: #ffffff;
--md-preview-bg: #ffffff;
--md-code-bg: rgba(243, 244, 246, 1);
--md-code-text: #1f2937;
--md-accent: #3b82f6;
--md-muted: #6b7280;
--md-radius: 10px;
--md-font: -apple-system, "Segoe UI", "PingFang SC", sans-serif;
--md-mono: "SF Mono", "Consolas", monospace;
}
```
### 注册自定义主题
```javascript
import { themeUtils } from '@metona-team/metona-editor';
themeUtils.registerTheme('ocean', {
bg: '#001122',
text: '#aabbcc',
border: '#003344',
accent: '#00ddff',
// 未提供字段从 light 主题继承
});
themeUtils.applyTheme('ocean');
themeUtils.hasTheme('ocean'); // true
themeUtils.unregisterTheme('ocean'); // 移除(内置主题不可移除)
```
### 主题监听
```javascript
const unsub = themeUtils.addThemeListener((theme, resolved) => {
console.log('主题切换:', theme, '=>', resolved);
});
unsub(); // 取消监听
```
### 持久化
主题会自动保存到 `localStorage`key 为 `metona-editor-theme``initTheme()` 在库加载时自动调用。
---
## 国际化
### 切换语言
```javascript
MeEditor.setLocale('en-US');
MeEditor.setLocale('zh-CN');
```
### 翻译函数
```javascript
import { i18nUtils } from '@metona-team/metona-editor';
i18nUtils.t('bold'); // => '粗体'zh-CN
i18nUtils.t('bold'); // => 'Bold'en-US
// 插值
i18nUtils.t('greeting', { name: '世界' });
// 翻译值 '你好,{name}' => '你好,世界!'
```
### 添加自定义语言
```javascript
i18nUtils.addTranslations('ja', {
bold: '太字',
italic: '斜体',
// ...其他翻译
});
MeEditor.setLocale('ja');
```
### 格式化
```javascript
i18nUtils.formatNumber(1234567); // => '1,234,567'
i18nUtils.formatCurrency(99.99, 'USD'); // => '$99.99'
i18nUtils.formatDate('2024-01-15'); // => '1/15/2024'
i18nUtils.formatNumber(0.5, { style: 'percent' }); // => '50%'
```
### 语言元信息
```javascript
i18nUtils.getSupportedLocales(); // => ['zh-CN', 'en-US', ...]
i18nUtils.isLocaleSupported('zh-CN');// => true
i18nUtils.getLocaleName('zh-CN'); // => '简体中文'
i18nUtils.getLocaleDirection('ar'); // => 'rtl'
```
### 语言监听
```javascript
const unsub = i18nUtils.addLocaleListener((locale) => {
console.log('语言切换:', locale);
});
unsub();
```
语言会自动保存到 `localStorage`key 为 `metona-editor-locale`
---
## Markdown 解析器
MetonaEditor 内置自研轻量解析器,支持 CommonMark 子集 + GFM 扩展:
### 支持的语法
| 语法 | 示例 | 输出 |
|------|------|------|
| 标题 | `# H1` `## H2` | `<h1>` ~ `<h6>` |
| 段落 | 纯文本 | `<p>` |
| 粗体 | `**bold**` `__bold__` | `<strong>` |
| 斜体 | `*italic*` `_italic_` | `<em>` |
| 删除线 | `~~text~~` | `<del>` |
| 行内代码 | `` `code` `` | `<code>` |
| 代码块 | ` ```lang ` | `<pre><code class="language-lang">` |
| 引用 | `> quote` | `<blockquote>` |
| 无序列表 | `- item` / `* item` / `+ item` | `<ul><li>` |
| 有序列表 | `1. item` | `<ol><li>` |
| 任务列表 | `- [x] done` | `<li class="me-task-item">` |
| 水平线 | `---` `***` `___` | `<hr>` |
| 表格 | `\| a \| b \|` | `<table>` |
| 链接 | `[text](url)` | `<a>` |
| 图片 | `![alt](url)` | `<img>` |
| 自动链接 | `<https://...>` | `<a>` |
### XSS 防护
```javascript
parseMarkdown('[click](javascript:alert(1))');
// => '<p><a href="">click</a></p>'(危险协议被过滤)
parseMarkdown('<script>alert(1)</script>');
// => '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>'HTML 转义)
```
过滤的协议:`javascript:` `vbscript:` `file:` `data:`(非 image
### 代码高亮钩子
```javascript
MeEditor.create(container, {
highlight: (code, lang) => {
// 返回高亮后的 HTML,例如集成 Prism.js / highlight.js
return Prism.highlight(code, Prism.languages[lang], lang);
},
});
```
未提供 `highlight` 时,代码块以纯文本输出。`highlight` 抛错时自动回退为转义输出。
### 替换整个解析器
```javascript
import { marked } from 'marked';
MeEditor.create(container, {
render: (md, env) => {
// 完全替换内置解析器
return marked.parse(md);
},
});
```
### HTML 净化钩子
```javascript
import DOMPurify from 'dompurify';
MeEditor.create(container, {
sanitize: (html) => DOMPurify.sanitize(html),
});
```
---
## TypeScript 支持
库自带完整的 TypeScript 类型声明:
```typescript
import MeEditor, { MarkdownEditor, MarkdownEditorOptions } from '@metona-team/metona-editor';
const editor: MarkdownEditor = MeEditor.create<HTMLElement>(
document.getElementById('editor')!,
{
value: '# Hello',
mode: 'split',
onChange: (value: string, editor: MarkdownEditor) => {
console.log(value);
},
}
);
editor.setValue('new content');
const html: string = editor.getHTML();
const stats = editor.getStats();
```
类型声明覆盖:所有配置项、API 方法、插件接口、事件回调、常量。
---
## 测试
```bash
# 运行全部测试
npm test
# 监听模式
npm run test:watch
# 生成覆盖率报告
npm run test:coverage
```
### 测试覆盖
| 文件 | 语句覆盖率 | 分支覆盖率 | 函数覆盖率 |
|------|-----------|-----------|-----------|
| animations.js | 100% | 100% | 100% |
| constants.js | 100% | 100% | 100% |
| icons.js | 100% | 100% | 100% |
| locales.js | 100% | 100% | 100% |
| parser.js | 95.6% | 87.6% | 100% |
| utils.js | 96.2% | 87.1% | 100% |
| themes.js | 92.9% | 84.2% | 96.7% |
| i18n.js | 86.6% | 69.5% | 93.9% |
| plugins.js | 79.9% | 59.6% | 73.8% |
| core.js | 72.9% | 60.9% | 69.4% |
| **总体** | **80.6%** | **67.8%** | **79.3%** |
测试文件位于 [tests/](./tests) 目录,共 **291 个测试用例**,覆盖:
- `parser.test.js` — Markdown 解析器全部语法 + XSS 防护
- `utils.test.js` — 工具函数(generateId / escapeHTML / debounce / throttle / deepMerge 等)
- `core.test.js` — MarkdownEditor 构造、内容 API、exec 命令、历史栈、模式、事件、插件、销毁
- `plugins.test.js` — 预设插件(autoSave / exportTool / searchReplace+ PluginManager + pluginUtils
- `animations.test.js` — 动画注册与查询
- `themes.test.js` — 主题切换、持久化、监听、自定义注册
- `i18n.test.js` — 翻译、语言切换、格式化、监听
---
## 构建
```bash
# 安装依赖
npm install
# 构建(生成 5 个产物)
npm run build
# 开发模式(监听 + 热更新)
npm run dev
# 类型检查
npm run typecheck
# 代码检查
npm run lint
npm run lint:fix
# 格式化
npm run format
# 生成 JSDoc 文档
npm run docs
```
### 构建产物
```
dist/
├── metona-editor.js # UMD(浏览器直接引入)
├── metona-editor.min.js # UMD 压缩版(CDN
├── metona-editor.esm.js # ES Module
├── metona-editor.cjs.js # CommonJS
└── metona-editor.d.ts # TypeScript 类型声明
```
---
## 浏览器兼容性
支持所有现代浏览器(ES2020+):
- Chrome / Edge 80+
- Firefox 75+
- Safari 13+
- 不支持 IE 11
---
## License
[MIT](./LICENSE) © MetonaTeam
## 相关链接
- [npm 包主页](https://www.npmjs.com/package/@metona-team/metona-editor)
- [问题反馈](https://git.metona.cn/MetonaTeam/MetonaEditor/issues)
- [在线演示](./site/demo.html)
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
modules: 'commonjs',
},
],
],
plugins: [
'@babel/plugin-transform-modules-commonjs',
],
};
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# MetonaEditor 构建脚本
echo "🍞 MetonaEditor 构建脚本"
echo "========================"
if ! command -v node &> /dev/null; then
echo "❌ 错误: 未找到Node.js"
exit 1
fi
if ! command -v npm &> /dev/null; then
echo "❌ 错误: 未找到npm"
exit 1
fi
echo "✅ Node.js版本: $(node -v)"
echo "✅ npm版本: $(npm -v)"
echo ""
echo "📦 安装依赖..."
npm install
if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败"
exit 1
fi
echo "✅ 依赖安装完成"
echo ""
echo "🧪 运行测试..."
npm test
if [ $? -ne 0 ]; then
echo "❌ 测试失败"
exit 1
fi
echo "✅ 测试通过"
echo ""
echo "🔨 构建项目..."
npm run build
if [ $? -ne 0 ]; then
echo "❌ 构建失败"
exit 1
fi
echo "✅ 构建完成"
echo ""
echo "📊 构建结果:"
echo "========================"
ls -lh dist/
echo ""
echo "✅ 构建成功完成!"
echo ""
echo "📁 文件结构:"
echo " - dist/metona-editor.js (UMD格式)"
echo " - dist/metona-editor.esm.js (ES Module格式)"
echo " - dist/metona-editor.cjs.js (CommonJS格式)"
echo " - dist/metona-editor.min.js (压缩版本)"
echo " - dist/metona-editor.d.ts (TypeScript声明)"
echo ""
echo "🚀 使用方法:"
echo " 1. 浏览器: <script src='dist/metona-editor.js'></script>"
echo " 2. ES Module: import MeEditor from 'dist/metona-editor.esm.js'"
echo " 3. CommonJS: const MeEditor = require('dist/metona-editor.cjs.js')"
echo ""
echo "📝 示例:"
echo " 打开 site/index.html 查看官网"
echo " 打开 site/demo.html 查看演示"
echo ""
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.js$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(@rollup)/)',
],
moduleFileExtensions: ['js', 'json'],
collectCoverageFrom: [
'src/**/*.js',
'!src/index.js',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
verbose: true,
};
+7444
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
{
"name": "@metona-team/metona-editor",
"version": "0.1.0",
"description": "轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用。",
"main": "dist/metona-editor.js",
"module": "src/index.js",
"unpkg": "dist/metona-editor.min.js",
"jsdelivr": "dist/metona-editor.min.js",
"types": "types/index.d.ts",
"exports": {
".": {
"import": "./src/index.js",
"require": "./dist/metona-editor.js",
"types": "./types/index.d.ts"
}
},
"files": [
"dist/",
"src/",
"types/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build",
"docs": "jsdoc src/ -d docs",
"example": "serve examples/"
},
"repository": {
"type": "git",
"url": "git+https://git.metona.cn/MetonaTeam/MetonaEditor.git"
},
"keywords": [
"markdown",
"editor",
"wysiwyg",
"ui",
"component",
"lightweight",
"zero-dependency"
],
"author": "thzxx",
"license": "MIT",
"bugs": {
"url": "https://git.metona.cn/MetonaTeam/MetonaEditor/issues"
},
"homepage": "https://git.metona.cn/MetonaTeam/MetonaEditor#readme",
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/preset-env": "^7.22.0",
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-terser": "^0.4.0",
"@types/jest": "^29.5.0",
"babel-jest": "^29.5.0",
"eslint": "^8.40.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"prettier": "^2.8.0",
"rollup": "^3.20.0",
"rollup-plugin-dts": "^5.3.0",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-serve": "^2.0.1",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
+122
View File
@@ -0,0 +1,122 @@
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import dts from 'rollup-plugin-dts';
import serve from 'rollup-plugin-serve';
import livereload from 'rollup-plugin-livereload';
const isDev = process.env.ROLLUP_WATCH;
const isProd = process.env.NODE_ENV === 'production';
const baseConfig = {
input: 'src/index.js',
plugins: [
resolve(),
commonjs(),
],
};
const devPlugins = isDev ? [
serve({
open: true,
contentBase: ['site', 'dist'],
port: 3001,
}),
livereload({
watch: ['src', 'site'],
}),
] : [];
const prodPlugins = isProd ? [
terser({
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn'],
},
format: {
comments: false,
},
}),
] : [];
export default [
{
...baseConfig,
output: {
file: 'dist/metona-editor.js',
format: 'umd',
name: 'MeEditor',
exports: 'named',
sourcemap: !isProd,
globals: {},
},
plugins: [
...baseConfig.plugins,
...devPlugins,
...prodPlugins,
],
},
{
...baseConfig,
output: {
file: 'dist/metona-editor.esm.js',
format: 'es',
exports: 'named',
sourcemap: !isProd,
},
plugins: [
...baseConfig.plugins,
...prodPlugins,
],
},
{
...baseConfig,
output: {
file: 'dist/metona-editor.cjs.js',
format: 'cjs',
exports: 'named',
sourcemap: !isProd,
},
plugins: [
...baseConfig.plugins,
...prodPlugins,
],
},
{
...baseConfig,
output: {
file: 'dist/metona-editor.min.js',
format: 'umd',
name: 'MeEditor',
exports: 'named',
sourcemap: false,
globals: {},
},
plugins: [
...baseConfig.plugins,
terser({
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn'],
passes: 2,
},
format: {
comments: false,
},
mangle: {
toplevel: true,
},
}),
],
},
{
input: 'types/index.d.ts',
output: {
file: 'dist/metona-editor.d.ts',
format: 'es',
},
plugins: [dts()],
},
];
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# MetonaEditor 本地开发服务器
echo "🍞 MetonaEditor 开发服务器"
echo "========================="
if command -v python3 &> /dev/null; then
PYTHON=python3
elif command -v python &> /dev/null; then
PYTHON=python
else
echo "❌ 错误: 未找到Python"
exit 1
fi
PORT=${1:-3001}
echo "✅ Python: $($PYTHON --version 2>&1)"
echo "🌐 启动服务器..."
echo ""
echo "📁 访问地址:"
echo " http://localhost:$PORT/site/index.html"
echo " http://localhost:$PORT/site/demo.html"
echo " http://localhost:$PORT/site/docs.html"
echo ""
echo "按 Ctrl+C 停止服务器"
echo ""
cd "$(dirname "$0")"
$PYTHON -m http.server $PORT
+400
View File
@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MetonaEditor · 功能演示</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f6f8;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.1);
--danger: #ef4444;
--success: #10b981;
--code-bg: #1c2029;
--code-text: #e6e8eb;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 24px;
}
.wrap { max-width: 1120px; margin: 0 auto; }
/* 顶栏 */
.topbar {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
margin-bottom: 20px;
}
.topbar h1 { font-size: 22px; font-weight: 700; display: flex; align-items: center; gap: 12px; }
.topbar h1 a {
color: var(--accent); text-decoration: none;
font-size: 13px; font-weight: 400;
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color 0.15s;
}
.topbar h1 a:hover { border-color: var(--accent); }
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.ctrl-label { font-size: 12px; color: var(--muted); margin-right: 4px; }
.ctrl-group {
display: inline-flex; gap: 0;
background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
}
.ctrl-btn {
padding: 7px 14px; border: 0; background: transparent; cursor: pointer;
font-size: 13px; color: var(--text); transition: background 0.15s, color 0.15s;
}
.ctrl-btn:hover { background: #f3f4f6; }
.ctrl-btn.active { background: var(--accent); color: #fff; }
.ctrl-btn + .ctrl-btn { border-left: 1px solid var(--border); }
/* 编辑器 */
#editor { margin-bottom: 24px; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 20px -8px rgba(0,0,0,0.1); }
/* 网格布局 */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
}
.panel-title {
font-size: 13px; color: var(--muted);
margin-bottom: 10px; font-weight: 600;
display: flex; align-items: center; justify-content: space-between;
}
.panel-title .count {
background: var(--accent-soft); color: var(--accent);
padding: 2px 8px; border-radius: 999px; font-size: 11px;
}
/* 控制台 */
.console {
background: var(--code-bg); color: var(--code-text);
border-radius: 8px; padding: 14px 16px;
font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 12.5px;
min-height: 140px; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-all;
line-height: 1.7;
}
.console:empty::before { content: "点击下方按钮查看输出..."; color: #6b7280; }
.console .log-time { color: #5c6370; }
.console .log-label { color: #82aaff; font-weight: 600; }
/* 事件流 */
.events {
background: var(--code-bg); color: var(--code-text);
border-radius: 8px; padding: 14px 16px;
font-family: "SF Mono", Consolas, monospace; font-size: 12px;
min-height: 140px; max-height: 240px; overflow: auto;
line-height: 1.7;
}
.events:empty::before { content: "事件流将在这里实时显示..."; color: #6b7280; }
.event-line { padding: 2px 0; }
.event-line .ev-name { color: #c3e88d; }
.event-line .ev-time { color: #5c6370; }
/* API 按钮 */
.api-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
margin-top: 12px;
}
.api-btn {
padding: 9px 12px; border: 1px solid var(--border); background: var(--card);
border-radius: 7px; cursor: pointer; font-size: 12.5px; color: var(--text);
transition: all 0.15s; text-align: center; font-family: "SF Mono", Consolas, monospace;
}
.api-btn:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
.api-btn:active { transform: translateY(0); }
.api-btn.danger:hover { border-color: var(--danger); color: var(--danger); }
.api-btn.success:hover { border-color: var(--success); color: var(--success); }
.hint {
font-size: 12px; color: var(--muted);
margin-top: 14px; padding: 10px 14px;
background: var(--accent-soft); border-radius: 8px;
border-left: 3px solid var(--accent);
}
code { background: #f3f4f6; padding: 1px 6px; border-radius: 4px; font-size: 12px; font-family: "SF Mono", Consolas, monospace; }
/* 状态指示 */
.status-bar {
display: flex; gap: 16px; flex-wrap: wrap;
padding: 10px 14px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; margin-bottom: 16px;
font-size: 12px; color: var(--muted);
}
.status-item { display: inline-flex; align-items: center; gap: 6px; }
.status-item strong { color: var(--text); font-weight: 600; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--success);
}
.status-dot.destroyed { background: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<h1>
MetonaEditor 功能演示
<a href="index.html">← 返回首页</a>
</h1>
<div class="controls">
<span class="ctrl-label">主题</span>
<div class="ctrl-group" id="themeCtrl">
<button class="ctrl-btn" data-theme="light">亮色</button>
<button class="ctrl-btn" data-theme="dark">暗色</button>
<button class="ctrl-btn" data-theme="warm">暖色</button>
</div>
<span class="ctrl-label">语言</span>
<div class="ctrl-group" id="localeCtrl">
<button class="ctrl-btn" data-locale="zh-CN">中文</button>
<button class="ctrl-btn" data-locale="en-US">English</button>
</div>
</div>
</div>
<div class="status-bar" id="statusBar">
<div class="status-item">
<span class="status-dot" id="statusDot"></span>
<span>状态:</span><strong id="stState">运行中</strong>
</div>
<div class="status-item"><span>模式:</span><strong id="stMode">split</strong></div>
<div class="status-item"><span>主题:</span><strong id="stTheme">light</strong></div>
<div class="status-item"><span>语言:</span><strong id="stLocale">zh-CN</strong></div>
<div class="status-item"><span>字数:</span><strong id="stWords">0</strong></div>
<div class="status-item"><span>全屏:</span><strong id="stFs"></strong></div>
</div>
<div id="editor"></div>
<div class="grid">
<div class="panel">
<div class="panel-title">
<span>API 控制台</span>
<span class="count" id="apiCount">0 次调用</span>
</div>
<div class="console" id="console"></div>
<div class="api-grid">
<button class="api-btn" data-api="getValue">getValue()</button>
<button class="api-btn" data-api="getHTML">getHTML()</button>
<button class="api-btn" data-api="getStats">getStats()</button>
<button class="api-btn" data-api="getStatus">getStatus()</button>
<button class="api-btn" data-api="insert">insert()</button>
<button class="api-btn" data-api="exec-bold">exec('bold')</button>
<button class="api-btn" data-api="exec-h1">exec('h1')</button>
<button class="api-btn" data-api="setMode-edit">编辑模式</button>
<button class="api-btn" data-api="setMode-split">分屏模式</button>
<button class="api-btn" data-api="setMode-preview">预览模式</button>
<button class="api-btn" data-api="fullscreen">切换全屏</button>
<button class="api-btn" data-api="undo">undo()</button>
<button class="api-btn" data-api="redo">redo()</button>
<button class="api-btn success" data-api="exportMD">导出 .md</button>
<button class="api-btn success" data-api="exportHTML">导出 .html</button>
<button class="api-btn" data-api="restoreDraft">恢复草稿</button>
<button class="api-btn" data-api="clearDraft">清空草稿</button>
<button class="api-btn danger" data-api="destroy">destroy()</button>
</div>
</div>
<div class="panel">
<div class="panel-title">
<span>事件流(实时)</span>
<span class="count" id="evCount">0 个事件</span>
</div>
<div class="events" id="events"></div>
</div>
</div>
<div class="hint">
<strong>快捷键提示:</strong>
编辑区内按 <code>Ctrl+B</code> 加粗、<code>Ctrl+I</code> 斜体、<code>Ctrl+K</code> 插入链接、
<code>Ctrl+Z</code> 撤销、<code>Ctrl+S</code> 触发保存、<code>Ctrl+F</code> 查找、<code>Ctrl+H</code> 替换、
<code>Tab</code> 缩进、<code>Esc</code> 关闭面板。
</div>
</div>
<script src="../dist/metona-editor.js"></script>
<script>
let apiCalls = 0;
let eventCount = 0;
const log = (label, val) => {
apiCalls++;
document.getElementById('apiCount').textContent = apiCalls + ' 次调用';
const c = document.getElementById('console');
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const text = typeof val === 'string' ? val : JSON.stringify(val, null, 2);
const truncated = text.length > 500 ? text.slice(0, 500) + '\n... (已截断)' : text;
c.innerHTML = `<span class="log-time">[${now}]</span> <span class="log-label">${label}</span>\n${escapeHtml(truncated)}`;
};
const logEvent = (name, data) => {
eventCount++;
document.getElementById('evCount').textContent = eventCount + ' 个事件';
const el = document.getElementById('events');
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const dataStr = data !== undefined ? ' ' + (typeof data === 'string' ? data.slice(0, 40) : JSON.stringify(data).slice(0, 40)) : '';
const line = document.createElement('div');
line.className = 'event-line';
line.innerHTML = `<span class="ev-time">${now}</span> <span class="ev-name">${name}</span>${escapeHtml(dataStr)}`;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
// 限制最多 50 条
while (el.children.length > 50) el.removeChild(el.firstChild);
};
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function updateStatus() {
if (editor.isDestroyed()) {
document.getElementById('stState').textContent = '已销毁';
document.getElementById('statusDot').classList.add('destroyed');
return;
}
const s = editor.getStatus();
const stats = editor.getStats();
document.getElementById('stState').textContent = '运行中';
document.getElementById('stMode').textContent = s.mode;
document.getElementById('stTheme').textContent = s.theme;
document.getElementById('stLocale').textContent = s.locale;
document.getElementById('stWords').textContent = stats.words;
document.getElementById('stFs').textContent = s.fullscreen ? '是' : '否';
}
// 创建编辑器,启用全部预设插件
const editor = MeEditor.create('#editor', {
mode: 'split',
height: 420,
theme: 'light',
locale: 'zh-CN',
value: [
'# 功能演示',
'',
'这里是 MetonaEditor 的完整功能演示。',
'',
'## 行内格式',
'',
'- 支持 **加粗**、*斜体*、~~删除线~~、`行内代码`',
'- 支持 [链接](https://example.com) 与 ![图片](https://placehold.co/100)',
'',
'## 表格',
'',
'| 名称 | 值 |',
'| --- | --- |',
'| 版本 | 0.1.0 |',
'| 依赖 | 零 |',
'| 测试 | 390+ |',
'',
'## 任务列表',
'',
'- [x] 分屏预览',
'- [x] 插件系统',
'- [x] 主题与国际化',
'- [ ] 你的创意',
'',
'## 代码块',
'',
'```js',
'function hello(name) {',
' return `Hello, ${name}!`;',
'}',
'```',
'',
'> 试试顶部的主题/语言切换与下方的 API 按钮。',
'',
'右侧事件流会实时显示编辑器触发的事件。'
].join('\n'),
plugins: ['autoSave', 'exportTool', 'searchReplace'],
onChange: (val) => { logEvent('change', val); updateStatus(); },
onInput: (val) => logEvent('input', val),
onFocus: () => logEvent('focus'),
onBlur: () => logEvent('blur'),
onSave: () => logEvent('save'),
onModeChange: (mode) => { logEvent('modeChange', mode); updateStatus(); },
onFullscreen: (fs) => { logEvent('fullscreen', fs); updateStatus(); },
});
// 监听 autosave 事件(来自 autoSave 插件)
editor.on('autosave', (data) => logEvent('autosave', data));
// 主题切换
document.getElementById('themeCtrl').addEventListener('click', (e) => {
const btn = e.target.closest('[data-theme]');
if (!btn) return;
document.querySelectorAll('#themeCtrl .ctrl-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
MeEditor.setTheme(btn.dataset.theme);
editor.el.className = editor.el.className.replace(/me-theme-\w+/g, '') + ' me-theme-' + btn.dataset.theme;
updateStatus();
});
document.querySelector('#themeCtrl [data-theme="light"]').classList.add('active');
// 语言切换
document.getElementById('localeCtrl').addEventListener('click', (e) => {
const btn = e.target.closest('[data-locale]');
if (!btn) return;
document.querySelectorAll('#localeCtrl .ctrl-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
MeEditor.setLocale(btn.dataset.locale);
updateStatus();
});
document.querySelector('#localeCtrl [data-locale="zh-CN"]').classList.add('active');
// API 控制台
document.querySelector('.api-grid').addEventListener('click', (e) => {
const btn = e.target.closest('[data-api]');
if (!btn) return;
const api = btn.dataset.api;
if (editor.isDestroyed()) { log('error', '编辑器已销毁,请刷新页面'); return; }
switch (api) {
case 'getValue': log('getValue', editor.getValue().slice(0, 200) + '...'); break;
case 'getHTML': log('getHTML', editor.getHTML()); break;
case 'getStats': log('getStats', editor.getStats()); break;
case 'getStatus': log('getStatus', editor.getStatus()); break;
case 'insert': editor.insert('\n\n## 新插入的标题\n'); log('insert', '已插入内容'); break;
case 'exec-bold': editor.exec('bold'); log('exec', "执行 'bold'"); break;
case 'exec-h1': editor.exec('h1'); log('exec', "执行 'h1'"); break;
case 'setMode-edit': editor.setMode('edit'); log('setMode', '切换到 edit'); break;
case 'setMode-split': editor.setMode('split'); log('setMode', '切换到 split'); break;
case 'setMode-preview': editor.setMode('preview'); log('setMode', '切换到 preview'); break;
case 'fullscreen': editor.toggleFullscreen(); log('fullscreen', '已切换全屏'); break;
case 'undo': editor.undo(); log('undo', '撤销'); break;
case 'redo': editor.redo(); log('redo', '重做'); break;
case 'exportMD': editor.exportMarkdown(); log('export', '已触发 .md 下载'); break;
case 'exportHTML': editor.exportHTML('demo.html', { title: 'MetonaEditor 导出' }); log('export', '已触发 .html 下载'); break;
case 'restoreDraft': editor.restoreDraft(); log('restoreDraft', '已从 localStorage 恢复草稿'); break;
case 'clearDraft': editor.clearDraft(); log('clearDraft', '已清空草稿'); break;
case 'destroy': editor.destroy(); log('destroy', '编辑器已销毁'); updateStatus(); break;
}
});
// 初始状态
updateStatus();
logEvent('ready', '编辑器已就绪');
</script>
</body>
</html>
+728
View File
@@ -0,0 +1,728 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MetonaEditor — 现代化零依赖 Markdown 编辑器</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.6;
min-height: 100vh;
overflow-x: hidden;
}
.container { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
/* 导航 */
nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(15, 17, 23, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
.nav-inner {
max-width: 1080px;
margin: 0 auto;
padding: 14px 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: 16px;
}
.nav-brand .logo-mark {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--gradient);
display: flex; align-items: center; justify-content: center;
font-weight: 800; color: #fff; font-size: 15px;
}
.nav-links { display: flex; gap: 28px; align-items: center; }
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 14px;
transition: color 0.15s ease;
}
.nav-links a:hover { color: var(--text); }
.nav-cta {
padding: 7px 16px;
border-radius: 8px;
background: var(--accent-soft);
color: var(--accent) !important;
border: 1px solid rgba(59,130,246,0.3);
font-weight: 500;
}
@media (max-width: 640px) {
.nav-links a:not(.nav-cta) { display: none; }
}
/* Hero */
header.hero {
text-align: center;
padding: 96px 24px 64px;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, var(--accent-soft), transparent 70%),
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(139,92,246,0.08), transparent 70%),
var(--bg);
position: relative;
}
.hero-deco {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
overflow: hidden;
}
.hero-deco::before, .hero-deco::after {
content: '';
position: absolute;
width: 500px; height: 500px;
border-radius: 50%;
filter: blur(80px);
opacity: 0.15;
}
.hero-deco::before {
background: var(--accent);
top: -200px; left: -100px;
}
.hero-deco::after {
background: var(--accent-3);
top: -100px; right: -150px;
}
.hero > * { position: relative; z-index: 1; }
.logo {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 28px;
}
h1 {
font-size: clamp(40px, 6vw, 68px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.05;
margin-bottom: 22px;
}
h1 .grad {
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: clamp(17px, 2vw, 21px);
color: var(--muted);
max-width: 640px;
margin: 0 auto 40px;
}
.badges {
display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;
margin-bottom: 44px;
}
.badge {
padding: 6px 14px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 13px;
color: var(--text);
transition: border-color 0.2s ease, color 0.2s ease;
}
.badge:hover { border-color: var(--accent); color: var(--accent); }
.badge.accent { border-color: var(--accent); color: var(--accent); }
.cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 13px 26px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer; border: 0;
}
.btn-primary { background: var(--gradient); color: #fff; box-shadow: 0 8px 24px -8px rgba(59,130,246,0.6); }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 12px 32px -8px rgba(59,130,246,0.7); }
.btn-ghost { background: var(--card); color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-2px); }
/* 统计条 */
.stats {
display: flex;
justify-content: center;
gap: 48px;
flex-wrap: wrap;
margin-top: 56px;
padding-top: 40px;
border-top: 1px solid var(--border);
}
.stat { text-align: center; }
.stat-num {
font-size: 32px;
font-weight: 800;
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
/* Section */
section { padding: 72px 0; }
.section-eyebrow {
display: inline-block;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 12px;
font-weight: 600;
}
.section-title {
font-size: clamp(26px, 3vw, 34px);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 14px;
}
.section-sub {
color: var(--muted);
margin-bottom: 40px;
font-size: 16px;
max-width: 600px;
}
/* Demo editor */
#editor {
border-radius: 14px;
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
/* Features */
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 18px; }
.feature {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 26px;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.feature:hover {
border-color: var(--accent);
transform: translateY(-4px);
background: var(--card-hover);
}
.feature-icon {
width: 44px; height: 44px;
border-radius: 11px;
background: var(--accent-soft);
color: var(--accent);
display: flex; align-items: center; justify-content: center;
margin-bottom: 18px;
}
.feature h3 { font-size: 17px; margin-bottom: 8px; font-weight: 600; }
.feature p { color: var(--muted); font-size: 14px; line-height: 1.65; }
/* Code */
.code-block {
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 26px;
overflow-x: auto;
font-family: "SF Mono", "Cascadia Code", Consolas, monospace;
font-size: 13.5px;
line-height: 1.8;
color: #e6e8eb;
}
.code-block .c-key { color: #c792ea; }
.code-block .c-str { color: #c3e88d; }
.code-block .c-fn { color: #82aaff; }
.code-block .c-com { color: #5c6370; font-style: italic; }
.code-block .c-kw { color: #89ddff; }
.code-block .c-num { color: #f78c6c; }
/* API 速览 */
.api-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.api-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px 22px;
}
.api-card h4 {
font-size: 14px;
color: var(--accent);
margin-bottom: 14px;
font-weight: 600;
letter-spacing: 0.02em;
}
.api-card ul { list-style: none; }
.api-card li {
font-family: "SF Mono", Consolas, monospace;
font-size: 13px;
padding: 5px 0;
color: var(--text);
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.api-card li:last-child { border-bottom: 0; }
.api-card li .ret {
color: var(--muted);
font-size: 12px;
float: right;
}
/* 快捷键 */
.shortcuts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.shortcut {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
}
.shortcut-label { font-size: 14px; color: var(--text); }
.keys { display: flex; gap: 4px; }
.key {
display: inline-flex;
align-items: center;
padding: 3px 8px;
background: var(--bg-soft);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
color: var(--muted);
}
footer {
border-top: 1px solid var(--border);
padding: 48px 24px;
text-align: center;
color: var(--muted);
font-size: 14px;
background: var(--bg-soft);
}
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }
.footer-links {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 16px;
flex-wrap: wrap;
}
/* 编辑器在深色页面里的适配:让 wrapper 跟随页面深色 */
.me-wrapper { background: var(--card); }
</style>
</head>
<body>
<nav>
<div class="nav-inner">
<a class="nav-brand" href="#">
<span class="logo-mark">M</span>
MetonaEditor
</a>
<div class="nav-links">
<a href="#features">特性</a>
<a href="#demo">体验</a>
<a href="#api">API</a>
<a href="#shortcuts">快捷键</a>
<a class="nav-cta" href="demo.html">在线演示</a>
</div>
</div>
</nav>
<header class="hero">
<div class="hero-deco"></div>
<div class="logo"><span class="logo-mark">M</span> METONA EDITOR</div>
<h1>现代化零依赖的<br/><span class="grad">Markdown 编辑器</span></h1>
<p class="tagline">单文件开箱即用,内置轻量解析器、分屏预览、插件系统与中文优先的国际化。为中文场景与现代 Web 应用而生。</p>
<div class="badges">
<span class="badge accent">零依赖</span>
<span class="badge">UMD / ESM / CJS</span>
<span class="badge">TypeScript</span>
<span class="badge">主题可定制</span>
<span class="badge">中文优先</span>
<span class="badge">MIT</span>
</div>
<div class="cta">
<a class="btn btn-primary" href="demo.html">在线体验 →</a>
<a class="btn btn-ghost" href="#features">了解特性</a>
</div>
<div class="stats">
<div class="stat">
<div class="stat-num">0</div>
<div class="stat-label">运行时依赖</div>
</div>
<div class="stat">
<div class="stat-num">390+</div>
<div class="stat-label">单元测试</div>
</div>
<div class="stat">
<div class="stat-num">80%+</div>
<div class="stat-label">测试覆盖率</div>
</div>
<div class="stat">
<div class="stat-num">15KB</div>
<div class="stat-label">gzip 体积</div>
</div>
</div>
</header>
<section id="demo">
<div class="container">
<span class="section-eyebrow">Live Demo</span>
<h2 class="section-title">立即体验</h2>
<p class="section-sub">下面是一个真实可用的编辑器实例,试着在左侧编辑,右侧会实时预览。</p>
<div id="editor"></div>
</div>
</section>
<section id="features">
<div class="container">
<span class="section-eyebrow">Features</span>
<h2 class="section-title">为什么选择 MetonaEditor</h2>
<p class="section-sub">为中文场景与现代 Web 应用而生,每一项特性都经过精心打磨。</p>
<div class="features">
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h3>真正的零依赖</h3>
<p>整个库不依赖任何第三方运行时,打包后单文件可直接 &lt;script&gt; 引入,也支持 npm 安装。</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
</div>
<h3>分屏实时预览</h3>
<p>编辑 / 分屏 / 预览三模式自由切换,分屏模式下可拖拽分隔条调整比例,滚动自动同步。</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</div>
<h3>内置轻量解析器</h3>
<p>自研 CommonMark 子集 + GFM 扩展解析器,覆盖标题、表格、任务列表、代码块等常用语法,也可整体替换。</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</div>
<h3>易扩展的插件系统</h3>
<p>内置自动保存、导出、查找替换插件;自定义插件只需实现 install / destroy 两个生命周期钩子。</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>
</div>
<h3>主题与国际化</h3>
<p>基于 CSS 变量的主题系统,内置亮 / 暗 / 暖色三套主题;中英双语开箱即用,支持 RTL 布局。</p>
</div>
<div class="feature">
<div class="feature-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<h3>XSS 安全防护</h3>
<p>所有文本经 HTML 转义,URL 过滤 javascript / vbscript / file 等危险协议,并提供 sanitize 钩子。</p>
</div>
</div>
</div>
</section>
<section>
<div class="container">
<span class="section-eyebrow">Quick Start</span>
<h2 class="section-title">三行代码即可使用</h2>
<p class="section-sub">CDN 引入或 npm 安装,都只需要极少的代码。</p>
<div class="code-block">
<span class="c-com">&lt;!-- 浏览器直接引入 --&gt;</span>
&lt;<span class="c-key">script</span> <span class="c-fn">src</span>=<span class="c-str">"dist/metona-editor.js"</span>&gt;&lt;/<span class="c-key">script</span>&gt;
&lt;<span class="c-key">div</span> <span class="c-fn">id</span>=<span class="c-str">"editor"</span>&gt;&lt;/<span class="c-key">div</span>&gt;
&lt;<span class="c-key">script</span>&gt;
<span class="c-kw">const</span> editor = <span class="c-fn">MeEditor.create</span>(<span class="c-str">'#editor'</span>, {
<span class="c-fn">mode</span>: <span class="c-str">'split'</span>,
<span class="c-fn">value</span>: <span class="c-str">'# 你好,世界'</span>,
<span class="c-fn">plugins</span>: [<span class="c-str">'autoSave'</span>, <span class="c-str">'exportTool'</span>]
});
&lt;/<span class="c-key">script</span>&gt;
<span class="c-com">// 或 npm 安装</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-kw">const</span> editor = <span class="c-fn">MeEditor.create</span>(container, {
<span class="c-fn">mode</span>: <span class="c-str">'split'</span>,
<span class="c-fn">theme</span>: <span class="c-str">'dark'</span>,
<span class="c-fn">onChange</span>: (value) =&gt; <span class="c-fn">console.log</span>(value)
});
<span class="c-com">// 链式 API</span>
editor.<span class="c-fn">exec</span>(<span class="c-str">'bold'</span>).<span class="c-fn">exec</span>(<span class="c-str">'h1'</span>).<span class="c-fn">focus</span>();
</div>
</div>
</section>
<section id="api">
<div class="container">
<span class="section-eyebrow">API Reference</span>
<h2 class="section-title">API 速览</h2>
<p class="section-sub">完整 API 文档请参考 README.md,以下是最常用的方法。</p>
<div class="api-grid">
<div class="api-card">
<h4>内容操作</h4>
<ul>
<li>getValue() <span class="ret">string</span></li>
<li>setValue(md) <span class="ret">this</span></li>
<li>getHTML() <span class="ret">string</span></li>
<li>insert(text) <span class="ret">this</span></li>
<li>wrap(before, after) <span class="ret">this</span></li>
<li>focus() / blur() <span class="ret">this</span></li>
</ul>
</div>
<div class="api-card">
<h4>命令执行</h4>
<ul>
<li>exec(action) <span class="ret">this</span></li>
<li>undo() / redo() <span class="ret">this</span></li>
<li>canUndo() <span class="ret">boolean</span></li>
<li>canRedo() <span class="ret">boolean</span></li>
</ul>
</div>
<div class="api-card">
<h4>模式与全屏</h4>
<ul>
<li>setMode(mode) <span class="ret">this</span></li>
<li>getMode() <span class="ret">string</span></li>
<li>toggleFullscreen() <span class="ret">this</span></li>
<li>isFullscreen() <span class="ret">boolean</span></li>
</ul>
</div>
<div class="api-card">
<h4>事件与插件</h4>
<ul>
<li>on(event, fn) <span class="ret">unsub</span></li>
<li>off(event, fn) <span class="ret">void</span></li>
<li>use(plugin) <span class="ret">this</span></li>
<li>getPlugins() <span class="ret">Plugin[]</span></li>
<li>addToolbarButton(cfg) <span class="ret">this</span></li>
</ul>
</div>
<div class="api-card">
<h4>统计与状态</h4>
<ul>
<li>getStats() <span class="ret">Stats</span></li>
<li>getStatus() <span class="ret">Status</span></li>
<li>enable() / disable() <span class="ret">this</span></li>
<li>destroy() <span class="ret">void</span></li>
</ul>
</div>
<div class="api-card">
<h4>静态 API</h4>
<ul>
<li>MeEditor.create(el, opts) <span class="ret">Editor</span></li>
<li>MeEditor.use(plugin) <span class="ret">void</span></li>
<li>MeEditor.setTheme(name) <span class="ret">void</span></li>
<li>MeEditor.setLocale(name) <span class="ret">void</span></li>
<li>MeEditor.on(hook, fn) <span class="ret">void</span></li>
</ul>
</div>
</div>
</div>
</section>
<section id="shortcuts">
<div class="container">
<span class="section-eyebrow">Keyboard Shortcuts</span>
<h2 class="section-title">快捷键</h2>
<p class="section-sub">Mac 用户请将 Ctrl 替换为 Cmd。</p>
<div class="shortcuts">
<div class="shortcut">
<span class="shortcut-label">加粗</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">B</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">斜体</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">I</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">行内代码</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">E</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">下划线</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">U</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">插入链接</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">K</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">标题 1/2/3</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">1/2/3</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">引用</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">Q</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">撤销</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">Z</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">重做</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">Shift+Z</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">保存</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">S</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">查找</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">F</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">查找替换</span>
<div class="keys"><span class="key">Ctrl</span><span class="key">H</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">缩进 / 反缩进</span>
<div class="keys"><span class="key">Tab</span><span class="key">Shift+Tab</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">下一个匹配</span>
<div class="keys"><span class="key">Enter</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">上一个匹配</span>
<div class="keys"><span class="key">Shift</span><span class="key">Enter</span></div>
</div>
<div class="shortcut">
<span class="shortcut-label">关闭面板</span>
<div class="keys"><span class="key">Esc</span></div>
</div>
</div>
</div>
</section>
<footer>
<div class="container">
<div class="footer-links">
<a href="demo.html">在线演示</a>
<a href="https://www.npmjs.com/package/@metona-team/metona-editor" target="_blank" rel="noopener">npm</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor" target="_blank" rel="noopener">源码仓库</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaEditor/issues" target="_blank" rel="noopener">问题反馈</a>
</div>
<div>MetonaEditor · MIT License · 由 <a href="https://git.metona.cn/MetonaTeam" target="_blank" rel="noopener">Metona Team</a> 出品</div>
</div>
</footer>
<script src="../dist/metona-editor.js"></script>
<script>
MeEditor.themes.switchTheme('dark');
MeEditor.create('#editor', {
mode: 'split',
height: 480,
theme: 'dark',
value: [
'# 欢迎使用 MetonaEditor',
'',
'一个**现代化、零依赖**的 Markdown 编辑器库。',
'',
'## 核心特性',
'',
'- 分屏实时预览,滚动同步',
'- 内置轻量解析器,支持 GFM',
'- 插件系统:`autoSave` / `exportTool` / `searchReplace`',
'- 主题与国际化,中文优先',
'- 完整 XSS 防护,安全可靠',
'',
'## 代码示例',
'',
'```js',
'const editor = MeEditor.create("#editor", {',
' mode: "split",',
' plugins: ["autoSave", "searchReplace"]',
'});',
'```',
'',
'> 试着在左侧编辑这段文字,右侧会实时更新预览。',
'',
'| 特性 | 说明 |',
'| --- | --- |',
'| 零依赖 | 无任何运行时第三方库 |',
'| 分屏 | 编辑+预览同步滚动 |',
'| 插件 | install/destroy 生命周期 |',
'| 主题 | light/dark/warm/auto |',
'',
'### 任务列表示例',
'',
'- [x] 核心编辑器实现',
'- [x] 解析器与 XSS 防护',
'- [x] 插件系统',
'- [ ] 更多预设插件',
'',
'1. 点击工具栏按钮快速插入格式',
'2. 拖拽中间分隔条调整比例',
'3. 按 `Ctrl+B` 加粗、`Ctrl+K` 插入链接',
'4. 按 `Ctrl+F` 查找、`Ctrl+S` 保存',
'',
'享受写作 ✨'
].join('\n')
});
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
/**
* MetonaEditor Animations - 动画管理
* @module animations
* @version 0.1.0
* @description 动画注册与管理
*/
import { ANIMATIONS } from './constants.js';
const animationMap = new Map();
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, config);
});
/**
* 动画工具函数
*/
export const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || ANIMATIONS[name] || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return 0;
},
cancelAll() {
// CSS动画由浏览器原生管理
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, config);
});
},
destroy() {
animationMap.clear();
},
};
export const animationPresets = {};
export const createAnimation = (config = {}) => ({
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
+194
View File
@@ -0,0 +1,194 @@
/**
* MetonaEditor Constants - 常量定义
* @module constants
* @version 0.1.0
* @description 默认配置、主题、动画、工具栏等常量
*/
import { ICONS } from './icons.js';
import { LOCALES } from './locales.js';
/**
* 默认工具栏按钮序列
* '|' 表示分组分隔符
*/
export const DEFAULT_TOOLBAR = [
'bold', 'italic', 'strikethrough', 'code', '|',
'h1', 'h2', 'h3', '|',
'quote', 'ul', 'ol', '|',
'link', 'image', 'table', 'hr', '|',
'undo', 'redo', '|',
'edit', 'split', 'preview', 'fullscreen',
];
/**
* 默认配置
*/
export const DEFAULTS = Object.freeze({
// 内容
value: '',
placeholder: '',
// 视图模式:edit / split / preview
mode: 'split',
// 高度:数字为 px,字符串原样使用
height: 400,
// 工具栏:数组或 false(隐藏)
toolbar: DEFAULT_TOOLBAR,
// 字数统计状态栏
wordCount: true,
// 自动聚焦
autofocus: false,
// 拼写检查
spellcheck: false,
// 历史栈上限
historyLimit: 100,
// 同步滚动(分屏模式)
syncScroll: true,
// 制表符插入的空格数(0 表示插入 \t)
tabSize: 2,
// 主题:light / dark / auto / warm / 自定义
theme: 'auto',
// 国际化
locale: 'zh-CN',
// 自定义 Markdown 渲染函数 (md, env) => html,覆盖内置解析器
render: null,
// 自定义代码高亮函数 (code, lang) => html
highlight: null,
// 自定义渲染前的 HTML 净化函数(接收渲染后的 HTML,返回安全 HTML)
sanitize: null,
// 外观
className: '',
style: {},
// 插件
plugins: [],
// 生命周期回调
onChange: null,
onInput: null,
onFocus: null,
onBlur: null,
onSave: null,
onModeChange: null,
onFullscreen: null,
onCreate: null,
onDestroy: null,
});
// Icons - 从 icons.js 导入并重新导出(保持单一来源)
export { ICONS };
/**
* 类型颜色配置(保留用于状态提示与按钮强调)
*/
export const TYPE_COLORS = {
success: { fg: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' },
error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
warning: { fg: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
info: { fg: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
loading: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.1)' },
default: { fg: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
};
/**
* 主题配置
*/
export const THEMES = {
light: {
bg: 'rgba(255, 255, 255, 0.96)',
text: '#1f2937',
border: 'rgba(0, 0, 0, 0.08)',
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
// 编辑器专属
toolbarBg: 'rgba(248, 249, 250, 0.92)',
textareaBg: '#ffffff',
previewBg: '#ffffff',
codeBg: 'rgba(243, 244, 246, 1)',
codeText: '#1f2937',
accent: '#3b82f6',
muted: '#6b7280',
},
dark: {
bg: 'rgba(28, 32, 40, 0.94)',
text: '#e6e8eb',
border: 'rgba(255, 255, 255, 0.1)',
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
toolbarBg: 'rgba(22, 26, 33, 0.92)',
textareaBg: '#1c2028',
previewBg: '#1c2028',
codeBg: 'rgba(15, 18, 24, 1)',
codeText: '#e6e8eb',
accent: '#60a5fa',
muted: '#9ca3af',
},
auto: 'auto',
warm: {
bg: 'rgba(255, 251, 235, 0.96)',
text: '#78350f',
border: 'rgba(245, 158, 11, 0.2)',
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
toolbarBg: 'rgba(254, 243, 199, 0.92)',
textareaBg: '#fffbeb',
previewBg: '#fffbeb',
codeBg: 'rgba(254, 215, 170, 0.6)',
codeText: '#78350f',
accent: '#d97706',
muted: '#a16207',
},
};
/**
* 位置配置(保留,预览面板提示等场景可用)
*/
export const POSITIONS = [
'top-left', 'top-center', 'top-right',
'bottom-left', 'bottom-center', 'bottom-right',
];
/**
* 动画配置(保留,DOM 过渡与未来扩展使用)
*/
export const ANIMATIONS = {
slide: { enter: { transform: 'translateX(80px)', opacity: 0 }, leave: { transform: 'translateX(120%)', opacity: 0 }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
fade: { enter: { opacity: 0, filter: 'blur(3px)' }, leave: { opacity: 0, filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
scale: { enter: { transform: 'scale(0.55)', opacity: 0 }, leave: { transform: 'scale(0.55)', opacity: 0 }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
bounce: { enter: { transform: 'translateY(-80px)', opacity: 0 }, leave: { transform: 'translateY(20px)', opacity: 0 }, duration: 650, easing: 'ease' },
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: 0 }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: 0 }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
zoom: { enter: { transform: 'scale(0.1)', opacity: 0 }, leave: { transform: 'scale(0.1)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
};
/**
* 主题类型
*/
export const THEME_TYPES = ['light', 'dark', 'auto'];
/**
* 动画类型
*/
export const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom'];
/**
* 编辑器视图模式
*/
export const EDIT_MODES = ['edit', 'split', 'preview'];
/**
* 内置工具栏动作清单
*/
export const TOOLBAR_ACTIONS = [
'bold', 'italic', 'strikethrough', 'underline', 'code',
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
'link', 'image', 'table',
'undo', 'redo',
'edit', 'split', 'preview', 'fullscreen',
];
// Locales - 从 locales.js 导入并重新导出
export { LOCALES };
/**
* 进度条方向(保留兼容)
*/
export const PROGRESS_DIRECTIONS = ['horizontal', 'vertical'];
+945
View File
@@ -0,0 +1,945 @@
/**
* MetonaEditor Core - 编辑器核心
* @module core
* @version 0.1.0
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
*/
import { generateId, escapeHTML, isBrowser } from './utils.js';
import { DEFAULTS, ICONS, THEMES } from './constants.js';
import { injectStyles } from './styles.js';
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
import { parseMarkdown } from './parser.js';
import { presetPlugins } from './plugins.js';
import { getTheme } from './themes.js';
const MODES = ['edit', 'split', 'preview'];
/**
* 解析主题名称为实际主题(auto -> light/dark
*/
const resolveThemeName = (theme) => {
if (theme && theme !== 'auto') return theme;
return getTheme('auto');
};
/**
* MarkdownEditor 编辑器类
*/
class MarkdownEditor {
/**
* 静态事件钩子(全局,所有实例共享,供插件使用)
*/
static _hooks = new Map();
static on(name, fn) {
if (!this._hooks.has(name)) this._hooks.set(name, []);
this._hooks.get(name).push(fn);
return () => this.off(name, fn);
}
static off(name, fn) {
const list = this._hooks.get(name);
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
}
static trigger(name, instance) {
const list = this._hooks.get(name);
if (list) list.forEach((fn) => {
try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); }
});
}
/**
* @param {string|HTMLElement} container - 容器选择器或元素
* @param {Object} options - 配置项(见 DEFAULTS
*/
constructor(container, options = {}) {
if (!isBrowser()) {
this._destroyed = true;
this._value = options.value || '';
return this;
}
this.id = options.id || generateId();
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
console.error('MeEditor: container not found:', container);
this._destroyed = true;
return this;
}
this.config = { ...DEFAULTS, ...options };
if (options.style && typeof options.style === 'object') {
this.config.style = { ...options.style };
}
this._value = this.config.value || '';
this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split';
this._history = [];
this._historyIndex = -1;
this._cleanups = [];
this._plugins = [];
this._listeners = {};
this._renderRaf = null;
this._historyTimer = null;
this._fullscreen = false;
this._destroyed = false;
// 渲染函数:自定义覆盖内置解析器
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
MarkdownEditor.trigger('beforeCreate', this);
injectStyles();
this._buildDOM();
this._buildToolbar();
this._bindEvents();
// 初始内容
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
// 安装配置中的插件
if (Array.isArray(this.config.plugins)) {
this.config.plugins.forEach((p) => this.use(p));
}
if (this.config.autofocus) this.focus();
if (typeof this.config.onCreate === 'function') {
try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); }
}
MarkdownEditor.trigger('afterCreate', this);
}
// ============ DOM 构建 ============
_buildDOM() {
const wrapper = document.createElement('div');
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
wrapper.dataset.id = this.id;
wrapper.setAttribute('role', 'application');
wrapper.setAttribute('aria-label', i18nT('edit'));
if (this.config.className) {
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
}
// 高度
const h = this.config.height;
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
// 自定义样式
if (this.config.style && typeof this.config.style === 'object') {
Object.entries(this.config.style).forEach(([k, v]) => { wrapper.style[k] = v; });
}
const toolbar = document.createElement('div');
toolbar.className = 'me-toolbar';
toolbar.setAttribute('role', 'toolbar');
const body = document.createElement('div');
body.className = `me-body me-mode-${this._mode}`;
const editorPane = document.createElement('div');
editorPane.className = 'me-editor-pane';
const textarea = document.createElement('textarea');
textarea.className = 'me-textarea';
textarea.spellcheck = !!this.config.spellcheck;
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
textarea.setAttribute('aria-label', i18nT('edit'));
editorPane.appendChild(textarea);
const divider = document.createElement('div');
divider.className = 'me-divider';
divider.setAttribute('role', 'separator');
const previewPane = document.createElement('div');
previewPane.className = 'me-preview-pane';
previewPane.setAttribute('role', 'region');
previewPane.setAttribute('aria-label', i18nT('preview'));
const preview = document.createElement('div');
preview.className = 'me-preview';
previewPane.appendChild(preview);
body.appendChild(editorPane);
body.appendChild(divider);
body.appendChild(previewPane);
wrapper.appendChild(toolbar);
wrapper.appendChild(body);
// 状态栏
let statusbar = null;
if (this.config.wordCount) {
statusbar = document.createElement('div');
statusbar.className = 'me-statusbar';
wrapper.appendChild(statusbar);
}
this.container.appendChild(wrapper);
this.el = wrapper;
this.toolbarEl = toolbar;
this.bodyEl = body;
this.editorPane = editorPane;
this.previewPane = previewPane;
this.dividerEl = divider;
this.textarea = textarea;
this.previewEl = preview;
this.statusEl = statusbar;
}
_buildToolbar() {
const tools = this.config.toolbar;
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
const modeActions = [];
tools.forEach((item) => {
if (item === '|') {
const sep = document.createElement('span');
sep.className = 'me-toolbar-sep';
this.toolbarEl.appendChild(sep);
return;
}
// 模式与全屏按钮归入右侧组
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') {
modeActions.push(item);
return;
}
this.toolbarEl.appendChild(this._createBtn(item));
});
if (modeActions.length) {
const group = document.createElement('div');
group.className = 'me-toolbar-group';
modeActions.forEach((item) => {
const btn = this._createBtn(item);
if (item === 'edit' || item === 'split' || item === 'preview') {
btn.dataset.mode = item;
if (item === this._mode) btn.classList.add('me-active');
}
group.appendChild(btn);
});
this.toolbarEl.appendChild(group);
}
}
_createBtn(item) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `me-btn me-btn-${item}`;
btn.dataset.action = item;
const label = i18nT(item) || item;
btn.title = label;
btn.setAttribute('aria-label', label);
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
return btn;
}
// ============ 事件绑定 ============
_bindEvents() {
const ta = this.textarea;
const onInput = () => {
this._value = ta.value;
this._scheduleRender();
this._scheduleHistory();
this._updateWordCount();
this._emit('input', this._value);
this._emit('change', this._value);
if (typeof this.config.onInput === 'function') {
try { this.config.onInput(this._value, this); } catch (e) { console.error(e); }
}
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
};
ta.addEventListener('input', onInput);
const onKeydown = (e) => this._handleKeydown(e);
ta.addEventListener('keydown', onKeydown);
const onScroll = () => {
if (!this.config.syncScroll || this._mode !== 'split') return;
const max = ta.scrollHeight - ta.clientHeight;
const ratio = max > 0 ? ta.scrollTop / max : 0;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
this.previewPane.scrollTop = ratio * pmax;
};
ta.addEventListener('scroll', onScroll);
const onFocus = () => {
this._emit('focus', this);
if (typeof this.config.onFocus === 'function') {
try { this.config.onFocus(this); } catch (e) { console.error(e); }
}
};
const onBlur = () => {
this._emit('blur', this);
if (typeof this.config.onBlur === 'function') {
try { this.config.onBlur(this); } catch (e) { console.error(e); }
}
};
ta.addEventListener('focus', onFocus);
ta.addEventListener('blur', onBlur);
// 工具栏点击
const onToolbarClick = (e) => {
const btn = e.target.closest('.me-btn');
if (!btn) return;
const mode = btn.dataset.mode;
if (mode) {
this.setMode(mode);
return;
}
const action = btn.dataset.action;
if (action) this.exec(action);
};
this.toolbarEl.addEventListener('click', onToolbarClick);
// 分隔条拖拽(分屏模式)
const onDividerDown = (e) => this._bindDividerDrag(e);
this.dividerEl.addEventListener('pointerdown', onDividerDown);
this._cleanups.push(() => {
ta.removeEventListener('input', onInput);
ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('scroll', onScroll);
ta.removeEventListener('focus', onFocus);
ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
});
}
_handleKeydown(e) {
// Tab 缩进
if (e.key === 'Tab') {
e.preventDefault();
this._handleTab(e.shiftKey);
return;
}
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const key = e.key.toLowerCase();
const shortcuts = {
b: 'bold', i: 'italic', k: 'link', u: 'underline',
e: 'code', '1': 'h1', '2': 'h2', '3': 'h3',
q: 'quote',
};
if (key === 'z' && !e.shiftKey) {
e.preventDefault();
this.undo();
} else if ((key === 'z' && e.shiftKey) || key === 'y') {
e.preventDefault();
this.redo();
} else if (key === 's') {
e.preventDefault();
this._emit('save', this._value);
if (typeof this.config.onSave === 'function') {
try { this.config.onSave(this._value, this); } catch (err) { console.error(err); }
}
} else if (shortcuts[key]) {
e.preventDefault();
this.exec(shortcuts[key]);
}
}
_handleTab(shift) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const tabSize = this.config.tabSize || 0;
const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
if (start === end) {
// 无选区:插入缩进
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
} else {
// 有选区:整块缩进/反缩进
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const block = ta.value.slice(lineStart, end);
const lines = block.split('\n');
let newBlock;
if (shift) {
newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n');
} else {
newBlock = lines.map((l) => tabStr + l).join('\n');
}
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + newBlock.length;
}
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_bindDividerDrag(e) {
if (this._mode !== 'split') return;
e.preventDefault();
const startX = e.clientX;
const editorWidth = this.editorPane.getBoundingClientRect().width;
const totalWidth = this.bodyEl.getBoundingClientRect().width;
const divider = this.dividerEl;
const onMove = (ev) => {
const dx = ev.clientX - startX;
let newEditorWidth = editorWidth + dx;
const min = 80;
const max = totalWidth - 80 - divider.offsetWidth;
newEditorWidth = Math.max(min, Math.min(max, newEditorWidth));
const pct = (newEditorWidth / totalWidth) * 100;
this.editorPane.style.flex = `0 0 ${pct}%`;
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
// ============ 渲染 ============
_scheduleRender() {
if (this._renderRaf) return;
this._renderRaf = requestAnimationFrame(() => {
this._renderRaf = null;
this._render();
});
}
_render() {
if (this._mode === 'edit') return;
MarkdownEditor.trigger('beforeRender', this);
const env = {
highlight: this._highlightFn,
locale: getCurrentLocale(),
};
let html;
try {
html = this._renderFn(this._value, env);
} catch (err) {
console.error('MeEditor render error:', err);
html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`;
}
if (typeof this.config.sanitize === 'function') {
try { html = this.config.sanitize(html); } catch (err) { console.error('sanitize error:', err); }
}
this.previewEl.innerHTML = html;
MarkdownEditor.trigger('afterRender', this);
}
// ============ 历史栈 ============
_scheduleHistory() {
if (this._historyTimer) clearTimeout(this._historyTimer);
this._historyTimer = setTimeout(() => this._pushHistory(), 400);
}
_pushHistory() {
const cur = this._history[this._historyIndex];
if (cur === this._value) return;
this._history = this._history.slice(0, this._historyIndex + 1);
this._history.push(this._value);
const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100;
while (this._history.length > limit) this._history.shift();
this._historyIndex = this._history.length - 1;
}
undo() {
if (this._historyIndex <= 0) return this;
this._historyIndex--;
this._applyHistory();
return this;
}
redo() {
if (this._historyIndex >= this._history.length - 1) return this;
this._historyIndex++;
this._applyHistory();
return this;
}
_applyHistory() {
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
this._updateWordCount();
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
}
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
// ============ 命令执行 ============
exec(action, ...args) {
const actions = {
bold: () => this._wrapSelection('**', '**'),
italic: () => this._wrapSelection('*', '*'),
strikethrough: () => this._wrapSelection('~~', '~~'),
underline: () => this._wrapSelection('<u>', '</u>'),
code: () => this._wrapSelection('`', '`'),
h1: () => this._toggleLinePrefix('# '),
h2: () => this._toggleLinePrefix('## '),
h3: () => this._toggleLinePrefix('### '),
quote: () => this._toggleLinePrefix('> '),
ul: () => this._toggleLinePrefix('- '),
ol: () => this._toggleLinePrefix('1. '),
indent: () => this._handleTab(false),
outdent: () => this._handleTab(true),
hr: () => this._insertBlock('\n---\n'),
link: () => this._insertLink(),
image: () => this._insertImage(),
table: () => this._insertTable(),
undo: () => this.undo(),
redo: () => this.redo(),
edit: () => this.setMode('edit'),
split: () => this.setMode('split'),
preview: () => this.setMode('preview'),
fullscreen: () => this.toggleFullscreen(),
};
const fn = actions[action];
if (fn) fn.apply(this, args);
return this;
}
_wrapSelection(before, after) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end);
const text = selected || (i18nT('placeholder') || 'text');
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
ta.focus();
if (selected) {
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + text.length;
} else {
ta.selectionStart = ta.selectionEnd = start + before.length;
}
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_toggleLinePrefix(prefix) {
const ta = this.textarea;
const start = ta.selectionStart;
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const lineEndPos = ta.value.indexOf('\n', start);
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
const line = ta.value.slice(lineStart, lineEnd);
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
let newLine;
if (existingMatch && existingMatch[0] === prefix) {
newLine = line.slice(prefix.length);
} else if (existingMatch) {
newLine = prefix + line.slice(existingMatch[0].length);
} else {
newLine = prefix + line;
}
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_insertBlock(text) {
const ta = this.textarea;
const start = ta.selectionStart;
const before = ta.value.slice(0, start);
const needNL = before && !before.endsWith('\n');
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
ta.focus();
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
_insertLink() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
const url = 'https://';
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
ta.focus();
// 选中 url 便于替换
const urlStart = start + sel.length + 3;
ta.selectionStart = urlStart;
ta.selectionEnd = urlStart + url.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_insertImage() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
const url = 'https://';
const insert = `![${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
ta.focus();
const urlStart = start + sel.length + 4;
ta.selectionStart = urlStart;
ta.selectionEnd = urlStart + url.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
_insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
const sep = Array.from({ length: cols }, () => '---').join(' | ');
let md = `| ${header} |\n| ${sep} |\n`;
for (let r = 1; r < rows; r++) {
const row = Array.from({ length: cols }, () => ' ').join(' | ');
md += `| ${row} |\n`;
}
this._insertBlock('\n' + md);
}
// ============ 模式与全屏 ============
setMode(mode) {
if (!MODES.includes(mode) || mode === this._mode) return this;
this._mode = mode;
this.bodyEl.className = `me-body me-mode-${mode}`;
this._updateModeButtons();
if (mode !== 'edit') this._render();
this._emit('modeChange', mode);
if (typeof this.config.onModeChange === 'function') {
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
}
return this;
}
getMode() { return this._mode; }
_updateModeButtons() {
this.toolbarEl.querySelectorAll('.me-btn[data-mode]').forEach((b) => {
b.classList.toggle('me-active', b.dataset.mode === this._mode);
});
}
toggleFullscreen() {
this._fullscreen = !this._fullscreen;
this.el.classList.toggle('me-fullscreen', this._fullscreen);
// 更新全屏按钮图标/标题
const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen');
if (fsBtn) {
fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏');
}
this._emit('fullscreen', this._fullscreen);
if (typeof this.config.onFullscreen === 'function') {
try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); }
}
return this;
}
isFullscreen() { return this._fullscreen; }
exitFullscreen() {
if (this._fullscreen) this.toggleFullscreen();
return this;
}
// ============ 字数统计 ============
_updateWordCount() {
if (!this.config.wordCount || !this.statusEl) return;
const text = this._value || '';
const chars = text.length;
// 中文字符数 + 英文单词数
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
const words = cnChars + enWords;
const lines = text ? text.split('\n').length : 0;
const readingMin = Math.max(1, Math.ceil(words / 300));
const parts = [
`${i18nT('characters')}: ${chars}`,
`${i18nT('words')}: ${words}`,
`${i18nT('lines')}: ${lines}`,
`${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`,
];
this.statusEl.textContent = parts.join(' · ');
}
getStats() {
const text = this._value || '';
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
return {
characters: text.length,
words: cnChars + enWords,
chineseChars: cnChars,
englishWords: enWords,
lines: text ? text.split('\n').length : 0,
readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)),
};
}
// ============ 公共 API ============
getValue() { return this._value; }
setValue(md, opts = {}) {
this._value = md || '';
this.textarea.value = this._value;
if (!opts.silent) this._pushHistory();
this._render();
this._updateWordCount();
if (!opts.silent) {
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
}
}
return this;
}
getHTML() {
const env = { highlight: this._highlightFn, locale: getCurrentLocale() };
let html = this._renderFn(this._value, env);
if (typeof this.config.sanitize === 'function') {
try { html = this.config.sanitize(html); } catch (e) { console.error(e); }
}
return html;
}
insert(text, opts = {}) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const replace = opts.replace || false;
ta.value = ta.value.slice(0, replace ? start : start) + text + ta.value.slice(replace ? end : end);
ta.focus();
const pos = start + text.length;
ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
return this;
}
/**
* 在选区两侧包裹文本
*/
wrap(before, after) {
this._wrapSelection(before, after || before);
return this;
}
focus() { this.textarea.focus(); return this; }
blur() { this.textarea.blur(); return this; }
enable() {
this.textarea.disabled = false;
this.el.classList.remove('me-disabled');
return this;
}
disable() {
this.textarea.disabled = true;
this.el.classList.add('me-disabled');
return this;
}
isDisabled() { return this.textarea.disabled; }
/**
* 注册实例事件监听
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
*/
on(name, fn) {
if (!this._listeners[name]) this._listeners[name] = [];
this._listeners[name].push(fn);
return () => this.off(name, fn);
}
off(name, fn) {
const list = this._listeners[name];
if (list) this._listeners[name] = list.filter((f) => f !== fn);
return this;
}
_emit(name, ...args) {
const list = this._listeners[name];
if (list) list.forEach((fn) => {
try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); }
});
}
/**
* 安装插件
* @param {string|Object} plugin - 预设插件名或插件对象
* @param {Object} options - 插件配置
*/
use(plugin, options = {}) {
let p = plugin;
if (typeof plugin === 'string') {
p = presetPlugins[plugin];
if (!p) {
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
return this;
}
}
if (!p || typeof p !== 'object') {
console.warn('MeEditor: invalid plugin');
return this;
}
const merged = { ...p, ...options };
if (typeof merged.install === 'function') {
try {
merged.install(this);
} catch (e) {
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
}
}
this._plugins.push(merged);
return this;
}
getPlugins() { return [...this._plugins]; }
/**
* 向工具栏追加自定义按钮
*/
addToolbarButton(config) {
if (!config || !config.action) return this;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `me-btn me-btn-custom-${config.action}`;
btn.dataset.action = config.action;
btn.title = config.title || config.action;
btn.setAttribute('aria-label', config.title || config.action);
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
if (typeof config.onClick === 'function') {
btn.addEventListener('click', () => config.onClick(this));
} else if (config.action) {
// 注册到 exec 动作
this._customActions = this._customActions || {};
this._customActions[config.action] = config.handler;
}
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
return this;
}
/**
* 销毁编辑器
*/
destroy() {
if (this._destroyed) return;
MarkdownEditor.trigger('beforeDestroy', this);
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
if (this._historyTimer) clearTimeout(this._historyTimer);
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
this._cleanups = [];
// 销毁插件
this._plugins.forEach((p) => {
if (typeof p.destroy === 'function') {
try { p.destroy(this); } catch (_) {}
}
});
this._plugins = [];
if (this.el && this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
this.el = null;
this.textarea = null;
this.previewEl = null;
this._destroyed = true;
if (typeof this.config.onDestroy === 'function') {
try { this.config.onDestroy(this); } catch (e) { console.error(e); }
}
// 先触发 destroy 事件,再清空监听器(否则 destroy 监听器永远收不到事件)
this._emit('destroy');
this._listeners = {};
MarkdownEditor.trigger('afterDestroy', this);
}
isDestroyed() { return this._destroyed; }
/**
* 获取编辑器状态
*/
getStatus() {
return {
id: this.id,
mode: this._mode,
theme: this.config.theme,
locale: getCurrentLocale(),
fullscreen: this._fullscreen,
disabled: this.textarea ? this.textarea.disabled : false,
destroyed: this._destroyed,
plugins: this._plugins.map((p) => p.name || 'custom'),
};
}
}
export { MarkdownEditor as Editor, MarkdownEditor };
export default MarkdownEditor;
+391
View File
@@ -0,0 +1,391 @@
/**
* MetonaEditor i18n - 国际化管理
* @module i18n
* @version 0.1.0
* @description 多语言支持、语言切换和翻译管理
*/
import { LOCALES } from './constants.js';
let currentLocale = 'zh-CN';
let localeListeners = new Set();
let fallbackLocale = 'zh-CN';
/**
* 获取当前语言
*/
export const getCurrentLocale = () => currentLocale;
/**
* 设置当前语言
*/
export const setCurrentLocale = (locale) => {
if (!LOCALES[locale]) {
console.warn(`Locale "${locale}" not found, falling back to "${fallbackLocale}"`);
locale = fallbackLocale;
}
currentLocale = locale;
notifyLocaleListeners(locale);
saveLocale(locale);
};
/**
* 翻译函数
*/
export const t = (key, params = {}) => {
const currentTranslation = getTranslation(currentLocale, key);
if (currentTranslation !== undefined) {
return interpolate(currentTranslation, params);
}
if (currentLocale !== fallbackLocale) {
const fallbackTranslation = getTranslation(fallbackLocale, key);
if (fallbackTranslation !== undefined) {
return interpolate(fallbackTranslation, params);
}
}
console.warn(`Translation missing for key "${key}" in locale "${currentLocale}"`);
return key;
};
/**
* 获取翻译
*/
const getTranslation = (locale, key) => {
const localeData = LOCALES[locale];
if (!localeData) return undefined;
const keys = key.split('.');
let result = localeData;
for (const k of keys) {
if (result && typeof result === 'object' && k in result) {
result = result[k];
} else {
return undefined;
}
}
return typeof result === 'string' ? result : undefined;
};
/**
* 插值函数
*/
const interpolate = (str, params) => {
return str.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
};
/**
* 检查翻译是否存在
*/
export const hasTranslation = (key) => {
return getTranslation(currentLocale, key) !== undefined ||
getTranslation(fallbackLocale, key) !== undefined;
};
/**
* 获取所有翻译
*/
export const getTranslations = (locale) => {
return LOCALES[locale] || {};
};
/**
* 添加翻译
*/
export const addTranslations = (locale, translations) => {
if (!LOCALES[locale]) {
LOCALES[locale] = {};
}
deepMerge(LOCALES[locale], translations);
};
/**
* 深度合并对象
*/
const deepMerge = (target, source) => {
for (const key in source) {
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
};
/**
* 获取支持的语言列表
*/
export const getSupportedLocales = () => {
return Object.keys(LOCALES);
};
/**
* 检查语言是否支持
*/
export const isLocaleSupported = (locale) => {
return locale in LOCALES;
};
/**
* 获取语言名称
*/
export const getLocaleName = (locale) => {
const names = {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
'en-US': 'English (US)',
'en-GB': 'English (UK)',
'ja': '日本語',
'ko': '한국어',
'fr': 'Français',
'de': 'Deutsch',
'es': 'Español',
'pt': 'Português',
'ru': 'Русский',
'ar': 'العربية',
'hi': 'हिन्दी',
'th': 'ไทย',
'vi': 'Tiếng Việt',
'id': 'Bahasa Indonesia',
'ms': 'Bahasa Melayu',
'tr': 'Türkçe',
'it': 'Italiano',
'nl': 'Nederlands',
'pl': 'Polski',
};
return names[locale] || locale;
};
/**
* 获取语言方向
*/
export const getLocaleDirection = (locale) => {
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
};
/**
* 格式化数字
*/
export const formatNumber = (number, options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, options).format(number);
} catch (e) {
return number.toString();
}
};
/**
* 格式化货币
*/
export const formatCurrency = (amount, currency = 'USD', options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, {
style: 'currency',
currency,
...options,
}).format(amount);
} catch (e) {
return amount.toString();
}
};
/**
* 格式化日期
*/
export const formatDate = (date, options = {}) => {
try {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(currentLocale, options).format(dateObj);
} catch (e) {
return date.toString();
}
};
/**
* 添加语言监听器
*/
export const addLocaleListener = (listener) => {
localeListeners.add(listener);
return () => {
localeListeners.delete(listener);
};
};
/**
* 移除语言监听器
*/
export const removeLocaleListener = (listener) => {
localeListeners.delete(listener);
};
/**
* 通知语言监听器
*/
const notifyLocaleListeners = (locale) => {
localeListeners.forEach((listener) => {
try {
listener(locale);
} catch (e) {
console.error('Locale listener error:', e);
}
});
};
/**
* 清除所有语言监听器
*/
export const clearLocaleListeners = () => {
localeListeners.clear();
};
/**
* 保存语言到本地存储
*/
export const saveLocale = (locale) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-locale', locale);
} catch (e) {
console.warn('Failed to save locale:', e);
}
}
};
/**
* 从本地存储加载语言
*/
export const loadLocale = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
} catch (e) {
console.warn('Failed to load locale:', e);
}
}
return getDefaultLocale();
};
/**
* 获取默认语言
*/
export const getDefaultLocale = () => {
if (typeof navigator !== 'undefined') {
const browserLocale = navigator.language || navigator.userLanguage;
if (browserLocale && isLocaleSupported(browserLocale)) {
return browserLocale;
}
const shortLocale = browserLocale?.split('-')[0];
if (shortLocale && isLocaleSupported(shortLocale)) {
return shortLocale;
}
}
return fallbackLocale;
};
/**
* 初始化国际化系统
*/
export const initI18n = () => {
const savedLocale = loadLocale();
setCurrentLocale(savedLocale);
};
/**
* 切换语言
*/
export const switchLocale = (locale) => {
setCurrentLocale(locale);
};
/**
* 国际化工具 — 代理层
*/
export const i18nUtils = {
t,
getCurrentLocale,
setCurrentLocale,
switchLocale,
getFallbackLocale: () => fallbackLocale,
setFallbackLocale: (locale) => { fallbackLocale = locale; },
hasTranslation,
getTranslations,
addTranslations,
getSupportedLocales,
isLocaleSupported,
getLocaleName,
getLocaleDirection,
formatNumber,
formatCurrency,
formatDate,
addLocaleListener,
removeLocaleListener,
clearLocaleListeners,
initI18n,
saveLocale,
loadLocale,
getDefaultLocale,
};
/**
* 预设语言包
*/
export const presetLocales = {
'zh-CN': {
name: '简体中文',
nativeName: '简体中文',
direction: 'ltr',
translations: LOCALES['zh-CN'],
},
'en-US': {
name: 'English (US)',
nativeName: 'English (US)',
direction: 'ltr',
translations: LOCALES['en-US'],
},
};
/**
* 创建国际化管理器
*/
export const createI18nManager = () => {
return {
t,
getCurrentLocale,
setCurrentLocale,
switchLocale,
getFallbackLocale: () => fallbackLocale,
setFallbackLocale: (locale) => { fallbackLocale = locale; },
hasTranslation,
getTranslations,
addTranslations,
getSupportedLocales,
isLocaleSupported,
getLocaleName,
getLocaleDirection,
formatNumber,
formatCurrency,
formatDate,
addLocaleListener,
removeLocaleListener,
clearLocaleListeners,
initI18n,
saveLocale,
loadLocale,
getDefaultLocale,
};
};
export { i18nUtils as default };
+56
View File
@@ -0,0 +1,56 @@
/**
* MetonaEditor Icons — 工具栏图标SVG定义
* @module icons
* @version 0.1.0
* @description 工具栏格式化按钮 SVG 图标
*/
export const ICONS = {
bold: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="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"/></svg>`,
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="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"/></svg>`,
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
};
+248
View File
@@ -0,0 +1,248 @@
/**
* MetonaEditor - 轻量级 Markdown Editor 库
* @module metona-editor
* @version 0.1.0
* @author thzxx
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
* @license MIT
*
* 用法:
* // 浏览器
* <script src="dist/metona-editor.js"></script>
* const editor = MeEditor.create('#editor', { mode: 'split' });
*
* // ES Module
* import MeEditor from 'metona-editor';
* const editor = MeEditor.create(container, options);
*
* // 直接使用类
* import { MarkdownEditor } from 'metona-editor';
* const editor = new MarkdownEditor(container, options);
*/
import { MarkdownEditor } from './core.js';
import { parseMarkdown, safeUrl, slugify } from './parser.js';
import { themeUtils } from './themes.js';
import { i18nUtils } from './i18n.js';
import { pluginUtils, presetPlugins } from './plugins.js';
import { animationUtils } from './animations.js';
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
// 版本信息
const VERSION = '0.1.0';
/**
* 全局默认插件队列
* 通过 beforeCreate 钩子自动应用到所有后续创建的编辑器实例
*/
const globalPlugins = [];
// 注册全局钩子:每个新实例创建时自动安装全局默认插件
// 注意:core.js 构造函数中 beforeCreate 在 config.plugins 安装之前触发,
// 且 this._plugins 已初始化为 [],因此此处调用 editor.use() 安全。
MarkdownEditor.on('beforeCreate', (editor) => {
if (!editor || editor._destroyed) return;
globalPlugins.forEach((p) => {
try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); }
});
});
/**
* 创建编辑器实例(工厂函数,推荐用法)
* @param {string|HTMLElement} container - 容器选择器或 DOM 元素
* @param {Object} [options] - 配置项(见 DEFAULTS
* @returns {MarkdownEditor} 编辑器实例
*/
function create(container, options = {}) {
return new MarkdownEditor(container, options);
}
/**
* 注册全局默认插件(作用于所有后续创建的编辑器实例)
* @param {string|Object} plugin - 预设插件名 或 插件对象
* @param {Object} [options] - 插件配置(覆盖预设默认值)
* @returns {typeof api} 返回 api 自身以支持链式调用
*/
function use(plugin, options = {}) {
let p = plugin;
if (typeof plugin === 'string') {
p = presetPlugins[plugin];
if (!p) {
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
return api;
}
}
if (!p || typeof p !== 'object') {
console.warn('MeEditor: invalid plugin');
return api;
}
const merged = (options && typeof options === 'object' && Object.keys(options).length > 0)
? { ...p, ...options }
: p;
globalPlugins.push(merged);
return api;
}
/**
* 注册全局事件钩子(转发到 MarkdownEditor 静态钩子,所有实例共享)
* 可用钩子:beforeCreate / afterCreate / beforeRender / afterRender / beforeDestroy / afterDestroy
* @param {string} name - 钩子名
* @param {Function} fn - 回调 (editor) => void
* @returns {Function} 取消注册函数
*/
function on(name, fn) {
return MarkdownEditor.on(name, fn);
}
/**
* 注销全局事件钩子
*/
function off(name, fn) {
MarkdownEditor.off(name, fn);
return api;
}
/**
* 切换全局主题(转发到 themeUtils
*/
function setTheme(theme, options) {
if (themeUtils && typeof themeUtils.switchTheme === 'function') {
themeUtils.switchTheme(theme, options);
}
return api;
}
/**
* 切换全局语言(转发到 i18nUtils)
*/
function setLocale(locale) {
if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') {
i18nUtils.setCurrentLocale(locale);
}
return api;
}
/**
* 销毁所有全局资源(主题监听、i18n 监听、动画、全局插件队列)
* 注意:此方法不销毁已创建的编辑器实例,请单独调用每个实例的 destroy()
*/
function destroy() {
if (themeUtils) {
if (typeof themeUtils.clearThemeListeners === 'function') {
try { themeUtils.clearThemeListeners(); } catch (_) {}
}
if (typeof themeUtils.unwatchSystemTheme === 'function') {
try { themeUtils.unwatchSystemTheme(); } catch (_) {}
}
}
if (i18nUtils && typeof i18nUtils.clearLocaleListeners === 'function') {
try { i18nUtils.clearLocaleListeners(); } catch (_) {}
}
if (animationUtils && typeof animationUtils.cancelAll === 'function') {
try { animationUtils.cancelAll(); } catch (_) {}
}
globalPlugins.length = 0;
}
/**
* 获取库运行状态
*/
function getStatus() {
return {
version: VERSION,
theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto',
locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN',
globalPlugins: globalPlugins.map((p) => p.name || 'custom'),
presetPlugins: Object.keys(presetPlugins || {}),
};
}
// 初始化主题与国际化(浏览器环境)
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') {
try { themeUtils.initTheme(); } catch (_) {}
}
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') {
try { i18nUtils.initI18n(); } catch (_) {}
}
/**
* 顶层 API 对象(默认导出)
*/
const api = {
// 版本
VERSION,
version: VERSION,
// 核心类
MarkdownEditor,
Editor: MarkdownEditor,
// 工厂函数(推荐入口)
create,
// 顶层 API
use,
on,
off,
setTheme,
setLocale,
destroy,
getStatus,
// 内置解析器(可单独使用或替换)
parseMarkdown,
safeUrl,
slugify,
// 工具集
themes: themeUtils,
i18n: i18nUtils,
animations: animationUtils,
plugins: pluginUtils,
presetPlugins,
// 常量
DEFAULTS,
ICONS,
THEMES,
EDIT_MODES,
DEFAULT_TOOLBAR,
TOOLBAR_ACTIONS,
};
// 浏览器环境全局注册(UMD/CDN 场景:window.MeEditor
if (typeof window !== 'undefined') {
window.MeEditor = api;
}
export default api;
export {
api,
api as meEditor,
api as MeEditor,
MarkdownEditor,
MarkdownEditor as Editor,
create,
use,
on,
off,
setTheme,
setLocale,
destroy,
getStatus,
parseMarkdown,
safeUrl,
slugify,
themeUtils,
i18nUtils,
pluginUtils,
presetPlugins,
animationUtils,
DEFAULTS,
ICONS,
THEMES,
EDIT_MODES,
DEFAULT_TOOLBAR,
TOOLBAR_ACTIONS,
VERSION,
};
+168
View File
@@ -0,0 +1,168 @@
/**
* MetonaEditor Locales — 国际化翻译数据
* @module locales
* @version 0.1.0
* @description 内置 zh-CN / en-US 完整翻译
*/
export const LOCALES = {
'zh-CN': {
bold: '粗体',
italic: '斜体',
underline: '下划线',
strikethrough: '删除线',
h1: '标题1',
h2: '标题2',
h3: '标题3',
quote: '引用',
code: '代码',
link: '链接',
image: '图片',
table: '表格',
ul: '无序列表',
ol: '有序列表',
indent: '增加缩进',
outdent: '减少缩进',
hr: '分隔线',
undo: '撤销',
redo: '重做',
edit: '编辑',
split: '分屏',
preview: '预览',
fullscreen: '全屏',
fullscreenExit: '退出全屏',
theme: '主题',
light: '亮色',
dark: '暗色',
auto: '自动',
warm: '暖色',
wordCount: '字数统计',
characters: '字符',
words: '词数',
lines: '行数',
readingTime: '阅读',
minutes: '分钟',
placeholder: '开始输入 Markdown...',
empty: '暂无内容',
copied: '已复制',
copyContent: '复制内容',
copyHTML: '复制 HTML',
copySuccess: '复制成功',
copyFailed: '复制失败',
clearContent: '清空内容',
clearConfirm: '确定要清空所有内容吗?',
linkPlaceholder: '请输入链接地址',
imagePlaceholder: '请输入图片地址',
altPlaceholder: '请输入替代文本',
tableRows: '行数',
tableCols: '列数',
confirm: '确认',
cancel: '取消',
exportMarkdown: '导出 Markdown',
exportHTML: '导出 HTML',
search: '搜索',
replace: '替换',
replaceAll: '全部替换',
searchPlaceholder: '查找内容',
replacePlaceholder: '替换为',
findNext: '查找下一个',
findPrev: '查找上一个',
matchCase: '区分大小写',
wholeWord: '全字匹配',
close: '关闭',
open: '打开',
save: '保存',
saved: '已保存',
saving: '保存中...',
delete: '删除',
confirmDelete: '确定要删除吗?',
unsavedChanges: '有未保存的更改',
error: '错误',
success: '成功',
warning: '警告',
info: '信息',
loading: '加载中...',
retry: '重试',
renderError: '渲染失败',
},
'en-US': {
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
quote: 'Quote',
code: 'Code',
link: 'Link',
image: 'Image',
table: 'Table',
ul: 'Bullet List',
ol: 'Numbered List',
indent: 'Indent',
outdent: 'Outdent',
hr: 'Horizontal Rule',
undo: 'Undo',
redo: 'Redo',
edit: 'Edit',
split: 'Split',
preview: 'Preview',
fullscreen: 'Fullscreen',
fullscreenExit: 'Exit Fullscreen',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
auto: 'Auto',
warm: 'Warm',
wordCount: 'Word Count',
characters: 'Characters',
words: 'Words',
lines: 'Lines',
readingTime: 'Reading',
minutes: 'min',
placeholder: 'Start typing Markdown...',
empty: 'No content',
copied: 'Copied',
copyContent: 'Copy Content',
copyHTML: 'Copy HTML',
copySuccess: 'Copied successfully',
copyFailed: 'Copy failed',
clearContent: 'Clear Content',
clearConfirm: 'Clear all content?',
linkPlaceholder: 'Enter link URL',
imagePlaceholder: 'Enter image URL',
altPlaceholder: 'Enter alt text',
tableRows: 'Rows',
tableCols: 'Columns',
confirm: 'Confirm',
cancel: 'Cancel',
exportMarkdown: 'Export Markdown',
exportHTML: 'Export HTML',
search: 'Search',
replace: 'Replace',
replaceAll: 'Replace All',
searchPlaceholder: 'Find',
replacePlaceholder: 'Replace with',
findNext: 'Find Next',
findPrev: 'Find Previous',
matchCase: 'Match Case',
wholeWord: 'Whole Word',
close: 'Close',
open: 'Open',
save: 'Save',
saved: 'Saved',
saving: 'Saving...',
delete: 'Delete',
confirmDelete: 'Are you sure you want to delete?',
unsavedChanges: 'You have unsaved changes',
error: 'Error',
success: 'Success',
warning: 'Warning',
info: 'Info',
loading: 'Loading...',
retry: 'Retry',
renderError: 'Render failed',
},
};
+322
View File
@@ -0,0 +1,322 @@
/**
* MetonaEditor Parser - 轻量 Markdown 解析器
* @module parser
* @version 0.1.0
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
*
* 支持语法:
* - 标题 # ~ ######
* - 段落 / 换行
* - 粗体 **text** / __text__
* - 斜体 *text* / _text_
* - 删除线 ~~text~~
* - 行内代码 `code`
* - 代码块 ```lang ... ``` 与 ~~~ ... ~~~
* - 引用块 >
* - 无序列表 - / * / +
* - 有序列表 1.
* - 任务列表 - [ ] / - [x]
* - 水平线 --- / *** / ___
* - 表格 | a | b |
* - 链接 [text](url) / 自动链接 <url>
* - 图片 ![alt](url)
* - 转义字符 \X
*
* 安全:所有文本经 HTML 转义;URL 过滤 javascript:/vbscript: 等危险协议
* 扩展:env.highlight(code, lang) => html 钩子用于代码高亮
*/
import { escapeHTML } from './utils.js';
/**
* 危险协议过滤
*/
const safeUrl = (url) => {
if (!url) return '';
const u = String(url).trim();
if (/^(javascript|vbscript|file):/i.test(u)) return '';
// 仅允许 data:image/
if (/^data:/i.test(u) && !/^data:image\//i.test(u)) return '';
return u;
};
/**
* 主解析入口
* @param {string} md - Markdown 源文本
* @param {Object} env - 渲染环境 { highlight, locale }
* @returns {string} HTML 字符串
*/
export const parseMarkdown = (md, env = {}) => {
if (md == null) return '';
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 空行
if (/^\s*$/.test(line)) { i++; continue; }
// 围栏代码块
const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/);
if (fence) {
const fenceChar = fence[2][0];
const lang = fence[3] || '';
const codeLines = [];
i++;
const closeRe = new RegExp('^\\s*' + fenceChar + '{3,}\\s*$');
while (i < lines.length && !closeRe.test(lines[i])) {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++; // 跳过结束围栏
tokens.push({ type: 'code', lang, content: codeLines.join('\n') });
continue;
}
// ATX 标题
const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?\s*$/);
if (h) {
tokens.push({ type: 'heading', level: h[1].length, text: h[2] });
i++;
continue;
}
// 水平线
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
tokens.push({ type: 'hr' });
i++;
continue;
}
// 引用块
if (/^\s*>/.test(line)) {
const quote = [];
while (i < lines.length && /^\s*>/.test(lines[i])) {
quote.push(lines[i].replace(/^\s*>?\s?/, ''));
i++;
}
tokens.push({ type: 'quote', content: quote.join('\n') });
continue;
}
// 表格:当前行含 |,下一行是分隔行
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const header = line;
i += 2;
const rows = [];
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
rows.push(lines[i]);
i++;
}
tokens.push({ type: 'table', header, rows });
continue;
}
// 无序列表 / 任务列表
if (/^\s*[-*+]\s/.test(line)) {
const items = [];
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
const raw = lines[i].replace(/^\s*[-*+]\s/, '');
const task = raw.match(/^\[([ xX])\]\s+(.*)$/);
if (task) {
items.push({ text: task[2], checked: task[1].toLowerCase() === 'x', task: true });
} else {
items.push({ text: raw, checked: false, task: false });
}
i++;
}
tokens.push({ type: 'ul', items });
continue;
}
// 有序列表
if (/^\s*\d+\.\s/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s/, ''));
i++;
}
tokens.push({ type: 'ol', items });
continue;
}
// 段落(收集连续非块级行)
const para = [];
while (i < lines.length) {
const l = lines[i];
if (/^\s*$/.test(l)) break;
if (/^\s*(`{3,}|~{3,})/.test(l)) break;
if (/^#{1,6}\s/.test(l)) break;
if (/^\s*>/.test(l)) break;
if (/^\s*[-*+]\s/.test(l)) break;
if (/^\s*\d+\.\s/.test(l)) break;
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) break;
para.push(l);
i++;
}
tokens.push({ type: 'paragraph', text: para.join('\n') });
}
return tokens.map((tok) => renderToken(tok, env)).join('\n');
};
/**
* 表格分隔行判定
*/
const isTableSeparator = (line) => {
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(line);
};
/**
* 渲染单个块级 token
*/
const renderToken = (tok, env) => {
switch (tok.type) {
case 'heading': {
const id = slugify(tok.text);
return `<h${tok.level} id="${id}">${renderInline(tok.text, env)}</h${tok.level}>`;
}
case 'paragraph':
return `<p>${renderInline(tok.text, env)}</p>`;
case 'hr':
return '<hr/>';
case 'quote':
return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
case 'code':
return renderCode(tok.content, tok.lang, env);
case 'ul':
return `<ul>${tok.items.map((it) => renderListItem(it, env)).join('')}</ul>`;
case 'ol':
return `<ol>${tok.items.map((it) => `<li>${renderInline(it, env)}</li>`).join('')}</ol>`;
case 'table':
return renderTable(tok, env);
default:
return '';
}
};
/**
* 渲染列表项
*/
const renderListItem = (it, env) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
return `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${renderInline(it.text, env)}</li>`;
}
return `<li>${renderInline(it.text, env)}</li>`;
};
/**
* 渲染代码块
*/
const renderCode = (code, lang, env) => {
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string') {
return `<pre><code${langClass}>${highlighted}</code></pre>`;
}
} catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
};
/**
* 渲染表格
*/
const renderTable = (tok, env) => {
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const headers = splitRow(tok.header);
let html = '<div class="me-table-wrap"><table><thead><tr>';
html += headers.map((h) => `<th>${renderInline(h, env)}</th>`).join('');
html += '</tr></thead><tbody>';
html += tok.rows.map((r) => {
const cells = splitRow(r);
return `<tr>${cells.map((c) => `<td>${renderInline(c, env)}</td>`).join('')}</tr>`;
}).join('');
html += '</tbody></table></div>';
return html;
};
/**
* 渲染内联元素
* 顺序:提取代码占位 -> escape -> 图片 -> 链接 -> 自动链接 -> 粗体 -> 斜体 -> 删除线 -> 还原代码 -> 换行
*/
const renderInline = (text, env) => {
if (!text) return '';
const codes = [];
let s = String(text);
// 1. 提取行内代码到占位符,避免被后续正则破坏
s = s.replace(/`+([^`]+?)`+/g, (m, c) => {
const idx = codes.length;
codes.push(c);
return `\u0000${idx}\u0000`;
});
// 2. HTML 转义(占位符中的零字符不被影响)
s = escapeHTML(s);
// 3. 图片 ![alt](url "title")
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, alt, url, title) => {
const u = safeUrl(url);
if (!u) return escapeHTML(m);
const t = title ? ` title="${title}"` : '';
return `<img src="${u}" alt="${alt}"${t} loading="lazy"/>`;
});
// 4. 链接 [text](url "title")
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, txt, url, title) => {
const u = safeUrl(url);
if (!u) return escapeHTML(m);
const t = title ? ` title="${title}"` : '';
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`;
});
// 5. 自动链接 <url>
s = s.replace(/&lt;(https?:\/\/[^\s&]+)&gt;/g, (m, url) => {
const u = safeUrl(url);
if (!u) return m;
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
// 6. 粗体 **text** / __text__
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// 7. 斜体 *text* / _text_
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
// 8. 删除线 ~~text~~
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// 9. 还原行内代码
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `<code>${escapeHTML(codes[+idx])}</code>`);
// 10. 软换行
s = s.replace(/\n/g, '<br/>');
return s;
};
/**
* 生成标题锚点 id(支持中文)
*/
const slugify = (text) => {
return String(text)
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
export { safeUrl, slugify };
export default parseMarkdown;
+483
View File
@@ -0,0 +1,483 @@
/**
* MetonaEditor Plugins - 插件系统
* @module plugins
* @version 0.1.0
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace
*
* 插件约定:
* {
* name: string,
* description?: string,
* install(editor, options): void, // 安装时调用,editor 为 MarkdownEditor 实例
* destroy(editor): void // 卸载时调用,清理事件与 DOM
* }
* install 的 this 指向插件对象本身(core.js 通过 merged.install(editor) 调用),
* 因此可在 this 上存放运行时状态(如定时器、DOM 引用)。
*/
import { t } from './i18n.js';
/**
* 插件管理器(独立于编辑器实例,用于全局注册与查询;编辑器实例级安装由 core.js 的 use() 负责)
*/
class PluginManager {
constructor() {
this.plugins = new Map();
}
register(name, plugin) {
if (this.plugins.has(name)) {
console.warn(`MeEditor: plugin "${name}" already registered`);
return this;
}
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) {
console.error(`MeEditor: invalid plugin "${name}"`);
return this;
}
this.plugins.set(name, { name, ...plugin, enabled: true });
return this;
}
unregister(name) {
this.plugins.delete(name);
return this;
}
get(name) { return this.plugins.get(name) || null; }
has(name) { return this.plugins.has(name); }
getAll() { return Array.from(this.plugins.values()); }
getNames() { return Array.from(this.plugins.keys()); }
enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
isEnabled(name){ const p = this.plugins.get(name); return p ? p.enabled : false; }
destroy() {
this.plugins.clear();
}
}
const defaultPluginManager = new PluginManager();
// ============ 预设插件 ============
/**
* 自动保存插件
* 将编辑器内容定时 / 失焦 / 保存时写入 localStorage,并提供草稿恢复与清除。
*
* 配置:
* key: 存储 key,默认 'me-draft-' + editor.id
* delay: 防抖延迟(ms),默认 1000
*/
const autoSavePlugin = {
name: 'autoSave',
description: '自动保存到 localStorage,支持草稿恢复',
key: null,
delay: 1000,
_timer: null,
_onInput: null,
_onBlur: null,
_onSave: null,
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
const key = this.key || ('me-draft-' + (editor.id || ''));
const save = () => {
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
try {
const value = editor.getValue();
localStorage.setItem(key, value);
if (typeof editor._emit === 'function') {
editor._emit('autosave', { key, value });
}
} catch (e) {
console.warn('MeEditor autoSave: save failed', e);
}
};
this._save = save;
this._onInput = () => {
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(save, this.delay);
};
this._onBlur = save;
this._onSave = save;
editor.on('change', this._onInput);
editor.on('blur', this._onBlur);
editor.on('save', this._onSave);
// 暴露草稿 API
editor.restoreDraft = () => {
try {
const v = localStorage.getItem(key);
if (v != null && typeof editor.setValue === 'function') {
editor.setValue(v);
}
return v;
} catch (e) { return null; }
};
editor.clearDraft = () => {
try { localStorage.removeItem(key); } catch (e) {}
return editor;
};
editor.getDraftKey = () => key;
},
destroy(editor) {
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
if (editor && typeof editor.off === 'function') {
if (this._onInput) editor.off('change', this._onInput);
if (this._onBlur) editor.off('blur', this._onBlur);
if (this._onSave) editor.off('save', this._onSave);
}
},
};
/**
* 导出工具插件
* 提供 .md / .html 文件下载。
*/
const exportToolPlugin = {
name: 'exportTool',
description: '导出 Markdown / HTML 文件',
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
const download = (filename, content, mime) => {
if (typeof document === 'undefined') return;
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const stamp = () => {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
};
editor.exportMarkdown = (filename) => {
download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown');
return editor;
};
editor.exportHTML = (filename, opts = {}) => {
const title = opts.title || 'Document';
const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
const html = `<!DOCTYPE html>
<html lang="${opts.lang || 'zh-CN'}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
${css ? `<style>${css}</style>` : ''}
</head>
<body>
${body}
</body>
</html>`;
download(filename || `metona-${stamp()}.html`, html, 'text/html');
return editor;
};
},
destroy() {},
};
/**
* 查找替换插件
* Ctrl+F 唤出浮动搜索条,支持上一个 / 下一个 / 替换 / 全部替换。
*/
const searchReplacePlugin = {
name: 'searchReplace',
description: '查找替换(Ctrl+F',
_onKeydown: null,
_panel: null,
_cleanup: null,
install(editor) {
if (!editor || !editor.textarea) return;
if (typeof document === 'undefined') return;
this._injectStyle();
this._onKeydown = (e) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === 'f') {
e.preventDefault();
this._open(editor);
} else if (k === 'h') {
e.preventDefault();
this._open(editor, true);
} else if (k === 'escape' && this._panel) {
this._close(editor);
}
};
editor.textarea.addEventListener('keydown', this._onKeydown);
},
_injectStyle() {
if (document.getElementById('me-search-style')) return;
const style = document.createElement('style');
style.id = 'me-search-style';
style.textContent = `
.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg, #f8f9fa);border:1px solid var(--md-border, rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}
.me-search-row{display:flex;gap:4px;align-items:center}
.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg, #fff);color:var(--md-text, #1f2937);font-size:13px}
.me-search input:focus{outline:none;border-color:var(--md-accent, #3b82f6)}
.me-search button{padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));background:var(--md-bg, #fff);color:var(--md-text, #1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}
.me-search button:hover{background:var(--md-accent, #3b82f6);color:#fff;border-color:var(--md-accent, #3b82f6)}
.me-search .me-search-count{color:var(--md-muted, #6b7280);font-size:12px;min-width:60px;text-align:center}
.me-search .me-search-close{padding:2px 6px}
`;
document.head.appendChild(style);
},
_open(editor, showReplace) {
if (this._panel) {
this._panel.dataset.replace = showReplace ? '1' : '0';
this._updateReplaceVisible();
const inp = this._panel.querySelector('.me-search-find');
if (inp) { inp.focus(); inp.select(); }
return;
}
const selected = editor.textarea.value.substring(
editor.textarea.selectionStart,
editor.textarea.selectionEnd
);
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `
<div class="me-search-row">
<input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/>
<button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button>
<button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button>
<span class="me-search-count"></span>
<button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button>
</div>
<div class="me-search-row me-search-replace-row">
<input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/>
<button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button>
<button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button>
</div>
`;
editor.el.appendChild(panel);
this._panel = panel;
this._updateReplaceVisible();
const findInput = panel.querySelector('.me-search-find');
const replaceInput = panel.querySelector('.me-search-replace');
const countEl = panel.querySelector('.me-search-count');
const findAll = () => {
const text = editor.textarea.value;
const q = findInput.value;
if (!q) { countEl.textContent = ''; return []; }
const idxs = [];
let from = 0;
while (true) {
const idx = text.indexOf(q, from);
if (idx === -1) break;
idxs.push(idx);
from = idx + q.length;
}
countEl.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs;
};
const selectAt = (idx) => {
const q = findInput.value;
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + q.length);
// 滚动到可见
const lineHeight = parseFloat(getComputedStyle(editor.textarea).lineHeight) || 20;
const lineNum = editor.textarea.value.substring(0, idx).split('\n').length - 1;
editor.textarea.scrollTop = Math.max(0, lineNum * lineHeight - editor.textarea.clientHeight / 2);
};
let lastIdxs = [];
let cursor = -1;
const findNext = () => {
lastIdxs = findAll();
if (!lastIdxs.length) return;
const cur = editor.textarea.selectionEnd;
let next = lastIdxs.find((i) => i >= cur);
if (next == null) next = lastIdxs[0];
cursor = lastIdxs.indexOf(next);
selectAt(next);
};
const findPrev = () => {
lastIdxs = findAll();
if (!lastIdxs.length) return;
const cur = editor.textarea.selectionStart;
let prev = -1;
for (let i = lastIdxs.length - 1; i >= 0; i--) {
if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; }
}
if (prev === -1) prev = lastIdxs[lastIdxs.length - 1];
cursor = lastIdxs.indexOf(prev);
selectAt(prev);
};
const replaceOne = () => {
const q = findInput.value;
const r = replaceInput.value;
if (!q) return;
const ta = editor.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const cur = ta.value.substring(start, end);
if (cur === q) {
ta.value = ta.value.substring(0, start) + r + ta.value.substring(end);
ta.setSelectionRange(start, start + r.length);
editor._value = ta.value;
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
}
findNext();
};
const replaceAll = () => {
const q = findInput.value;
const r = replaceInput.value;
if (!q) return;
const ta = editor.textarea;
const before = ta.value;
const after = before.split(q).join(r);
if (before === after) return;
ta.value = after;
ta.setSelectionRange(0, 0);
editor._value = ta.value;
if (typeof editor._pushHistory === 'function') editor._pushHistory();
if (typeof editor._render === 'function') editor._render();
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
findAll();
};
findInput.addEventListener('input', () => { lastIdxs = findAll(); cursor = -1; });
findInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
});
replaceInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
});
panel.querySelector('.me-search-next').addEventListener('click', findNext);
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
panel.querySelector('.me-search-close').addEventListener('click', () => this._close(editor));
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
if (selected) findAll();
findInput.focus();
findInput.select();
this._cleanup = () => {
if (panel.parentNode) panel.parentNode.removeChild(panel);
};
},
_updateReplaceVisible() {
if (!this._panel) return;
const show = this._panel.dataset.replace === '1';
const row = this._panel.querySelector('.me-search-replace-row');
if (row) row.style.display = show ? 'flex' : 'none';
},
_close(editor) {
if (this._cleanup) { this._cleanup(); this._cleanup = null; }
this._panel = null;
if (editor && editor.textarea) editor.textarea.focus();
},
destroy(editor) {
this._close(editor);
if (this._onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', this._onKeydown);
}
this._onKeydown = null;
},
};
/**
* 预设插件注册表
*/
const presetPlugins = {
autoSave: autoSavePlugin,
exportTool: exportToolPlugin,
searchReplace: searchReplacePlugin,
};
/**
* 转义属性值(用于内联 HTML 属性)
*/
const escapeAttr = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
/**
* 插件工具
*/
const pluginUtils = {
createManager() { return new PluginManager(); },
manager: defaultPluginManager,
register(name, plugin) { return defaultPluginManager.register(name, plugin); },
unregister(name) { return defaultPluginManager.unregister(name); },
get(name) { return defaultPluginManager.get(name); },
has(name) { return defaultPluginManager.has(name); },
getAll() { return defaultPluginManager.getAll(); },
getNames() { return defaultPluginManager.getNames(); },
enable(name) { return defaultPluginManager.enable(name); },
disable(name) { return defaultPluginManager.disable(name); },
isEnabled(name) { return defaultPluginManager.isEnabled(name); },
getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; },
getAllPresets() { return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}); },
createPlugin(config = {}) {
const result = {
name: config.name || 'custom',
description: config.description || '',
// 默认提供空函数,确保 editor.use(plugin) 时 destroy() 调用安全
install: () => {},
destroy: () => {},
...config,
};
// 强制类型校验:用户传入非函数时回退为空函数,避免运行时报错
if (typeof result.install !== 'function') result.install = () => {};
if (typeof result.destroy !== 'function') result.destroy = () => {};
return result;
},
validatePlugin(plugin) {
const errors = [];
if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object');
if (plugin && !plugin.name) errors.push('plugin must have a name');
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
return { valid: errors.length === 0, errors };
},
};
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
export default presetPlugins;
+381
View File
@@ -0,0 +1,381 @@
/**
* MetonaEditor Styles - 编辑器样式
* @module styles
* @version 0.1.0
* @description 编辑器 UI 样式注入与管理
*/
import { THEMES } from './constants.js';
let styleElement = null;
/**
* 生成 CSS 样式
*/
const generateCSS = () => {
return `
/* ============ CSS 变量默认值(被 themes.js 动态覆盖) ============ */
:root {
--md-bg: #ffffff;
--md-text: #1f2937;
--md-border: rgba(0, 0, 0, 0.08);
--md-shadow: 0 10px 36px -10px rgba(0,0,0,0.18), 0 4px 14px -4px rgba(0,0,0,0.08);
--md-hover-shadow: 0 14px 48px -10px rgba(0,0,0,0.22), 0 6px 18px -4px rgba(0,0,0,0.10);
--md-toolbar-bg: rgba(248, 249, 250, 0.92);
--md-textarea-bg: #ffffff;
--md-preview-bg: #ffffff;
--md-code-bg: rgba(243, 244, 246, 1);
--md-code-text: #1f2937;
--md-accent: #3b82f6;
--md-muted: #6b7280;
--md-radius: 10px;
--md-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
--md-mono: "SF Mono", "Cascadia Code", "Consolas", "Liberation Mono", "Courier New", monospace;
}
/* ============ 容器 ============ */
.me-wrapper {
display: flex;
flex-direction: column;
box-sizing: border-box;
border: 1px solid var(--md-border);
border-radius: var(--md-radius);
overflow: hidden;
background: var(--md-bg);
color: var(--md-text);
box-shadow: var(--md-shadow);
font-family: var(--md-font);
font-size: 14px;
line-height: 1.6;
position: relative;
transition: box-shadow 0.25s ease, border-color 0.25s ease;
width: 100%;
}
.me-wrapper:hover { box-shadow: var(--md-hover-shadow); }
.me-wrapper.me-disabled { opacity: 0.6; pointer-events: none; }
.me-wrapper * { box-sizing: border-box; }
.me-wrapper.me-fullscreen {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
z-index: 9999;
border-radius: 0;
border: 0;
}
/* ============ 工具栏 ============ */
.me-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 6px 8px;
background: var(--md-toolbar-bg);
border-bottom: 1px solid var(--md-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
min-height: 40px;
}
.me-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px; height: 30px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--md-text);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease;
flex-shrink: 0;
}
.me-btn:hover { background: var(--md-code-bg); color: var(--md-accent); }
.me-btn:active { transform: scale(0.92); }
.me-btn.me-active { background: var(--md-accent); color: #fff; }
.me-btn:focus-visible { outline: 2px solid var(--md-accent); outline-offset: 1px; }
.me-btn svg { width: 17px; height: 17px; display: block; }
.me-btn span { font-size: 12px; font-weight: 500; }
.me-toolbar-sep {
display: inline-block;
width: 1px; height: 20px;
background: var(--md-border);
margin: 0 4px;
flex-shrink: 0;
}
.me-toolbar-group {
display: inline-flex;
gap: 2px;
margin-left: auto;
padding-left: 6px;
border-left: 1px solid var(--md-border);
}
.me-toolbar-group .me-btn { width: auto; padding: 0 8px; }
.me-toolbar-group .me-btn span { font-size: 12px; }
/* ============ 主体布局 ============ */
.me-body {
display: flex;
flex: 1;
min-height: 0;
position: relative;
}
.me-editor-pane, .me-preview-pane {
flex: 1 1 50%;
min-width: 0;
overflow: hidden;
position: relative;
}
.me-divider {
flex: 0 0 1px;
background: var(--md-border);
cursor: col-resize;
position: relative;
}
.me-divider::after {
content: '';
position: absolute;
top: 0; bottom: 0; left: -3px; right: -3px;
}
.me-divider:hover { background: var(--md-accent); }
/* 编辑区 */
.me-textarea {
width: 100%;
height: 100%;
padding: 16px 18px;
border: 0;
outline: 0;
resize: none;
background: var(--md-textarea-bg);
color: var(--md-text);
font-family: var(--md-mono);
font-size: 13.5px;
line-height: 1.7;
tab-size: 2;
-moz-tab-size: 2;
display: block;
white-space: pre-wrap;
word-wrap: break-word;
}
.me-textarea::placeholder { color: var(--md-muted); opacity: 0.7; }
.me-textarea:focus { outline: 0; }
/* 预览区 */
.me-preview-pane { overflow: auto; background: var(--md-preview-bg); }
.me-preview {
padding: 18px 22px;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 模式切换 */
.me-body.me-mode-edit .me-preview-pane,
.me-body.me-mode-edit .me-divider { display: none; }
.me-body.me-mode-edit .me-editor-pane { flex: 1 1 100%; }
.me-body.me-mode-preview .me-editor-pane,
.me-body.me-mode-preview .me-divider { display: none; }
.me-body.me-mode-preview .me-preview-pane { flex: 1 1 100%; }
/* ============ 预览区排版 ============ */
.me-preview > :first-child { margin-top: 0; }
.me-preview > :last-child { margin-bottom: 0; }
.me-preview h1, .me-preview h2, .me-preview h3,
.me-preview h4, .me-preview h5, .me-preview h6 {
margin: 1.4em 0 0.6em;
font-weight: 650;
line-height: 1.3;
}
.me-preview h1 { font-size: 1.9em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
.me-preview h2 { font-size: 1.55em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
.me-preview h3 { font-size: 1.3em; }
.me-preview h4 { font-size: 1.12em; }
.me-preview h5 { font-size: 1em; }
.me-preview h6 { font-size: 0.9em; color: var(--md-muted); }
.me-preview p { margin: 0.7em 0; }
.me-preview a { color: var(--md-accent); text-decoration: none; }
.me-preview a:hover { text-decoration: underline; }
.me-preview strong { font-weight: 650; }
.me-preview em { font-style: italic; }
.me-preview del { text-decoration: line-through; opacity: 0.75; }
.me-preview ul, .me-preview ol { margin: 0.6em 0; padding-left: 1.6em; }
.me-preview li { margin: 0.25em 0; }
.me-preview li.me-task-item { list-style: none; margin-left: -1.4em; }
.me-preview li.me-task-item input { margin-right: 0.5em; vertical-align: middle; }
.me-preview blockquote {
margin: 0.8em 0;
padding: 0.4em 1em;
border-left: 3px solid var(--md-accent);
background: var(--md-code-bg);
border-radius: 0 6px 6px 0;
color: var(--md-text);
}
.me-preview blockquote > :first-child { margin-top: 0; }
.me-preview blockquote > :last-child { margin-bottom: 0; }
.me-preview hr {
border: 0;
height: 1px;
background: var(--md-border);
margin: 1.6em 0;
}
.me-preview code {
font-family: var(--md-mono);
font-size: 0.88em;
padding: 0.15em 0.4em;
background: var(--md-code-bg);
color: var(--md-code-text);
border-radius: 4px;
}
.me-preview pre {
margin: 0.9em 0;
padding: 14px 16px;
background: var(--md-code-bg);
border-radius: 8px;
overflow-x: auto;
border: 1px solid var(--md-border);
}
.me-preview pre code {
padding: 0;
background: transparent;
color: var(--md-code-text);
font-size: 0.9em;
line-height: 1.6;
border-radius: 0;
}
.me-preview img {
max-width: 100%;
height: auto;
border-radius: 6px;
vertical-align: middle;
}
.me-table-wrap { overflow-x: auto; margin: 0.9em 0; }
.me-preview table {
border-collapse: collapse;
width: 100%;
font-size: 0.93em;
display: block;
}
.me-preview th, .me-preview td {
border: 1px solid var(--md-border);
padding: 7px 12px;
text-align: left;
}
.me-preview th {
background: var(--md-code-bg);
font-weight: 600;
}
.me-preview tr:nth-child(even) td { background: var(--md-code-bg); }
.me-preview tr:nth-child(even) td { background-color: color-mix(in srgb, var(--md-code-bg) 40%, transparent); }
/* ============ 状态栏 ============ */
.me-statusbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
padding: 4px 12px;
border-top: 1px solid var(--md-border);
background: var(--md-toolbar-bg);
color: var(--md-muted);
font-size: 12px;
min-height: 26px;
}
.me-statusbar:empty { display: none; }
/* ============ 响应式 ============ */
@media (max-width: 768px) {
.me-body.me-mode-split {
flex-direction: column;
}
.me-body.me-mode-split .me-divider {
flex: 0 0 1px;
width: 100%;
height: 1px;
cursor: row-resize;
}
.me-body.me-mode-split .me-divider::after {
top: -3px; bottom: -3px; left: 0; right: 0;
}
.me-body.me-mode-split .me-editor-pane,
.me-body.me-mode-split .me-preview-pane {
flex: 1 1 50%;
width: 100%;
}
.me-toolbar { padding: 4px; }
.me-btn { width: 28px; height: 28px; }
.me-preview { padding: 14px; }
.me-textarea { padding: 12px; font-size: 13px; }
}
/* ============ 无障碍 ============ */
.me-wrapper:focus-within {
border-color: var(--md-accent);
}
.me-textarea:focus-visible { outline: 0; }
/* ============ 动画 ============ */
@media (prefers-reduced-motion: reduce) {
.me-wrapper, .me-btn, .me-textarea { transition: none !important; }
}
/* ============ 全屏模式优化 ============ */
.me-wrapper.me-fullscreen .me-preview,
.me-wrapper.me-fullscreen .me-textarea { font-size: 15px; }
`;
};
/**
* 注入样式(单例)
*/
export const injectStyles = () => {
if (typeof document === 'undefined') return;
if (styleElement && document.getElementById('metona-editor-styles')) return;
const css = generateCSS();
styleElement = document.createElement('style');
styleElement.id = 'metona-editor-styles';
styleElement.textContent = css.replace(/\s+/g, ' ');
document.head.appendChild(styleElement);
};
/**
* 更新样式
*/
export const updateStyles = () => {
if (!styleElement) { injectStyles(); return; }
styleElement.textContent = generateCSS().replace(/\s+/g, ' ');
};
/**
* 移除样式
*/
export const removeStyles = () => {
if (styleElement && styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
styleElement = null;
};
/**
* 获取系统主题
*/
export const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
/**
* 监听系统主题变化
*/
export const watchSystemTheme = (callback) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => callback(e.matches ? 'dark' : 'light');
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
};
export { THEMES };
export default { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme };
+388
View File
@@ -0,0 +1,388 @@
/**
* MetonaEditor Themes - 主题管理
* @module themes
* @version 0.1.0
* @description 主题系统、自定义主题和主题切换
*/
import { THEMES } from './constants.js';
import { prefersDark } from './utils.js';
let currentTheme = 'auto';
let themeListeners = new Set();
let systemThemeMediaQuery = null;
let systemThemeListener = null;
/**
* 获取系统主题
*/
export const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light';
return prefersDark() ? 'dark' : 'light';
};
/**
* 获取主题(别名)
*/
export const getTheme = (theme) => {
return resolveTheme(theme);
};
/**
* 解析主题
*/
export const resolveTheme = (theme) => {
if (theme === 'auto') {
if (currentTheme && currentTheme !== 'auto') {
return currentTheme;
}
return getSystemTheme();
}
return theme;
};
/**
* 获取主题配置
*/
export const getThemeConfig = (theme) => {
const resolved = resolveTheme(theme);
return THEMES[resolved] || THEMES.light;
};
/**
* 应用主题
*/
export const applyTheme = (theme) => {
currentTheme = theme;
const resolved = resolveTheme(theme);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-md-theme', resolved);
document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto');
document.documentElement.classList.add(`md-theme-${resolved}`);
const config = getThemeConfig(theme);
setThemeVariables(config);
}
notifyThemeListeners(theme, resolved);
};
/**
* 设置主题CSS变量
*/
export const setThemeVariables = (config) => {
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
const root = document.documentElement;
// 基础变量(保留用于全局与组件回退)
root.style.setProperty('--md-bg', config.bg);
root.style.setProperty('--md-text', config.text);
root.style.setProperty('--md-border', config.border);
root.style.setProperty('--md-shadow', config.shadow);
root.style.setProperty('--md-hover-shadow', config.hoverShadow);
// 编辑器专属变量
root.style.setProperty('--md-toolbar-bg', config.toolbarBg || config.bg);
root.style.setProperty('--md-textarea-bg', config.textareaBg || config.bg);
root.style.setProperty('--md-preview-bg', config.previewBg || config.bg);
root.style.setProperty('--md-code-bg', config.codeBg || 'rgba(127,127,127,0.1)');
root.style.setProperty('--md-code-text', config.codeText || config.text);
root.style.setProperty('--md-accent', config.accent || '#3b82f6');
root.style.setProperty('--md-muted', config.muted || '#9ca3af');
// 兼容旧字段
if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg);
if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
};
/**
* 获取当前主题
*/
export const getCurrentTheme = () => {
return currentTheme;
};
/**
* 获取解析后的主题
*/
export const getResolvedTheme = () => {
return resolveTheme(currentTheme);
};
/**
* 切换主题
*/
export const switchTheme = (theme) => {
applyTheme(theme);
saveTheme(theme);
};
/**
* 切换亮色/暗色主题
*/
export const toggleTheme = () => {
const resolved = getResolvedTheme();
const newTheme = resolved === 'dark' ? 'light' : 'dark';
switchTheme(newTheme);
};
/**
* 重置为自动主题
*/
export const resetToAuto = () => {
switchTheme('auto');
};
/**
* 保存主题到本地存储
*/
export const saveTheme = (theme) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-theme', theme);
} catch (e) {
console.warn('Failed to save theme:', e);
}
}
};
/**
* 从本地存储加载主题
*/
export const loadTheme = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-theme') || 'auto';
} catch (e) {
console.warn('Failed to load theme:', e);
}
}
return 'auto';
};
/**
* 初始化主题系统
*/
export const initTheme = () => {
const savedTheme = loadTheme();
applyTheme(savedTheme);
watchSystemTheme();
};
/**
* 监听系统主题变化
*/
export const watchSystemTheme = () => {
if (typeof window === 'undefined' || !window.matchMedia) return;
if (systemThemeMediaQuery && systemThemeListener) {
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
}
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemThemeListener = (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
};
systemThemeMediaQuery.addEventListener('change', systemThemeListener);
};
/**
* 停止监听系统主题变化
*/
export const unwatchSystemTheme = () => {
if (systemThemeMediaQuery && systemThemeListener) {
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
systemThemeMediaQuery = null;
systemThemeListener = null;
}
};
/**
* 添加主题监听器
*/
export const addThemeListener = (listener) => {
themeListeners.add(listener);
return () => {
themeListeners.delete(listener);
};
};
/**
* 移除主题监听器
*/
export const removeThemeListener = (listener) => {
themeListeners.delete(listener);
};
/**
* 通知主题监听器
*/
const notifyThemeListeners = (theme, resolved) => {
themeListeners.forEach((listener) => {
try {
listener(theme, resolved);
} catch (e) {
console.error('Theme listener error:', e);
}
});
};
/**
* 清除所有主题监听器
*/
export const clearThemeListeners = () => {
themeListeners.clear();
};
/**
* 注册自定义主题
*/
export const registerTheme = (name, config) => {
const base = THEMES.light;
THEMES[name] = {
bg: config.bg || base.bg,
text: config.text || base.text,
border: config.border || base.border,
shadow: config.shadow || base.shadow,
hoverShadow: config.hoverShadow || base.hoverShadow,
toolbarBg: config.toolbarBg || config.bg || base.toolbarBg,
textareaBg: config.textareaBg || config.bg || base.textareaBg,
previewBg: config.previewBg || config.bg || base.previewBg,
codeBg: config.codeBg || base.codeBg,
codeText: config.codeText || base.codeText,
accent: config.accent || base.accent,
muted: config.muted || base.muted,
// 兼容旧字段
progressBg: config.progressBg,
closeHoverBg: config.closeHoverBg,
};
};
/**
* 注销自定义主题
*/
export const unregisterTheme = (name) => {
if (name === 'light' || name === 'dark' || name === 'auto') {
console.warn('Cannot unregister built-in theme:', name);
return;
}
delete THEMES[name];
};
/**
* 获取所有主题
*/
export const getAllThemes = () => {
return { ...THEMES };
};
/**
* 获取主题名称列表
*/
export const getThemeNames = () => {
return Object.keys(THEMES);
};
/**
* 检查主题是否存在
*/
export const hasTheme = (name) => {
return name in THEMES;
};
/**
* 主题工具 — 代理层
*/
export const themeUtils = {
getSystemTheme,
resolveTheme,
getThemeConfig,
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
initTheme,
watchSystemTheme,
unwatchSystemTheme,
addThemeListener,
removeThemeListener,
clearThemeListeners,
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
saveTheme,
loadTheme,
};
/**
* 预设主题
*/
export const presetThemes = {
light: {
name: '浅色',
description: '明亮清晰的主题',
config: THEMES.light,
},
dark: {
name: '深色',
description: '护眼舒适的暗色主题',
config: THEMES.dark,
},
auto: {
name: '自动',
description: '跟随系统主题设置',
config: 'auto',
},
warm: {
name: '暖色',
description: '温馨舒适的暖色主题',
config: THEMES.warm,
},
};
// 注册预设主题
Object.entries(presetThemes).forEach(([name, theme]) => {
if (name !== 'auto' && theme.config !== 'auto') {
THEMES[name] = theme.config;
}
});
/**
* 创建主题管理器
*/
export const createThemeManager = () => {
return {
getSystemTheme,
resolveTheme,
getThemeConfig,
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
initTheme,
watchSystemTheme,
unwatchSystemTheme,
addThemeListener,
removeThemeListener,
clearThemeListeners,
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
saveTheme,
loadTheme,
};
};
export { themeUtils as default };
+138
View File
@@ -0,0 +1,138 @@
/**
* MetonaEditor Utils - 工具函数
* @module utils
* @version 0.1.0
* @description 通用工具函数集合
*/
/**
* 生成唯一ID
* @returns {string} 唯一ID
*/
export const generateId = () => {
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
};
/**
* HTML转义
* @param {string} s - 输入字符串
* @returns {string} 转义后的字符串
*/
export const escapeHTML = (s) => {
if (typeof document === 'undefined') {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const div = document.createElement('div');
div.textContent = String(s == null ? '' : s);
return div.innerHTML;
};
/**
* 检测暗色模式偏好
* @returns {boolean} 是否偏好暗色模式
*/
export const prefersDark = () => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间(毫秒)
* @param {boolean} immediate - 是否立即执行
* @returns {Function} 防抖后的函数
*/
export const debounce = (func, wait, immediate = false) => {
let timeout;
return function executedFunction(...args) {
const context = this;
const later = () => {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
/**
* 节流函数
* @param {Function} func - 要节流的函数
* @param {number} limit - 限制时间(毫秒)
* @returns {Function} 节流后的函数
*/
export const throttle = (func, limit) => {
let inThrottle;
return function executedFunction(...args) {
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
};
/**
* 深度合并对象
* @param {Object} target - 目标对象
* @param {Object} source - 源对象
* @returns {Object} 合并后的对象
*/
export const deepMerge = (target, source) => {
const output = { ...target };
Object.keys(source).forEach(key => {
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
output[key] = deepMerge(target[key], source[key]);
} else {
output[key] = source[key];
}
});
return output;
};
/**
* 检查是否为浏览器环境
* @returns {boolean} 是否为浏览器环境
*/
export const isBrowser = () => {
return typeof window !== 'undefined' && typeof document !== 'undefined';
};
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @param {number} decimals - 小数位数
* @returns {string} 格式化后的字符串
*/
export const formatFileSize = (bytes, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
/**
* 延迟执行
* @param {number} ms - 毫秒数
* @returns {Promise} Promise对象
*/
export const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
+146
View File
@@ -0,0 +1,146 @@
/**
* animations.js 单元测试
* 覆盖 animationUtils 全部 API + createAnimation 工厂
*/
import { animationUtils, createAnimation, animationPresets } from '../src/animations.js';
import { ANIMATIONS } from '../src/constants.js';
describe('animationUtils - 默认动画注册', () => {
test('默认动画已注册到 animationMap', () => {
const names = animationUtils.getAnimationNames();
// 与 constants.js 中 ANIMATIONS 的键一致
Object.keys(ANIMATIONS).forEach((name) => {
expect(names).toContain(name);
});
});
test('内置动画类型完整', () => {
const names = animationUtils.getAnimationNames();
expect(names).toEqual(expect.arrayContaining([
'slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
]));
});
});
describe('animationUtils - register / unregister / get', () => {
test('register 注册自定义动画', () => {
animationUtils.register('custom', {
enter: { opacity: 0 },
leave: { opacity: 0 },
duration: 500,
easing: 'ease-in',
});
const a = animationUtils.get('custom');
expect(a).not.toBeNull();
expect(a.name).toBe('custom');
expect(a.duration).toBe(500);
expect(a.easing).toBe('ease-in');
expect(a.enter).toEqual({ opacity: 0 });
});
test('register 缺省字段使用默认值', () => {
animationUtils.register('minimal', {});
const a = animationUtils.get('minimal');
expect(a.enter).toEqual({});
expect(a.leave).toEqual({});
expect(a.duration).toBe(300);
expect(a.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
});
test('unregister 移除动画', () => {
animationUtils.register('tmp', { duration: 100 });
expect(animationUtils.get('tmp')).not.toBeNull();
animationUtils.unregister('tmp');
// 移除后回退到 ANIMATIONS,但 'tmp' 不在 ANIMATIONS 中,返回 null
expect(animationUtils.get('tmp')).toBeNull();
});
test('get 不存在的动画返回 null', () => {
expect(animationUtils.get('non-existent-animation')).toBeNull();
});
test('get 内置动画返回配置', () => {
const a = animationUtils.get('slide');
expect(a).not.toBeNull();
// 内置动画直接存储 ANIMATIONS.slide 配置(不含 name 字段,只有 register() 才添加)
expect(a.duration).toBe(ANIMATIONS.slide.duration);
expect(a.enter).toEqual(ANIMATIONS.slide.enter);
});
});
describe('animationUtils - 查询 API', () => {
test('getAnimationNames 返回数组', () => {
const names = animationUtils.getAnimationNames();
expect(Array.isArray(names)).toBe(true);
expect(names.length).toBeGreaterThan(0);
});
test('getActiveCount 始终返回 0CSS 动画由浏览器管理)', () => {
expect(animationUtils.getActiveCount()).toBe(0);
});
test('cancelAll 不抛错(无操作)', () => {
expect(() => animationUtils.cancelAll()).not.toThrow();
});
});
describe('animationUtils - reset / destroy', () => {
test('reset 清除自定义动画并恢复内置', () => {
animationUtils.register('tmp1', {});
animationUtils.register('tmp2', {});
expect(animationUtils.getAnimationNames()).toContain('tmp1');
animationUtils.reset();
const names = animationUtils.getAnimationNames();
expect(names).not.toContain('tmp1');
expect(names).not.toContain('tmp2');
// 内置动画仍存在
expect(names).toContain('slide');
expect(names).toContain('fade');
});
test('destroy 清空所有动画', () => {
animationUtils.destroy();
expect(animationUtils.getAnimationNames()).toEqual([]);
// destroy 后 reset 可恢复
animationUtils.reset();
expect(animationUtils.getAnimationNames().length).toBeGreaterThan(0);
});
});
describe('createAnimation 工厂', () => {
test('返回完整动画对象', () => {
const a = createAnimation({
enter: { transform: 'translateX(10px)' },
leave: { transform: 'translateX(-10px)' },
duration: 600,
easing: 'linear',
});
expect(a.enter).toEqual({ transform: 'translateX(10px)' });
expect(a.leave).toEqual({ transform: 'translateX(-10px)' });
expect(a.duration).toBe(600);
expect(a.easing).toBe('linear');
});
test('缺省字段使用默认值', () => {
const a = createAnimation({});
expect(a.enter).toEqual({});
expect(a.leave).toEqual({});
expect(a.duration).toBe(300);
expect(a.easing).toBe('cubic-bezier(0.4, 0, 0.2, 1)');
});
test('无参数也能创建', () => {
const a = createAnimation();
expect(a).toBeDefined();
expect(a.duration).toBe(300);
});
});
describe('animationPresets', () => {
test('导出为对象', () => {
expect(typeof animationPresets).toBe('object');
});
});
+1227
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
/**
* i18n.js 单元测试
* 覆盖国际化系统:翻译 / 语言切换 / 插值 / 格式化 / 监听 / 持久化
*
* 注意:i18n.js 使用模块级状态(currentLocale / localeListeners / fallbackLocale),
* 测试间需要重置状态以避免相互影响。
*/
import {
i18nUtils,
t,
getCurrentLocale,
setCurrentLocale,
switchLocale,
hasTranslation,
getTranslations,
addTranslations,
getSupportedLocales,
isLocaleSupported,
getLocaleName,
getLocaleDirection,
formatNumber,
formatCurrency,
formatDate,
addLocaleListener,
removeLocaleListener,
clearLocaleListeners,
saveLocale,
loadLocale,
getDefaultLocale,
initI18n,
presetLocales,
createI18nManager,
} from '../src/i18n.js';
import { LOCALES } from '../src/constants.js';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
const store = {};
global.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
};
}
describe('当前语言', () => {
beforeEach(() => {
clearLocaleListeners();
setCurrentLocale('zh-CN');
localStorage.clear();
});
test('getCurrentLocale 默认 zh-CN', () => {
expect(getCurrentLocale()).toBe('zh-CN');
});
test('setCurrentLocale 切换语言', () => {
setCurrentLocale('en-US');
expect(getCurrentLocale()).toBe('en-US');
});
test('setCurrentLocale 未知语言回退到 fallback', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
setCurrentLocale('fr-FR');
expect(spy).toHaveBeenCalled();
expect(getCurrentLocale()).toBe('zh-CN');
spy.mockRestore();
});
test('switchLocale 是 setCurrentLocale 的别名', () => {
switchLocale('en-US');
expect(getCurrentLocale()).toBe('en-US');
});
});
describe('翻译函数 t', () => {
beforeEach(() => {
setCurrentLocale('zh-CN');
});
test('返回当前语言的翻译', () => {
expect(t('bold')).toBe('粗体');
expect(t('italic')).toBe('斜体');
});
test('切换语言后返回新语言翻译', () => {
setCurrentLocale('en-US');
expect(t('bold')).toBe('Bold');
expect(t('italic')).toBe('Italic');
});
test('未知 key 返回 key 本身', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(t('non-existent-key')).toBe('non-existent-key');
spy.mockRestore();
});
test('插值替换占位符', () => {
// 临时添加带占位符的翻译
addTranslations('zh-CN', { greeting: '你好,{name}' });
expect(t('greeting', { name: '爸爸' })).toBe('你好,爸爸!');
});
test('插值未提供占位符值时保留原占位符', () => {
addTranslations('zh-CN', { greeting: '你好,{name}' });
expect(t('greeting', {})).toBe('你好,{name}');
});
test('fallback 语言提供翻译', () => {
// 临时切换到不存在的语言会回退;这里用 en-US 但 key 只在 zh-CN 中
setCurrentLocale('en-US');
// en-US 中也有 bold,所以构造一个只在 zh-CN 中的场景较难,
// 改为直接验证:en-US 切换后 t('bold') 来自 en-US 而非 zh-CN
expect(t('bold')).toBe('Bold');
});
});
describe('翻译查询', () => {
test('hasTranslation 已知 key 返回 true', () => {
setCurrentLocale('zh-CN');
expect(hasTranslation('bold')).toBe(true);
expect(hasTranslation('italic')).toBe(true);
});
test('hasTranslation 未知 key 返回 false', () => {
expect(hasTranslation('non-existent')).toBe(false);
});
test('getTranslations 返回指定语言的翻译对象', () => {
const tr = getTranslations('zh-CN');
expect(tr.bold).toBe('粗体');
expect(tr.italic).toBe('斜体');
});
test('getTranslations 未知语言返回空对象', () => {
expect(getTranslations('fr-FR')).toEqual({});
});
});
describe('添加翻译', () => {
beforeEach(() => {
setCurrentLocale('zh-CN');
});
test('addTranslations 添加新语言', () => {
addTranslations('ja', { bold: '太字' });
expect(LOCALES.ja).toBeDefined();
expect(LOCALES.ja.bold).toBe('太字');
setCurrentLocale('ja');
expect(t('bold')).toBe('太字');
// 清理
delete LOCALES.ja;
});
test('addTranslations 深度合并到已有语言', () => {
addTranslations('zh-CN', { customKey: '自定义值' });
expect(LOCALES['zh-CN'].customKey).toBe('自定义值');
// 原有 key 不受影响
expect(LOCALES['zh-CN'].bold).toBe('粗体');
delete LOCALES['zh-CN'].customKey;
});
});
describe('语言支持查询', () => {
test('getSupportedLocales 返回内置语言', () => {
const locales = getSupportedLocales();
expect(locales).toContain('zh-CN');
expect(locales).toContain('en-US');
});
test('isLocaleSupported 已知语言返回 true', () => {
expect(isLocaleSupported('zh-CN')).toBe(true);
expect(isLocaleSupported('en-US')).toBe(true);
});
test('isLocaleSupported 未知语言返回 false', () => {
expect(isLocaleSupported('fr-FR')).toBe(false);
expect(isLocaleSupported('')).toBe(false);
});
});
describe('语言元信息', () => {
test('getLocaleName 返回语言显示名', () => {
expect(getLocaleName('zh-CN')).toBe('简体中文');
expect(getLocaleName('en-US')).toBe('English (US)');
expect(getLocaleName('ja')).toBe('日本語');
});
test('getLocaleName 未知语言返回语言代码本身', () => {
expect(getLocaleName('unknown')).toBe('unknown');
});
test('getLocaleDirection LTR 语言', () => {
expect(getLocaleDirection('zh-CN')).toBe('ltr');
expect(getLocaleDirection('en-US')).toBe('ltr');
});
test('getLocaleDirection RTL 语言', () => {
expect(getLocaleDirection('ar')).toBe('rtl');
expect(getLocaleDirection('he')).toBe('rtl');
expect(getLocaleDirection('fa')).toBe('rtl');
});
});
describe('格式化函数', () => {
beforeEach(() => {
setCurrentLocale('en-US');
});
test('formatNumber 格式化数字', () => {
const r = formatNumber(1234567);
expect(typeof r).toBe('string');
expect(r).toContain('1');
expect(r).toContain('234');
});
test('formatNumber 接受 options', () => {
const r = formatNumber(0.5, { style: 'percent' });
expect(r).toContain('50');
});
test('formatCurrency 格式化货币', () => {
const r = formatCurrency(99.99, 'USD');
expect(typeof r).toBe('string');
expect(r).toContain('99');
});
test('formatDate 格式化日期', () => {
const r = formatDate('2024-01-15');
expect(typeof r).toBe('string');
// 至少包含年份信息
expect(r.length).toBeGreaterThan(0);
});
test('formatDate 接受 Date 对象', () => {
const r = formatDate(new Date('2024-06-15'));
expect(typeof r).toBe('string');
expect(r.length).toBeGreaterThan(0);
});
test('formatNumber 无效输入回退为字符串', () => {
const r = formatNumber('not-a-number');
expect(typeof r).toBe('string');
});
});
describe('语言监听', () => {
beforeEach(() => {
clearLocaleListeners();
setCurrentLocale('zh-CN');
});
test('addLocaleListener 注册回调', () => {
const fn = jest.fn();
addLocaleListener(fn);
setCurrentLocale('en-US');
expect(fn).toHaveBeenCalledWith('en-US');
});
test('addLocaleListener 返回取消函数', () => {
const fn = jest.fn();
const unsub = addLocaleListener(fn);
expect(typeof unsub).toBe('function');
unsub();
setCurrentLocale('en-US');
expect(fn).not.toHaveBeenCalled();
});
test('removeLocaleListener 移除回调', () => {
const fn = jest.fn();
addLocaleListener(fn);
removeLocaleListener(fn);
setCurrentLocale('en-US');
expect(fn).not.toHaveBeenCalled();
});
test('clearLocaleListeners 清空所有回调', () => {
const fn1 = jest.fn();
const fn2 = jest.fn();
addLocaleListener(fn1);
addLocaleListener(fn2);
clearLocaleListeners();
setCurrentLocale('en-US');
expect(fn1).not.toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
});
test('监听器抛错不影响其他监听器', () => {
const errFn = jest.fn(() => { throw new Error('boom'); });
const okFn = jest.fn();
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
addLocaleListener(errFn);
addLocaleListener(okFn);
setCurrentLocale('en-US');
expect(errFn).toHaveBeenCalled();
expect(okFn).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('语言持久化', () => {
beforeEach(() => {
localStorage.clear();
setCurrentLocale('zh-CN');
});
test('saveLocale 写入 localStorage', () => {
saveLocale('en-US');
expect(localStorage.getItem('metona-editor-locale')).toBe('en-US');
});
test('loadLocale 无保存时返回默认语言', () => {
// 无保存时调用 getDefaultLocale
const r = loadLocale();
expect(typeof r).toBe('string');
expect(r.length).toBeGreaterThan(0);
});
test('loadLocale 读取已保存值', () => {
localStorage.setItem('metona-editor-locale', 'en-US');
expect(loadLocale()).toBe('en-US');
});
test('initI18n 加载已保存的语言', () => {
localStorage.setItem('metona-editor-locale', 'en-US');
initI18n();
expect(getCurrentLocale()).toBe('en-US');
});
});
describe('默认语言探测', () => {
test('getDefaultLocale 返回字符串', () => {
const r = getDefaultLocale();
expect(typeof r).toBe('string');
expect(r.length).toBeGreaterThan(0);
});
test('navigator 不支持时返回 fallback', () => {
const origNav = global.navigator;
Object.defineProperty(global, 'navigator', { value: undefined, configurable: true });
expect(getDefaultLocale()).toBe('zh-CN');
Object.defineProperty(global, 'navigator', { value: origNav, configurable: true });
});
});
describe('fallback 语言', () => {
test('getFallbackLocale 默认 zh-CN', () => {
expect(i18nUtils.getFallbackLocale()).toBe('zh-CN');
});
test('setFallbackLocale 修改 fallback', () => {
i18nUtils.setFallbackLocale('en-US');
expect(i18nUtils.getFallbackLocale()).toBe('en-US');
// 恢复
i18nUtils.setFallbackLocale('zh-CN');
});
});
describe('i18nUtils 代理层', () => {
test('暴露所有 i18n API', () => {
[
't', 'getCurrentLocale', 'setCurrentLocale', 'switchLocale',
'getFallbackLocale', 'setFallbackLocale', 'hasTranslation',
'getTranslations', 'addTranslations', 'getSupportedLocales',
'isLocaleSupported', 'getLocaleName', 'getLocaleDirection',
'formatNumber', 'formatCurrency', 'formatDate',
'addLocaleListener', 'removeLocaleListener', 'clearLocaleListeners',
'initI18n', 'saveLocale', 'loadLocale', 'getDefaultLocale',
].forEach((fn) => {
expect(typeof i18nUtils[fn]).toBe('function');
});
});
test('代理方法与具名导出一致', () => {
expect(i18nUtils.t).toBe(t);
expect(i18nUtils.getCurrentLocale).toBe(getCurrentLocale);
expect(i18nUtils.switchLocale).toBe(switchLocale);
});
});
describe('presetLocales', () => {
test('包含 zh-CN 和 en-US', () => {
expect(presetLocales['zh-CN']).toBeDefined();
expect(presetLocales['en-US']).toBeDefined();
});
test('包含 name / nativeName / direction / translations', () => {
const z = presetLocales['zh-CN'];
expect(z.name).toBeDefined();
expect(z.nativeName).toBeDefined();
expect(z.direction).toBe('ltr');
expect(z.translations).toBe(LOCALES['zh-CN']);
});
});
describe('createI18nManager', () => {
test('返回包含全部方法的管理器对象', () => {
const m = createI18nManager();
expect(typeof m.t).toBe('function');
expect(typeof m.setCurrentLocale).toBe('function');
expect(typeof m.formatNumber).toBe('function');
});
test('管理器方法可独立调用', () => {
const m = createI18nManager();
m.setCurrentLocale('en-US');
expect(m.getCurrentLocale()).toBe('en-US');
expect(m.t('bold')).toBe('Bold');
});
});
+353
View File
@@ -0,0 +1,353 @@
/**
* parser.js 单元测试
* 覆盖 Markdown 解析器的所有语法分支与安全特性
*/
import { parseMarkdown, safeUrl, slugify } from '../src/parser.js';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
expect(parseMarkdown('')).toBe('');
expect(parseMarkdown(null)).toBe('');
expect(parseMarkdown(undefined)).toBe('');
});
test('非字符串输入被转换为字符串', () => {
expect(parseMarkdown(123)).toBe('<p>123</p>');
});
test('Windows 换行符被规范化', () => {
const html = parseMarkdown('line1\r\nline2');
expect(html).toContain('line1');
expect(html).toContain('line2');
expect(html).not.toContain('\r');
});
});
describe('parseMarkdown - 标题', () => {
test('h1 到 h6', () => {
expect(parseMarkdown('# T1')).toBe('<h1 id="t1">T1</h1>');
expect(parseMarkdown('## T2')).toBe('<h2 id="t2">T2</h2>');
expect(parseMarkdown('### T3')).toBe('<h3 id="t3">T3</h3>');
expect(parseMarkdown('#### T4')).toBe('<h4 id="t4">T4</h4>');
expect(parseMarkdown('##### T5')).toBe('<h5 id="t5">T5</h5>');
expect(parseMarkdown('###### T6')).toBe('<h6 id="t6">T6</h6>');
});
test('标题文本被内联渲染', () => {
const html = parseMarkdown('# Hello **world**');
expect(html).toContain('<strong>world</strong>');
});
test('标题生成锚点 id', () => {
expect(parseMarkdown('# Hello World')).toContain('id="hello-world"');
});
test('超过 6 个 # 不作为标题', () => {
const html = parseMarkdown('####### not heading');
expect(html).not.toContain('<h7');
expect(html).toContain('<p>');
});
});
describe('parseMarkdown - 段落', () => {
test('单行段落', () => {
expect(parseMarkdown('hello')).toBe('<p>hello</p>');
});
test('多行段落合并', () => {
const html = parseMarkdown('line1\nline2');
expect(html).toBe('<p>line1<br/>line2</p>');
});
test('空行分隔段落', () => {
const html = parseMarkdown('p1\n\np2');
expect(html).toBe('<p>p1</p>\n<p>p2</p>');
});
});
describe('parseMarkdown - 行内格式', () => {
test('粗体 **text**', () => {
expect(parseMarkdown('**bold**')).toBe('<p><strong>bold</strong></p>');
});
test('粗体 __text__', () => {
expect(parseMarkdown('__bold__')).toBe('<p><strong>bold</strong></p>');
});
test('斜体 *text*', () => {
expect(parseMarkdown('a *italic* b')).toBe('<p>a <em>italic</em> b</p>');
});
test('斜体 _text_', () => {
expect(parseMarkdown('a _italic_ b')).toBe('<p>a <em>italic</em> b</p>');
});
test('删除线 ~~text~~', () => {
expect(parseMarkdown('~~del~~')).toBe('<p><del>del</del></p>');
});
test('行内代码 `code`', () => {
const html = parseMarkdown('use `code` here');
expect(html).toContain('<code>code</code>');
});
test('行内代码内容不被解析为格式', () => {
const html = parseMarkdown('`a **b** c`');
expect(html).toContain('<code>a **b** c</code>');
expect(html).not.toContain('<strong>');
});
test('行内代码内容被转义', () => {
const html = parseMarkdown('`<script>`');
expect(html).toContain('&lt;script&gt;');
});
});
describe('parseMarkdown - 代码块', () => {
test('围栏代码块 ```', () => {
const html = parseMarkdown('```\ncode\n```');
expect(html).toContain('<pre><code');
expect(html).toContain('code');
});
test('带语言的代码块', () => {
const html = parseMarkdown('```js\nconsole.log(1)\n```');
expect(html).toContain('class="language-js"');
});
test('波浪线围栏 ~~~', () => {
const html = parseMarkdown('~~~\ncode\n~~~');
expect(html).toContain('<pre><code');
});
test('代码块内容被转义', () => {
const html = parseMarkdown('```\n<a>\n```');
expect(html).toContain('&lt;a&gt;');
});
test('highlight 钩子被调用', () => {
const env = {
highlight: (code, lang) => `<span class="hl">${code}-${lang}</span>`,
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('<span class="hl">foo-js</span>');
});
test('highlight 抛错时回退为转义输出', () => {
const env = {
highlight: () => { throw new Error('boom'); },
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('foo');
expect(html).toContain('<pre><code');
});
});
describe('parseMarkdown - 引用块', () => {
test('单行引用', () => {
const html = parseMarkdown('> quote');
expect(html).toContain('<blockquote>');
expect(html).toContain('quote');
});
test('多行引用', () => {
const html = parseMarkdown('> line1\n> line2');
expect(html).toContain('<blockquote>');
expect(html).toContain('line1');
expect(html).toContain('line2');
});
test('引用内可包含其他语法', () => {
const html = parseMarkdown('> # heading');
expect(html).toContain('<blockquote>');
expect(html).toContain('<h1');
});
});
describe('parseMarkdown - 列表', () => {
test('无序列表 -', () => {
const html = parseMarkdown('- a\n- b');
expect(html).toContain('<ul>');
expect(html).toContain('<li>a</li>');
expect(html).toContain('<li>b</li>');
});
test('无序列表 *', () => {
const html = parseMarkdown('* a\n* b');
expect(html).toContain('<ul>');
});
test('无序列表 +', () => {
const html = parseMarkdown('+ a\n+ b');
expect(html).toContain('<ul>');
});
test('有序列表', () => {
const html = parseMarkdown('1. one\n2. two');
expect(html).toContain('<ol>');
expect(html).toContain('<li>one</li>');
expect(html).toContain('<li>two</li>');
});
test('任务列表未完成', () => {
const html = parseMarkdown('- [ ] todo');
expect(html).toContain('me-task-item');
expect(html).toContain('<input type="checkbox" disabled/>');
expect(html).not.toContain('checked');
});
test('任务列表已完成', () => {
const html = parseMarkdown('- [x] done');
expect(html).toContain('checked');
});
test('任务列表大写 X 也算完成', () => {
const html = parseMarkdown('- [X] done');
expect(html).toContain('checked');
});
});
describe('parseMarkdown - 水平线', () => {
test('--- 作为水平线', () => {
expect(parseMarkdown('---')).toBe('<hr/>');
});
test('*** 作为水平线', () => {
expect(parseMarkdown('***')).toBe('<hr/>');
});
test('___ 作为水平线', () => {
expect(parseMarkdown('___')).toBe('<hr/>');
});
});
describe('parseMarkdown - 表格', () => {
test('基本表格', () => {
const md = '| a | b |\n| --- | --- |\n| 1 | 2 |';
const html = parseMarkdown(md);
expect(html).toContain('<table>');
expect(html).toContain('<thead>');
expect(html).toContain('<th>a</th>');
expect(html).toContain('<th>b</th>');
expect(html).toContain('<td>1</td>');
expect(html).toContain('<td>2</td>');
});
test('表格被 me-table-wrap 包裹', () => {
const md = '| a |\n| --- |\n| 1 |';
expect(parseMarkdown(md)).toContain('me-table-wrap');
});
test('非分隔行不构成表格', () => {
const html = parseMarkdown('| a |\nnot a separator');
expect(html).not.toContain('<table>');
});
});
describe('parseMarkdown - 链接与图片', () => {
test('基本链接', () => {
const html = parseMarkdown('[text](https://example.com)');
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('>text</a>');
});
test('自动链接 <url>', () => {
const html = parseMarkdown('<https://example.com>');
expect(html).toContain('<a href="https://example.com"');
});
test('图片', () => {
const html = parseMarkdown('![alt](https://example.com/img.png)');
expect(html).toContain('<img src="https://example.com/img.png"');
expect(html).toContain('alt="alt"');
expect(html).toContain('loading="lazy"');
});
});
describe('parseMarkdown - XSS 防护', () => {
test('文本中的 HTML 标签被转义', () => {
const html = parseMarkdown('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
});
test('javascript: 协议链接被过滤', () => {
const html = parseMarkdown('[click](javascript:alert(1))');
expect(html).not.toContain('href="javascript:');
});
test('vbscript: 协议被过滤', () => {
const html = parseMarkdown('[x](vbscript:msgbox(1))');
expect(html).not.toContain('href="vbscript:');
});
test('file: 协议被过滤', () => {
const html = parseMarkdown('[x](file:///etc/passwd)');
expect(html).not.toContain('href="file:');
});
test('非 image 的 data: 协议被过滤', () => {
const html = parseMarkdown('[x](data:text/html,<script>)');
expect(html).not.toContain('href="data:text/html');
});
test('data:image: 协议被允许', () => {
const html = parseMarkdown('![x](data:image/png;base64,abc)');
expect(html).toContain('src="data:image/png;base64,abc"');
});
test('图片 javascript: 协议被过滤', () => {
const html = parseMarkdown('![x](javascript:alert(1))');
expect(html).not.toContain('src="javascript:');
});
});
describe('safeUrl', () => {
test('正常 http url 原样返回', () => {
expect(safeUrl('https://example.com')).toBe('https://example.com');
});
test('空值返回空字符串', () => {
expect(safeUrl('')).toBe('');
expect(safeUrl(null)).toBe('');
expect(safeUrl(undefined)).toBe('');
});
test('javascript: 返回空', () => {
expect(safeUrl('javascript:alert(1)')).toBe('');
});
test('大小写不敏感过滤', () => {
expect(safeUrl('JAVASCRIPT:alert(1)')).toBe('');
expect(safeUrl('JavaScript:alert(1)')).toBe('');
});
test('trim 空白', () => {
expect(safeUrl(' https://example.com ')).toBe('https://example.com');
});
});
describe('slugify', () => {
test('英文转小写连字符', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('保留中文', () => {
expect(slugify('你好 世界')).toBe('你好-世界');
});
test('移除特殊字符', () => {
expect(slugify('A! B@ C#')).toBe('a-b-c');
});
test('合并多个连字符', () => {
expect(slugify('a---b')).toBe('a-b');
});
test('去除首尾连字符', () => {
expect(slugify('-hello-')).toBe('hello');
});
});
+869
View File
@@ -0,0 +1,869 @@
/**
* plugins.js 单元测试
* 覆盖预设插件 autoSave / exportTool / searchReplace 及 pluginUtils
*/
import { MarkdownEditor } from '../src/core.js';
import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins.js';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
const store = {};
global.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
};
}
// jsdom 不提供 URL.createObjectURL / revokeObjectURL,导出插件依赖它们
if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = () => 'blob:mock';
URL.revokeObjectURL = () => {};
}
function makeEditor(opts = {}) {
document.body.innerHTML = '';
const container = document.createElement('div');
document.body.appendChild(container);
return new MarkdownEditor(container, { value: 'hello', ...opts });
}
describe('presetPlugins 注册表', () => {
test('包含三个预设插件', () => {
expect(presetPlugins.autoSave).toBeDefined();
expect(presetPlugins.exportTool).toBeDefined();
expect(presetPlugins.searchReplace).toBeDefined();
});
test('每个插件都有 name 和 install', () => {
Object.values(presetPlugins).forEach((p) => {
expect(typeof p.name).toBe('string');
expect(typeof p.install).toBe('function');
});
});
});
describe('autoSave 插件', () => {
let ed;
beforeEach(() => {
localStorage.clear();
ed = makeEditor();
ed.use(presetPlugins.autoSave);
});
afterEach(() => ed.destroy());
test('安装后暴露草稿 API', () => {
expect(typeof ed.restoreDraft).toBe('function');
expect(typeof ed.clearDraft).toBe('function');
expect(typeof ed.getDraftKey).toBe('function');
});
test('getDraftKey 返回包含 id 的 key', () => {
const key = ed.getDraftKey();
expect(key).toContain('me-draft-');
});
test('change 后内容最终写入 localStorage', (done) => {
ed.setValue('autosaved content');
// autoSave 默认 delay 1000ms,等 1100ms
setTimeout(() => {
const key = ed.getDraftKey();
const stored = localStorage.getItem(key);
expect(stored).toBe('autosaved content');
done();
}, 1150);
});
test('clearDraft 清除存储', () => {
const key = ed.getDraftKey();
localStorage.setItem(key, 'tmp');
ed.clearDraft();
expect(localStorage.getItem(key)).toBeNull();
});
test('restoreDraft 从存储恢复', () => {
const key = ed.getDraftKey();
localStorage.setItem(key, '# restored');
ed.restoreDraft();
expect(ed.getValue()).toBe('# restored');
});
test('destroy 时清理定时器不抛错', () => {
expect(() => ed.destroy()).not.toThrow();
});
});
describe('autoSave 自定义配置', () => {
test('自定义 key', () => {
localStorage.clear();
const ed = makeEditor();
ed.use(presetPlugins.autoSave, { key: 'my-key', delay: 50 });
ed.setValue('data');
expect(ed.getDraftKey()).toBe('my-key');
ed.destroy();
});
test('自定义 delay 生效', (done) => {
localStorage.clear();
const ed = makeEditor();
ed.use(presetPlugins.autoSave, { delay: 30 });
ed.setValue('fast');
setTimeout(() => {
expect(localStorage.getItem(ed.getDraftKey())).toBe('fast');
ed.destroy();
done();
}, 60);
});
});
describe('exportTool 插件', () => {
let ed;
beforeEach(() => {
ed = makeEditor({ value: '# export me' });
ed.use(presetPlugins.exportTool);
});
afterEach(() => ed.destroy());
test('安装后暴露导出方法', () => {
expect(typeof ed.exportMarkdown).toBe('function');
expect(typeof ed.exportHTML).toBe('function');
});
test('exportMarkdown 创建下载链接', () => {
// 在原型层拦截 a.click,避免 mock createElement 导致递归
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
ed.exportMarkdown('test.md');
expect(clickMock).toHaveBeenCalled();
clickMock.mockRestore();
});
test('exportHTML 包含 HTML 文档结构', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(() => ed.exportHTML('test.html', { title: 'My Title' })).not.toThrow();
clickMock.mockRestore();
});
test('exportMarkdown 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportMarkdown()).toBe(ed);
clickMock.mockRestore();
});
});
describe('searchReplace 插件', () => {
let ed;
beforeEach(() => {
ed = makeEditor({ value: 'find me here, find again' });
ed.use(presetPlugins.searchReplace);
});
afterEach(() => ed.destroy());
test('Ctrl+F 打开搜索面板', () => {
const ta = ed.textarea;
const ev = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true });
ta.dispatchEvent(ev);
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
});
test('Escape 关闭面板(通过面板内输入框)', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
const input = panel.querySelector('.me-search-find');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('查找下一个高亮选中匹配', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
const input = panel.querySelector('.me-search-find');
input.value = 'find';
input.dispatchEvent(new Event('input', { bubbles: true }));
panel.querySelector('.me-search-next').click();
// 选中区域应包含 'find'
expect(ta.value.substring(ta.selectionStart, ta.selectionEnd)).toBe('find');
});
test('全部替换', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
panel.querySelector('.me-search-find').value = 'find';
panel.querySelector('.me-search-replace').value = 'FIND';
panel.querySelector('.me-search-replace-all').click();
expect(ed.getValue()).toBe('FIND me here, FIND again');
});
test('单次替换仅替换当前选区匹配', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const panel = ed.el.querySelector('.me-search');
const findInput = panel.querySelector('.me-search-find');
findInput.value = 'find';
findInput.dispatchEvent(new Event('input', { bubbles: true }));
// 先定位到第一个
panel.querySelector('.me-search-next').click();
panel.querySelector('.me-search-replace').value = 'X';
panel.querySelector('.me-search-replace-one').click();
// 第一个被替换,第二个保留
expect(ed.getValue()).toBe('X me here, find again');
});
test('destroy 移除面板与事件监听', () => {
const ta = ed.textarea;
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
ed.destroy();
// destroy 后 el 为 null,无法查询,但不应抛错
expect(ed.el).toBeNull();
});
});
describe('PluginManager', () => {
test('register / get / has', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
expect(pm.has('x')).toBe(true);
expect(pm.get('x').name).toBe('x');
});
test('重复 register warn', () => {
const pm = new PluginManager();
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
pm.register('x', { name: 'x' });
pm.register('x', { name: 'x' });
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('unregister', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
pm.unregister('x');
expect(pm.has('x')).toBe(false);
});
test('enable / disable / isEnabled', () => {
const pm = new PluginManager();
pm.register('x', { name: 'x' });
expect(pm.isEnabled('x')).toBe(true);
pm.disable('x');
expect(pm.isEnabled('x')).toBe(false);
pm.enable('x');
expect(pm.isEnabled('x')).toBe(true);
});
test('getAll / getNames', () => {
const pm = new PluginManager();
pm.register('a', { name: 'a' });
pm.register('b', { name: 'b' });
expect(pm.getAll().length).toBe(2);
expect(pm.getNames()).toEqual(['a', 'b']);
});
test('destroy 清空', () => {
const pm = new PluginManager();
pm.register('a', { name: 'a' });
pm.destroy();
expect(pm.getAll().length).toBe(0);
});
});
describe('pluginUtils', () => {
test('createManager 返回 PluginManager 实例', () => {
expect(pluginUtils.createManager()).toBeInstanceOf(PluginManager);
});
test('getPreset 返回预设插件副本', () => {
const p = pluginUtils.getPreset('autoSave');
expect(p).not.toBeNull();
expect(p.name).toBe('autoSave');
// 副本:修改不影响原对象
p.name = 'changed';
expect(presetPlugins.autoSave.name).toBe('autoSave');
});
test('getPreset 未知返回 null', () => {
expect(pluginUtils.getPreset('nope')).toBeNull();
});
test('getAllPresets 返回全部副本', () => {
const all = pluginUtils.getAllPresets();
expect(all.autoSave).toBeDefined();
expect(all.exportTool).toBeDefined();
expect(all.searchReplace).toBeDefined();
});
test('createPlugin 构造插件对象', () => {
const p = pluginUtils.createPlugin({
name: 'my',
install: () => {},
});
expect(p.name).toBe('my');
expect(typeof p.install).toBe('function');
expect(typeof p.destroy).toBe('function'); // 默认提供空 destroy
});
test('validatePlugin 校验', () => {
expect(pluginUtils.validatePlugin({ name: 'ok', install: () => {} }).valid).toBe(true);
expect(pluginUtils.validatePlugin(null).valid).toBe(false);
expect(pluginUtils.validatePlugin({ name: 'x', install: 'notfn' }).valid).toBe(false);
});
});
// ============ 补充分支测试 ============
describe('autoSave 插件 - 边界', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
localStorage.clear();
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: '' });
});
afterEach(() => ed.destroy());
test('未安装插件时 restoreDraft/clearDraft/getDraftKey 不存在', () => {
// 插件方法只在 install 时挂载到 editor 上
expect(ed.restoreDraft).toBeUndefined();
expect(ed.clearDraft).toBeUndefined();
expect(ed.getDraftKey).toBeUndefined();
});
test('安装后 getDraftKey 使用配置 key', () => {
ed.use(presetPlugins.autoSave, { key: 'my-draft' });
expect(ed.getDraftKey()).toBe('my-draft');
});
test('安装后 getDraftKey 默认 key 含 editor id', () => {
ed.use(presetPlugins.autoSave);
const k = ed.getDraftKey();
expect(k).toBe('me-draft-' + ed.id);
});
test('restoreDraft 读取并恢复内容', () => {
localStorage.setItem('my-draft', 'restored content');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
ed.restoreDraft();
expect(ed.getValue()).toBe('restored content');
});
test('restoreDraft 无草稿时不改变内容', () => {
ed.setValue('original');
ed.use(presetPlugins.autoSave, { key: 'empty-draft', delay: 10 });
const r = ed.restoreDraft();
expect(r).toBeNull();
expect(ed.getValue()).toBe('original');
});
test('restoreDraft 返回草稿值', () => {
localStorage.setItem('my-draft', 'val');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
expect(ed.restoreDraft()).toBe('val');
});
test('clearDraft 移除 localStorage 中的草稿', () => {
localStorage.setItem('my-draft', 'content');
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
ed.clearDraft();
expect(localStorage.getItem('my-draft')).toBeNull();
});
test('clearDraft 返回 editor(链式)', () => {
ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 });
expect(ed.clearDraft()).toBe(ed);
});
test('保存触发 autosave 事件', (done) => {
ed.use(presetPlugins.autoSave, { key: 'evt-draft', delay: 10 });
ed.on('autosave', (data) => {
expect(data.key).toBe('evt-draft');
expect(data.value).toBe('triggered');
done();
});
ed.setValue('triggered');
});
test('blur 事件立即保存', () => {
ed.use(presetPlugins.autoSave, { key: 'blur-draft', delay: 1000 });
ed.setValue('blur-content');
// blur 触发立即保存
ed.textarea.dispatchEvent(new Event('blur', { bubbles: true }));
expect(localStorage.getItem('blur-draft')).toBe('blur-content');
});
test('save 事件立即保存', () => {
ed.use(presetPlugins.autoSave, { key: 'save-draft', delay: 1000 });
ed.setValue('save-content');
ed._emit('save', ed.getValue());
expect(localStorage.getItem('save-draft')).toBe('save-content');
});
});
describe('exportTool 插件 - 边界', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: '# Hello' });
ed.use(presetPlugins.exportTool);
});
afterEach(() => ed.destroy());
test('未安装插件时 exportMarkdown/exportHTML 不存在', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed2 = new MarkdownEditor(c, { value: 'test' });
expect(ed2.exportMarkdown).toBeUndefined();
expect(ed2.exportHTML).toBeUndefined();
ed2.destroy();
});
test('exportMarkdown 默认文件名(含时间戳)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
// 捕获 download 属性
const origCreate = URL.createObjectURL;
let capturedName = null;
URL.createObjectURL = (blob) => {
return 'blob:mock';
};
// 监听 createElement 返回的 a 元素的 download
const origCE = document.createElement.bind(document);
const spy = jest.spyOn(document, 'createElement').mockImplementation((tag) => {
const el = origCE(tag);
if (tag === 'a') {
Object.defineProperty(el, 'download', {
set(v) { capturedName = v; },
get() { return capturedName; },
configurable: true,
});
el.click = () => {};
}
return el;
});
ed.exportMarkdown();
URL.createObjectURL = origCreate;
clickMock.mockRestore();
spy.mockRestore();
expect(capturedName).toMatch(/^metona-\d{8}-\d{4}\.md$/);
});
test('exportMarkdown 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportMarkdown('test.md')).toBe(ed);
clickMock.mockRestore();
});
test('exportHTML 返回 editor(链式)', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
expect(ed.exportHTML('test.html')).toBe(ed);
clickMock.mockRestore();
});
test('exportHTML 生成完整 HTML 文档', () => {
const clickMock = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
let capturedBlob = null;
const origCreate = URL.createObjectURL;
URL.createObjectURL = (blob) => {
capturedBlob = blob;
return 'blob:mock';
};
ed.exportHTML('doc.html', { title: 'My Doc', css: 'body{color:red}' });
URL.createObjectURL = origCreate;
clickMock.mockRestore();
expect(capturedBlob).not.toBeNull();
expect(capturedBlob.type).toBe('text/html;charset=utf-8');
});
});
describe('searchReplace 插件 - 完整流程', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'foo bar foo baz foo' });
ed.use(presetPlugins.searchReplace);
});
afterEach(() => ed.destroy());
function openPanel(showReplace = false) {
const ev = new KeyboardEvent('keydown', {
key: showReplace ? 'h' : 'f',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
ed.textarea.dispatchEvent(ev);
}
test('Ctrl+F 打开面板', () => {
openPanel();
expect(ed.el.querySelector('.me-search')).not.toBeNull();
});
test('Ctrl+H 打开替换面板', () => {
openPanel(true);
const panel = ed.el.querySelector('.me-search');
expect(panel).not.toBeNull();
expect(panel.dataset.replace).toBe('1');
});
test('面板已打开时 Ctrl+F 复用面板', () => {
openPanel();
const panel1 = ed.el.querySelector('.me-search');
openPanel();
const panel2 = ed.el.querySelector('.me-search');
expect(ed.el.querySelectorAll('.me-search').length).toBe(1);
expect(panel2).toBe(panel1);
});
test('面板已打开时 Ctrl+H 切换到替换模式', () => {
openPanel(false);
expect(ed.el.querySelector('.me-search').dataset.replace).toBe('0');
openPanel(true);
expect(ed.el.querySelector('.me-search').dataset.replace).toBe('1');
});
test('Escape 在 findInput 上关闭面板', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('查找下一个高亮匹配', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('查找上一个高亮匹配', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
ed.el.querySelector('.me-search-next').click();
ed.el.querySelector('.me-search-prev').click();
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('空查询不抛错', () => {
openPanel();
expect(() => ed.el.querySelector('.me-search-next').click()).not.toThrow();
});
test('关闭按钮移除面板', () => {
openPanel();
ed.el.querySelector('.me-search-close').click();
expect(ed.el.querySelector('.me-search')).toBeNull();
});
test('Enter 在 findInput 上查找下一个', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('Shift+Enter 在 findInput 上查找上一个', () => {
openPanel();
const input = ed.el.querySelector('.me-search-find');
input.value = 'foo';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('替换行 display 切换(none ↔ flex', () => {
openPanel(false);
const row = ed.el.querySelector('.me-search-replace-row');
expect(row.style.display).toBe('none');
openPanel(true);
expect(row.style.display).toBe('flex');
});
test('选中区域文本自动填入查找框', () => {
ed.textarea.setSelectionRange(0, 3); // 选中 "foo"
openPanel();
const input = ed.el.querySelector('.me-search-find');
expect(input.value).toBe('foo');
});
test('destroy 移除面板', () => {
openPanel();
const panelCount = ed.el.querySelectorAll('.me-search').length;
expect(panelCount).toBe(1);
ed.destroy();
expect(ed.el).toBeNull();
});
test('注入样式只执行一次', () => {
const styleBefore = document.getElementById('me-search-style');
const id1 = styleBefore ? styleBefore.id : null;
openPanel();
openPanel();
const styles = document.querySelectorAll('#me-search-style');
expect(styles.length).toBe(1);
});
});
describe('pluginUtils - 默认管理器代理', () => {
test('register / unregister / get / has / getNames', () => {
const p = { name: 'proxy-test', install: () => {}, destroy: () => {} };
pluginUtils.register('proxy-test', p);
expect(pluginUtils.has('proxy-test')).toBe(true);
// get 返回包装后的对象(含 enabled 字段),不是原始 p
const got = pluginUtils.get('proxy-test');
expect(got).not.toBeNull();
expect(got.name).toBe('proxy-test');
expect(got.enabled).toBe(true);
expect(pluginUtils.getNames()).toContain('proxy-test');
pluginUtils.unregister('proxy-test');
expect(pluginUtils.has('proxy-test')).toBe(false);
});
test('getAll 返回数组', () => {
const p = { name: 'arr-test', install: () => {} };
pluginUtils.register('arr-test', p);
const all = pluginUtils.getAll();
expect(Array.isArray(all)).toBe(true);
expect(all.some((x) => x.name === 'arr-test')).toBe(true);
pluginUtils.unregister('arr-test');
});
test('enable / disable / isEnabled', () => {
const p = { name: 'en-test', install: () => {} };
pluginUtils.register('en-test', p);
expect(pluginUtils.isEnabled('en-test')).toBe(true);
pluginUtils.disable('en-test');
expect(pluginUtils.isEnabled('en-test')).toBe(false);
pluginUtils.enable('en-test');
expect(pluginUtils.isEnabled('en-test')).toBe(true);
pluginUtils.unregister('en-test');
});
test('manager 暴露默认管理器实例', () => {
expect(pluginUtils.manager).toBeDefined();
expect(typeof pluginUtils.manager.register).toBe('function');
});
test('createManager 返回新管理器实例', () => {
const m = pluginUtils.createManager();
expect(m).not.toBe(pluginUtils.manager);
expect(typeof m.register).toBe('function');
});
test('register 返回 this(链式)', () => {
const m = pluginUtils.createManager();
const p = { name: 'chain', install: () => {} };
expect(m.register('chain', p)).toBe(m);
});
test('重复注册 warn 但不抛错', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const m = pluginUtils.createManager();
const p1 = { name: 'dup', install: () => {} };
const p2 = { name: 'dup', install: () => {} };
m.register('dup', p1);
m.register('dup', p2);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('unregister / enable / disable 返回 this(链式)', () => {
const m = pluginUtils.createManager();
const p = { name: 'ret', install: () => {} };
m.register('ret', p);
expect(m.unregister('ret')).toBe(m);
m.register('ret2', p);
expect(m.enable('ret2')).toBe(m);
expect(m.disable('ret2')).toBe(m);
});
test('get 未知返回 null', () => {
expect(pluginUtils.get('non-existent')).toBeNull();
});
test('isEnabled 未知返回 false', () => {
expect(pluginUtils.isEnabled('non-existent')).toBe(false);
});
test('disable 后 get 仍返回对象(enabled=false', () => {
const m = pluginUtils.createManager();
const p = { name: 'dis', install: () => {} };
m.register('dis', p);
m.disable('dis');
const got = m.get('dis');
expect(got).not.toBeNull();
expect(got.enabled).toBe(false);
m.enable('dis');
expect(m.get('dis').enabled).toBe(true);
});
test('destroy 清空所有插件', () => {
const m = pluginUtils.createManager();
m.register('p1', { name: 'p1', install: () => {} });
m.register('p2', { name: 'p2', install: () => {} });
m.destroy();
expect(m.getNames()).toEqual([]);
});
});
describe('createPlugin - 边界', () => {
test('无参数也能创建', () => {
const p = pluginUtils.createPlugin();
expect(p).toBeDefined();
expect(p.name).toBe('custom');
expect(typeof p.install).toBe('function');
expect(typeof p.destroy).toBe('function');
});
test('install 非函数时回退为空函数', () => {
const p = pluginUtils.createPlugin({ name: 'x', install: 'notfn' });
expect(typeof p.install).toBe('function');
});
test('destroy 非函数时回退为空函数', () => {
const p = pluginUtils.createPlugin({ name: 'x', destroy: 'notfn' });
expect(typeof p.destroy).toBe('function');
});
test('保留用户传入的额外字段', () => {
const p = pluginUtils.createPlugin({
name: 'x',
install: () => {},
customField: 'value',
});
expect(p.customField).toBe('value');
});
});
describe('零散分支补全', () => {
test('PluginManager.register 无效插件对象 error', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const m = pluginUtils.createManager();
m.register('null', null);
m.register('num', 123);
// name 为 falsy 且 plugin 无 name 才触发无效分支
m.register(undefined, { install: () => {} });
m.register('', { install: () => {} });
expect(spy).toHaveBeenCalled();
expect(m.get('null')).toBeNull();
expect(m.get('num')).toBeNull();
expect(m.get(undefined)).toBeNull();
expect(m.get('')).toBeNull();
spy.mockRestore();
});
test('autoSave save 内部抛错时 warn 不崩溃', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.use('autoSave');
// 让 editor.getValue 抛错,触发 save 的 catch 分支
jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); });
const plugin = ed.getPlugins().find((p) => p.name === 'autoSave');
expect(() => plugin._save()).not.toThrow();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('restoreDraft localStorage.getItem 抛错时返回 null', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'init' });
ed.use('autoSave');
const original = localStorage.getItem;
localStorage.getItem = jest.fn(() => { throw new Error('read error'); });
expect(ed.restoreDraft()).toBeNull();
expect(ed.getValue()).toBe('init');
localStorage.getItem = original;
ed.destroy();
});
test('searchReplace textarea 上 Escape 触发 _close', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
ed.use(presetPlugins.searchReplace);
const plugin = ed.getPlugins().find((p) => p.name === 'searchReplace');
// Ctrl+F 打开面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).not.toBeNull();
expect(plugin._panel).toBeDefined();
// spy _close 验证 textarea 上 Ctrl+Escape 分支被触发
// 注:plugins.js 中 escape 分支在 if (!mod) return 之后,必须带 Ctrl/Meta
const closeSpy = jest.spyOn(plugin, '_close');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
expect(closeSpy).toHaveBeenCalled();
closeSpy.mockRestore();
ed.destroy();
});
test('searchReplace replaceInput Enter 替换当前匹配', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'foo bar foo' });
ed.use(presetPlugins.searchReplace);
// Ctrl+H 打开替换面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const findInput = ed.el.querySelector('.me-search-find');
const replaceInput = ed.el.querySelector('.me-search-replace');
findInput.value = 'foo';
findInput.dispatchEvent(new Event('input', { bubbles: true }));
replaceInput.value = 'baz';
// 先查找下一个选中第一个 foo
ed.el.querySelector('.me-search-next').click();
// 在 replaceInput 上按 Enter 触发 replaceOne
replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toContain('baz');
ed.destroy();
});
test('searchReplace replaceInput Escape 关闭面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.use(presetPlugins.searchReplace);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true }));
const replaceInput = ed.el.querySelector('.me-search-replace');
replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.el.querySelector('.me-search')).toBeNull();
ed.destroy();
});
});
+433
View File
@@ -0,0 +1,433 @@
/**
* themes.js 单元测试
* 覆盖主题系统:解析 / 切换 / 存储 / 监听 / 自定义主题注册
*
* 注意:themes.js 使用模块级状态(currentTheme / themeListeners),
* 测试间需要重置状态以避免相互影响。
*/
import {
themeUtils,
getSystemTheme,
resolveTheme,
getThemeConfig,
applyTheme,
getCurrentTheme,
getResolvedTheme,
switchTheme,
toggleTheme,
resetToAuto,
saveTheme,
loadTheme,
initTheme,
watchSystemTheme,
unwatchSystemTheme,
addThemeListener,
removeThemeListener,
clearThemeListeners,
registerTheme,
unregisterTheme,
getAllThemes,
getThemeNames,
hasTheme,
getTheme,
setThemeVariables,
presetThemes,
createThemeManager,
} from '../src/themes.js';
import { THEMES } from '../src/constants.js';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
const store = {};
global.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
};
}
// jsdom 默认无 matchMedia,需 mock 以测试 watchSystemTheme
const mockMatchMedia = (matches = false) => ({
matches,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
describe('主题解析', () => {
test('resolveTheme 非 auto 原样返回', () => {
expect(resolveTheme('light')).toBe('light');
expect(resolveTheme('dark')).toBe('dark');
expect(resolveTheme('warm')).toBe('warm');
});
test('resolveTheme auto 返回系统主题', () => {
// currentTheme 默认为 'auto',调用 resolveTheme('auto') 会返回 getSystemTheme()
const r = resolveTheme('auto');
expect(['light', 'dark']).toContain(r);
});
test('getTheme 是 resolveTheme 的别名', () => {
expect(getTheme('light')).toBe(resolveTheme('light'));
});
test('getSystemTheme 返回 light 或 dark', () => {
const t = getSystemTheme();
expect(['light', 'dark']).toContain(t);
});
test('getThemeConfig 已知主题返回配置对象', () => {
const c = getThemeConfig('light');
expect(c).toBe(THEMES.light);
expect(c.bg).toBeDefined();
expect(c.accent).toBeDefined();
});
test('getThemeConfig 未知主题回退到 light', () => {
const c = getThemeConfig('unknown-theme');
expect(c).toBe(THEMES.light);
});
});
describe('主题切换与应用', () => {
beforeEach(() => {
clearThemeListeners();
resetToAuto();
localStorage.clear();
});
test('applyTheme 设置 currentTheme', () => {
applyTheme('dark');
expect(getCurrentTheme()).toBe('dark');
});
test('applyTheme 在 documentElement 上设置 data-md-theme 属性', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-md-theme')).toBe('dark');
});
test('applyTheme 切换 md-theme-* class', () => {
applyTheme('light');
expect(document.documentElement.classList.contains('md-theme-light')).toBe(true);
expect(document.documentElement.classList.contains('md-theme-dark')).toBe(false);
applyTheme('dark');
expect(document.documentElement.classList.contains('md-theme-dark')).toBe(true);
expect(document.documentElement.classList.contains('md-theme-light')).toBe(false);
});
test('applyTheme 设置 CSS 变量', () => {
applyTheme('dark');
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe(THEMES.dark.bg);
expect(root.style.getPropertyValue('--md-accent')).toBe(THEMES.dark.accent);
});
test('switchTheme 应用并持久化', () => {
switchTheme('dark');
expect(getCurrentTheme()).toBe('dark');
expect(localStorage.getItem('metona-editor-theme')).toBe('dark');
});
test('toggleTheme 在 light/dark 间切换', () => {
switchTheme('light');
toggleTheme();
expect(getCurrentTheme()).toBe('dark');
toggleTheme();
expect(getCurrentTheme()).toBe('light');
});
test('resetToAuto 切回 auto', () => {
switchTheme('dark');
resetToAuto();
expect(getCurrentTheme()).toBe('auto');
});
test('getResolvedTheme 返回解析后的实际主题', () => {
switchTheme('dark');
expect(getResolvedTheme()).toBe('dark');
switchTheme('light');
expect(getResolvedTheme()).toBe('light');
});
});
describe('主题持久化', () => {
beforeEach(() => {
localStorage.clear();
resetToAuto();
});
test('saveTheme 写入 localStorage', () => {
saveTheme('dark');
expect(localStorage.getItem('metona-editor-theme')).toBe('dark');
});
test('loadTheme 默认返回 auto', () => {
expect(loadTheme()).toBe('auto');
});
test('loadTheme 读取已保存的值', () => {
localStorage.setItem('metona-editor-theme', 'dark');
expect(loadTheme()).toBe('dark');
});
test('initTheme 加载已保存的主题', () => {
localStorage.setItem('metona-editor-theme', 'dark');
initTheme();
expect(getCurrentTheme()).toBe('dark');
});
test('initTheme 无保存时默认 auto', () => {
initTheme();
expect(getCurrentTheme()).toBe('auto');
});
});
describe('主题监听', () => {
beforeEach(() => {
clearThemeListeners();
resetToAuto();
});
test('addThemeListener 注册回调', () => {
const fn = jest.fn();
addThemeListener(fn);
switchTheme('dark');
expect(fn).toHaveBeenCalledWith('dark', 'dark');
});
test('addThemeListener 返回取消函数', () => {
const fn = jest.fn();
const unsub = addThemeListener(fn);
expect(typeof unsub).toBe('function');
unsub();
switchTheme('dark');
expect(fn).not.toHaveBeenCalled();
});
test('removeThemeListener 移除回调', () => {
const fn = jest.fn();
addThemeListener(fn);
removeThemeListener(fn);
switchTheme('dark');
expect(fn).not.toHaveBeenCalled();
});
test('clearThemeListeners 清空所有回调', () => {
const fn1 = jest.fn();
const fn2 = jest.fn();
addThemeListener(fn1);
addThemeListener(fn2);
clearThemeListeners();
switchTheme('dark');
expect(fn1).not.toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
});
test('监听器抛错不影响其他监听器', () => {
const errFn = jest.fn(() => { throw new Error('boom'); });
const okFn = jest.fn();
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
addThemeListener(errFn);
addThemeListener(okFn);
switchTheme('dark');
expect(errFn).toHaveBeenCalled();
expect(okFn).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('系统主题监听', () => {
beforeEach(() => {
unwatchSystemTheme();
resetToAuto();
});
afterEach(() => {
unwatchSystemTheme();
});
test('watchSystemTheme 无 matchMedia 时不抛错', () => {
const orig = window.matchMedia;
delete window.matchMedia;
expect(() => watchSystemTheme()).not.toThrow();
window.matchMedia = orig;
});
test('watchSystemTheme 注册 matchMedia 监听', () => {
const orig = window.matchMedia;
const mql = mockMatchMedia(false);
window.matchMedia = jest.fn(() => mql);
watchSystemTheme();
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
window.matchMedia = orig;
});
test('unwatchSystemTheme 移除监听', () => {
const orig = window.matchMedia;
const mql = mockMatchMedia(false);
window.matchMedia = jest.fn(() => mql);
watchSystemTheme();
unwatchSystemTheme();
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
window.matchMedia = orig;
});
test('重复 watchSystemTheme 替换旧监听', () => {
const orig = window.matchMedia;
const mql1 = mockMatchMedia(false);
const mql2 = mockMatchMedia(false);
let callCount = 0;
window.matchMedia = jest.fn(() => {
callCount += 1;
return callCount === 1 ? mql1 : mql2;
});
watchSystemTheme();
watchSystemTheme();
expect(mql1.removeEventListener).toHaveBeenCalled();
window.matchMedia = orig;
});
});
describe('自定义主题注册', () => {
beforeEach(() => {
resetToAuto();
});
test('registerTheme 注册新主题', () => {
registerTheme('ocean', {
bg: '#001122',
text: '#aabbcc',
accent: '#00ddff',
});
expect(hasTheme('ocean')).toBe(true);
const c = getThemeConfig('ocean');
expect(c.bg).toBe('#001122');
expect(c.accent).toBe('#00ddff');
// 未提供字段从 light 主题继承
expect(c.muted).toBe(THEMES.light.muted);
});
test('unregisterTheme 移除自定义主题', () => {
registerTheme('tmp', { bg: '#000' });
expect(hasTheme('tmp')).toBe(true);
unregisterTheme('tmp');
expect(hasTheme('tmp')).toBe(false);
});
test('unregisterTheme 拒绝移除内置主题', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
unregisterTheme('light');
expect(spy).toHaveBeenCalled();
expect(hasTheme('light')).toBe(true);
spy.mockRestore();
});
test('getAllThemes 返回所有主题副本', () => {
const all = getAllThemes();
expect(all.light).toEqual(THEMES.light);
expect(all.dark).toEqual(THEMES.dark);
// 副本:修改不影响原始
all.light = null;
expect(THEMES.light).toBeDefined();
});
test('getThemeNames 返回主题名数组', () => {
const names = getThemeNames();
expect(names).toContain('light');
expect(names).toContain('dark');
expect(names).toContain('auto');
expect(names).toContain('warm');
});
test('hasTheme 检查主题存在', () => {
expect(hasTheme('light')).toBe(true);
expect(hasTheme('non-existent')).toBe(false);
});
});
describe('setThemeVariables', () => {
test('设置 CSS 变量到 documentElement', () => {
setThemeVariables({
bg: '#fff',
text: '#000',
border: '#eee',
shadow: 'none',
hoverShadow: 'none',
accent: '#abc',
});
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe('#fff');
expect(root.style.getPropertyValue('--md-accent')).toBe('#abc');
});
test('空 config 不抛错', () => {
expect(() => setThemeVariables(null)).not.toThrow();
expect(() => setThemeVariables(undefined)).not.toThrow();
});
test('部分字段使用默认值', () => {
setThemeVariables({ bg: '#fafafa' });
const root = document.documentElement;
expect(root.style.getPropertyValue('--md-bg')).toBe('#fafafa');
// 未提供的 accent 使用默认
expect(root.style.getPropertyValue('--md-accent')).toBe('#3b82f6');
});
});
describe('themeUtils 代理层', () => {
test('暴露所有主题 API', () => {
[
'getSystemTheme', 'resolveTheme', 'getThemeConfig', 'applyTheme',
'getCurrentTheme', 'getResolvedTheme', 'switchTheme', 'toggleTheme',
'resetToAuto', 'initTheme', 'watchSystemTheme', 'unwatchSystemTheme',
'addThemeListener', 'removeThemeListener', 'clearThemeListeners',
'registerTheme', 'unregisterTheme', 'getAllThemes', 'getThemeNames',
'hasTheme', 'saveTheme', 'loadTheme',
].forEach((fn) => {
expect(typeof themeUtils[fn]).toBe('function');
});
});
test('代理方法与具名导出一致', () => {
expect(themeUtils.getCurrentTheme).toBe(getCurrentTheme);
expect(themeUtils.switchTheme).toBe(switchTheme);
});
});
describe('presetThemes', () => {
test('包含 light/dark/auto/warm', () => {
expect(presetThemes.light).toBeDefined();
expect(presetThemes.dark).toBeDefined();
expect(presetThemes.auto).toBeDefined();
expect(presetThemes.warm).toBeDefined();
});
test('auto 主题 config 为字符串 "auto"', () => {
expect(presetThemes.auto.config).toBe('auto');
});
test('light/dark/warm 主题 config 为对象', () => {
expect(typeof presetThemes.light.config).toBe('object');
expect(typeof presetThemes.dark.config).toBe('object');
expect(typeof presetThemes.warm.config).toBe('object');
});
});
describe('createThemeManager', () => {
test('返回包含全部方法的管理器对象', () => {
const m = createThemeManager();
expect(typeof m.switchTheme).toBe('function');
expect(typeof m.getCurrentTheme).toBe('function');
expect(typeof m.registerTheme).toBe('function');
});
test('管理器方法可独立调用', () => {
const m = createThemeManager();
m.switchTheme('dark');
expect(m.getCurrentTheme()).toBe('dark');
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* utils.js 单元测试
* 覆盖所有工具函数
*/
import {
generateId,
escapeHTML,
prefersDark,
debounce,
throttle,
deepMerge,
isBrowser,
formatFileSize,
sleep,
} from '../src/utils.js';
describe('generateId', () => {
test('返回字符串', () => {
expect(typeof generateId()).toBe('string');
});
test('以 me- 前缀开头', () => {
expect(generateId()).toMatch(/^me-/);
});
test('多次调用产生不同值', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) ids.add(generateId());
expect(ids.size).toBe(100);
});
});
describe('escapeHTML', () => {
test('转义 < > &', () => {
const out = escapeHTML('<a>&b</a>');
expect(out).toContain('&lt;');
expect(out).toContain('&gt;');
expect(out).toContain('&amp;');
expect(out).not.toContain('<a>');
});
test('null/undefined 转空字符串', () => {
expect(escapeHTML(null)).toBe('');
expect(escapeHTML(undefined)).toBe('');
});
test('数字被转为字符串', () => {
expect(escapeHTML(123)).toBe('123');
});
test('无特殊字符原样返回', () => {
expect(escapeHTML('hello world')).toBe('hello world');
});
});
describe('prefersDark', () => {
test('返回布尔值', () => {
expect(typeof prefersDark()).toBe('boolean');
});
});
describe('debounce', () => {
test('延迟执行', (done) => {
let called = 0;
const fn = debounce(() => { called++; }, 50);
fn();
expect(called).toBe(0);
setTimeout(() => {
expect(called).toBe(1);
done();
}, 100);
});
test('多次调用只执行最后一次', (done) => {
let result = 0;
const fn = debounce((v) => { result = v; }, 50);
fn(1);
fn(2);
fn(3);
setTimeout(() => {
expect(result).toBe(3);
done();
}, 100);
});
test('immediate 模式立即执行', () => {
let called = 0;
const fn = debounce(() => { called++; }, 50, true);
fn();
expect(called).toBe(1);
});
test('保留 this 上下文', (done) => {
const obj = {
val: 42,
getVal: debounce(function () { return this.val; }, 30),
};
const p = obj.getVal();
// immediate=false 时首次不返回,但 this 应正确
setTimeout(() => {
expect(typeof p).toBe('undefined'); // debounce 返回的是 undefined(异步执行)
done();
}, 60);
});
test('传递参数', (done) => {
let captured = null;
const fn = debounce((a, b) => { captured = a + b; }, 30);
fn(1, 2);
setTimeout(() => {
expect(captured).toBe(3);
done();
}, 60);
});
});
describe('throttle', () => {
test('首次立即执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
expect(called).toBe(1);
});
test('限流期内不重复执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
fn();
fn();
expect(called).toBe(1);
});
test('限流期后可再次执行', (done) => {
let called = 0;
const fn = throttle(() => { called++; }, 40);
fn();
setTimeout(() => {
fn();
expect(called).toBe(2);
done();
}, 60);
});
test('保留 this 与参数', (done) => {
const obj = {
n: 10,
run: throttle(function (x) { this.result = this.n + x; }, 30),
};
obj.run(5);
expect(obj.result).toBe(15);
done();
});
});
describe('deepMerge', () => {
test('浅层合并', () => {
const out = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
expect(out).toEqual({ a: 1, b: 3, c: 4 });
});
test('深层对象合并', () => {
const out = deepMerge({ obj: { x: 1, y: 2 } }, { obj: { y: 3, z: 4 } });
expect(out).toEqual({ obj: { x: 1, y: 3, z: 4 } });
});
test('source 覆盖 target 非对象值', () => {
const out = deepMerge({ a: { x: 1 } }, { a: 'str' });
expect(out.a).toBe('str');
});
test('不修改原始 target', () => {
const target = { a: { x: 1 } };
const source = { a: { y: 2 } };
deepMerge(target, source);
expect(target.a).toEqual({ x: 1 });
});
test('多层嵌套合并', () => {
const out = deepMerge(
{ a: { b: { c: 1, d: 2 } } },
{ a: { b: { d: 3, e: 4 } } }
);
expect(out).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } });
});
});
describe('isBrowser', () => {
test('jsdom 环境下返回 true', () => {
expect(isBrowser()).toBe(true);
});
});
describe('formatFileSize', () => {
test('0 返回 0 Bytes', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
});
test('Bytes 档位', () => {
expect(formatFileSize(512)).toBe('512 Bytes');
});
test('KB 档位', () => {
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
});
test('MB 档位', () => {
expect(formatFileSize(1048576)).toBe('1 MB');
});
test('GB 档位', () => {
expect(formatFileSize(1073741824)).toBe('1 GB');
});
test('小数位数控制', () => {
expect(formatFileSize(1536, 0)).toBe('2 KB');
expect(formatFileSize(1536, 3)).toBe('1.5 KB');
});
test('负 decimals 当作 0', () => {
expect(formatFileSize(1536, -1)).toBe('2 KB');
});
});
describe('sleep', () => {
test('返回 Promise', () => {
expect(sleep(10)).toBeInstanceOf(Promise);
});
test('延迟后 resolve', (done) => {
const start = Date.now();
sleep(50).then(() => {
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(40);
done();
});
});
test('async/await 用法', async () => {
const start = Date.now();
await sleep(30);
expect(Date.now() - start).toBeGreaterThanOrEqual(20);
});
});
+366
View File
@@ -0,0 +1,366 @@
// Type definitions for MetonaEditor
// Project: https://git.metona.cn/MetonaTeam/MetonaToast
// Author: thzxx
declare const VERSION: string;
/** 视图模式 */
export type EditMode = 'edit' | 'split' | 'preview';
/** 主题名 */
export type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
/** 渲染环境,传给自定义 render 函数 */
export interface RenderEnv {
highlight?: (code: string, lang: string) => string;
locale?: string;
[key: string]: unknown;
}
/** 工具栏项:动作名或 '|'(分隔符) */
export type ToolbarItem = string | '|';
/** 编辑器配置 */
export interface MarkdownEditorOptions {
/** 初始内容 */
value?: string;
/** 占位符 */
placeholder?: string;
/** 视图模式,默认 'split' */
mode?: EditMode;
/** 高度:数字为 px,字符串原样使用,默认 400 */
height?: number | string;
/** 工具栏按钮序列,传 false 隐藏工具栏 */
toolbar?: ToolbarItem[] | false;
/** 是否显示字数统计状态栏,默认 true */
wordCount?: boolean;
/** 自动聚焦,默认 false */
autofocus?: boolean;
/** 拼写检查,默认 false */
spellcheck?: boolean;
/** 历史栈上限,默认 100 */
historyLimit?: number;
/** 分屏模式下同步滚动,默认 true */
syncScroll?: boolean;
/** Tab 插入的空格数,0 表示插入 \t,默认 2 */
tabSize?: number;
/** 主题,默认 'auto' */
theme?: ThemeName;
/** 国际化语言,默认 'zh-CN' */
locale?: string;
/** 自定义 Markdown 渲染函数,覆盖内置解析器 */
render?: (markdown: string, env: RenderEnv) => string;
/** 自定义代码高亮函数 */
highlight?: (code: string, lang: string) => string;
/** 自定义 HTML 净化函数(接收渲染后 HTML,返回安全 HTML) */
sanitize?: (html: string) => string;
/** 自定义容器 className */
className?: string;
/** 容器内联样式对象 */
style?: { [key: string]: string };
/** 实例级插件列表 */
plugins?: Array<string | PluginObject>;
/** 唯一 id */
id?: string;
// 生命周期回调
onCreate?: (editor: MarkdownEditor) => void;
onDestroy?: (editor: MarkdownEditor) => void;
onInput?: (value: string, editor: MarkdownEditor) => void;
onChange?: (value: string, editor: MarkdownEditor) => void;
onFocus?: (editor: MarkdownEditor) => void;
onBlur?: (editor: MarkdownEditor) => void;
onSave?: (value: string, editor: MarkdownEditor) => void;
onModeChange?: (mode: EditMode, editor: MarkdownEditor) => void;
onFullscreen?: (fullscreen: boolean, editor: MarkdownEditor) => void;
}
/** 插件对象 */
export interface PluginObject {
name: string;
description?: string;
[key: string]: any;
install?: (editor: MarkdownEditor) => void;
destroy?: (editor: MarkdownEditor) => void;
}
/** 工具栏按钮配置(addToolbarButton 用) */
export interface ToolbarButtonConfig {
action: string;
title?: string;
text?: string;
icon?: string;
onClick?: (editor: MarkdownEditor) => void;
handler?: (...args: any[]) => void;
}
/** 统计信息 */
export interface EditorStats {
characters: number;
words: number;
chineseChars: number;
englishWords: number;
lines: number;
readingTime: number;
}
/** 编辑器状态 */
export interface EditorStatus {
id: string;
mode: EditMode;
theme: ThemeName;
locale: string;
fullscreen: boolean;
disabled: boolean;
destroyed: boolean;
plugins: string[];
}
/** 事件名 */
export type EditorEventName =
| 'input' | 'change' | 'focus' | 'blur' | 'save'
| 'modeChange' | 'fullscreen' | 'autosave' | 'destroy';
/** 全局钩子名 */
export type HookName =
| 'beforeCreate' | 'afterCreate'
| 'beforeRender' | 'afterRender'
| 'beforeDestroy' | 'afterDestroy';
/** MarkdownEditor 编辑器类 */
export class MarkdownEditor {
constructor(container: string | HTMLElement, options?: MarkdownEditorOptions);
/** 实例 id */
readonly id: string;
/** 容器元素 */
readonly container: HTMLElement;
/** 配置(只读视图) */
readonly config: MarkdownEditorOptions;
/** 根元素 */
readonly el: HTMLElement;
readonly toolbarEl: HTMLElement;
readonly bodyEl: HTMLElement;
readonly editorPane: HTMLElement;
readonly previewPane: HTMLElement;
readonly dividerEl: HTMLElement;
readonly textarea: HTMLTextAreaElement;
readonly previewEl: HTMLElement;
readonly statusEl: HTMLElement | null;
// 静态钩子
static on(name: HookName, fn: (editor: MarkdownEditor) => void): () => void;
static off(name: HookName, fn: (editor: MarkdownEditor) => void): void;
static trigger(name: HookName, editor: MarkdownEditor): void;
// 内容
getValue(): string;
setValue(markdown: string, opts?: { silent?: boolean }): this;
getHTML(): string;
insert(text: string, opts?: { replace?: boolean }): this;
wrap(before: string, after?: string): this;
// 选区/焦点
focus(): this;
blur(): this;
enable(): this;
disable(): this;
isDisabled(): boolean;
// 事件
on(name: EditorEventName, fn: (...args: any[]) => void): () => void;
off(name: EditorEventName, fn: (...args: any[]) => void): this;
// 命令
exec(action: string, ...args: any[]): this;
// 历史栈
undo(): this;
redo(): this;
canUndo(): boolean;
canRedo(): boolean;
// 模式与全屏
setMode(mode: EditMode): this;
getMode(): EditMode;
toggleFullscreen(): this;
isFullscreen(): boolean;
exitFullscreen(): this;
// 统计与状态
getStats(): EditorStats;
getStatus(): EditorStatus;
// 插件
use(plugin: string | PluginObject, options?: object): this;
getPlugins(): PluginObject[];
addToolbarButton(config: ToolbarButtonConfig): this;
// 生命周期
destroy(): void;
isDestroyed(): boolean;
}
/** MarkdownEditor 别名 */
export const Editor: typeof MarkdownEditor;
/** 工厂函数:创建编辑器实例 */
export function create(container: string | HTMLElement, options?: MarkdownEditorOptions): MarkdownEditor;
/** 注册全局默认插件(作用于所有后续创建的实例) */
export function use(plugin: string | PluginObject, options?: object): typeof api;
/** 注册全局事件钩子 */
export function on(name: HookName, fn: (editor: MarkdownEditor) => void): () => void;
/** 注销全局事件钩子 */
export function off(name: HookName, fn: (editor: MarkdownEditor) => void): typeof api;
/** 切换全局主题 */
export function setTheme(theme: ThemeName, options?: object): typeof api;
/** 切换全局语言 */
export function setLocale(locale: string): typeof api;
/** 销毁所有全局资源 */
export function destroy(): void;
/** 获取库运行状态 */
export function getStatus(): {
version: string;
theme: string;
locale: string;
globalPlugins: string[];
presetPlugins: string[];
};
// ============ 解析器 ============
/** Markdown 解析函数 */
export function parseMarkdown(markdown: string, env?: RenderEnv): string;
/** URL 安全过滤 */
export function safeUrl(url: string): string;
/** 生成标题锚点 id */
export function slugify(text: string): string;
// ============ 插件 ============
/** 预设插件表 */
export const presetPlugins: {
autoSave: PluginObject;
exportTool: PluginObject;
searchReplace: PluginObject;
[key: string]: PluginObject;
};
/** 插件工具 */
export const pluginUtils: {
createManager(): PluginManager;
register(name: string, plugin: PluginObject): PluginManager;
unregister(name: string): PluginManager;
get(name: string): PluginObject | null;
has(name: string): boolean;
getAll(): PluginObject[];
getNames(): string[];
enable(name: string): PluginManager;
disable(name: string): PluginManager;
isEnabled(name: string): boolean;
getPreset(name: string): PluginObject | null;
getAllPresets(): { [key: string]: PluginObject };
createPlugin(config: object): PluginObject;
validatePlugin(plugin: PluginObject): { valid: boolean; errors: string[] };
};
/** 插件管理器 */
export class PluginManager {
register(name: string, plugin: PluginObject): this;
unregister(name: string): this;
get(name: string): PluginObject | null;
has(name: string): boolean;
getAll(): PluginObject[];
getNames(): string[];
enable(name: string): this;
disable(name: string): this;
isEnabled(name: string): boolean;
destroy(): void;
}
// ============ 常量 ============
export const DEFAULTS: Readonly<MarkdownEditorOptions>;
export const ICONS: { [key: string]: string };
export const THEMES: { [key: string]: any };
export const EDIT_MODES: EditMode[];
export const DEFAULT_TOOLBAR: ToolbarItem[];
export const TOOLBAR_ACTIONS: string[];
// ============ 工具集 ============
export const themeUtils: {
initTheme(): void;
switchTheme(theme: ThemeName): void;
toggleTheme(): void;
getCurrentTheme(): string;
getResolvedTheme(): string;
applyTheme(theme: ThemeName): void;
registerTheme(name: string, config: object): void;
unregisterTheme(name: string): void;
getAllThemes(): object;
getThemeNames(): string[];
hasTheme(name: string): boolean;
watchSystemTheme(): void;
unwatchSystemTheme(): void;
addThemeListener(fn: (theme: string, resolved: string) => void): () => void;
removeThemeListener(fn: (theme: string, resolved: string) => void): void;
clearThemeListeners(): void;
[key: string]: any;
};
export const i18nUtils: {
initI18n(): void;
t(key: string, params?: object): string;
getCurrentLocale(): string;
setCurrentLocale(locale: string): void;
loadLocale(locale: string, messages: object): void;
addLocaleListener(fn: (locale: string) => void): () => void;
removeLocaleListener(fn: (locale: string) => void): void;
clearLocaleListeners(): void;
[key: string]: any;
};
export const animationUtils: {
cancelAll(): void;
[key: string]: any;
};
// ============ 默认导出 ============
export interface MeEditorAPI {
VERSION: string;
version: string;
MarkdownEditor: typeof MarkdownEditor;
Editor: typeof MarkdownEditor;
create: typeof create;
use: typeof use;
on: typeof on;
off: typeof off;
setTheme: typeof setTheme;
setLocale: typeof setLocale;
destroy: typeof destroy;
getStatus: typeof getStatus;
parseMarkdown: typeof parseMarkdown;
safeUrl: typeof safeUrl;
slugify: typeof slugify;
themes: typeof themeUtils;
i18n: typeof i18nUtils;
animations: typeof animationUtils;
plugins: typeof pluginUtils;
presetPlugins: typeof presetPlugins;
DEFAULTS: typeof DEFAULTS;
ICONS: typeof ICONS;
THEMES: typeof THEMES;
EDIT_MODES: typeof EDIT_MODES;
DEFAULT_TOOLBAR: typeof DEFAULT_TOOLBAR;
TOOLBAR_ACTIONS: typeof TOOLBAR_ACTIONS;
}
/** 默认导出 API 对象 */
declare const api: MeEditorAPI;
export default api;
export {
api,
api as meEditor,
api as MeEditor,
};