Initial commit: MetonaEditor v0.1.0
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
|
||||
# 本地 npm 发布凭据(含 _auth,勿提交)
|
||||
.npmrc
|
||||
@@ -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.
|
||||
@@ -0,0 +1,837 @@
|
||||
# MetonaEditor
|
||||
|
||||
> 轻量、零依赖、精致美观的 Markdown Editor 库。单文件,开箱即用,中文优先。
|
||||
|
||||
[](https://www.npmjs.com/package/@metona-team/metona-editor)
|
||||
[](./LICENSE)
|
||||
[](./tests)
|
||||
[](./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)、searchReplace(Ctrl+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) => safeHtml,HTML 净化钩子
|
||||
|
||||
// 外观
|
||||
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) => void(Ctrl+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>` |
|
||||
| 图片 | `` | `<img>` |
|
||||
| 自动链接 | `<https://...>` | `<a>` |
|
||||
|
||||
### XSS 防护
|
||||
|
||||
```javascript
|
||||
parseMarkdown('[click](javascript:alert(1))');
|
||||
// => '<p><a href="">click</a></p>'(危险协议被过滤)
|
||||
|
||||
parseMarkdown('<script>alert(1)</script>');
|
||||
// => '<p><script>alert(1)</script></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)
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
targets: {
|
||||
node: 'current',
|
||||
},
|
||||
modules: 'commonjs',
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-transform-modules-commonjs',
|
||||
],
|
||||
};
|
||||
@@ -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 ""
|
||||
@@ -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,
|
||||
};
|
||||
Generated
+7444
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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()],
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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) 与 ',
|
||||
'',
|
||||
'## 表格',
|
||||
'',
|
||||
'| 名称 | 值 |',
|
||||
'| --- | --- |',
|
||||
'| 版本 | 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
@@ -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>整个库不依赖任何第三方运行时,打包后单文件可直接 <script> 引入,也支持 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"><!-- 浏览器直接引入 --></span>
|
||||
<<span class="c-key">script</span> <span class="c-fn">src</span>=<span class="c-str">"dist/metona-editor.js"</span>></<span class="c-key">script</span>>
|
||||
<<span class="c-key">div</span> <span class="c-fn">id</span>=<span class="c-str">"editor"</span>></<span class="c-key">div</span>>
|
||||
<<span class="c-key">script</span>>
|
||||
<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>]
|
||||
});
|
||||
</<span class="c-key">script</span>>
|
||||
|
||||
<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) => <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>
|
||||
@@ -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)',
|
||||
});
|
||||
@@ -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
@@ -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 = ``;
|
||||
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
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
* - 图片 
|
||||
* - 转义字符 \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. 图片 
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/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+"([^&]*)")?\)/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(/<(https?:\/\/[^\s&]+)>/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
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* 插件工具
|
||||
*/
|
||||
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
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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));
|
||||
};
|
||||
@@ -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 始终返回 0(CSS 动画由浏览器管理)', () => {
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
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('<a>');
|
||||
});
|
||||
|
||||
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('');
|
||||
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('<script>');
|
||||
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('');
|
||||
expect(html).toContain('src="data:image/png;base64,abc"');
|
||||
});
|
||||
|
||||
test('图片 javascript: 协议被过滤', () => {
|
||||
const html = parseMarkdown(')');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('<');
|
||||
expect(out).toContain('>');
|
||||
expect(out).toContain('&');
|
||||
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);
|
||||
});
|
||||
});
|
||||
Vendored
+366
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user